diff --git a/.github/workflows/build_preview.yml b/.github/workflows/build_preview.yml index cfb9e824d..1ff2242d1 100644 --- a/.github/workflows/build_preview.yml +++ b/.github/workflows/build_preview.yml @@ -86,6 +86,13 @@ jobs: contents: ${{ secrets.GOOGLE_SERVICES_TEXT }} write-mode: overwrite + - name: Write client_secrets.json + uses: DamianReeves/write-file-action@v1.2 + with: + path: app/src/main/assets/client_secrets.json + contents: ${{ secrets.CLIENT_SECRETS_TEXT }} + write-mode: overwrite + - name: Build app if: ${{ ! startsWith(github.ref, 'refs/tags/') && github.repository == 'komikku-app/komikku' }} run: ./gradlew assembleStandardRelease testStandardReleaseUnitTest --stacktrace diff --git a/.github/workflows/build_push.yml b/.github/workflows/build_push.yml index fcd55962d..8d9925808 100644 --- a/.github/workflows/build_push.yml +++ b/.github/workflows/build_push.yml @@ -75,6 +75,13 @@ jobs: path: app/google-services.json contents: ${{ secrets.GOOGLE_SERVICES_TEXT }} write-mode: overwrite + + - name: Write client_secrets.json + uses: DamianReeves/write-file-action@v1.2 + with: + path: app/src/main/assets/client_secrets.json + contents: ${{ secrets.CLIENT_SECRETS_TEXT }} + write-mode: overwrite # SY --> - name: Build app and run unit tests diff --git a/.github/workflows/build_push_preview.yml b/.github/workflows/build_push_preview.yml index 493741bdd..94abbc817 100644 --- a/.github/workflows/build_push_preview.yml +++ b/.github/workflows/build_push_preview.yml @@ -18,17 +18,14 @@ jobs: - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 - - name: TAG - Bump version and push tag - uses: anothrNick/github-tag-action@1.67.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - WITH_V: true - RELEASE_BRANCHES: preview - DEFAULT_BUMP: patch + - name: Create Tag + run: | + git tag "preview-${{ github.run_number }}" + git push origin "preview-${{ github.run_number }}" - name: PING - Dispatch initiating repository event run: | - curl -X POST https://api.github.com/repos/jobobby04/TachiyomiSYPreview/dispatches \ + curl -X POST https://api.github.com/repos/komikku-app/KomikkuPreview/dispatches \ -H 'Accept: application/vnd.github.everest-preview+json' \ -u ${{ secrets.ACCESS_TOKEN }} \ --data '{"event_type": "ping", "client_payload": { "repository": "'"$GITHUB_REPOSITORY"'" }}' diff --git a/.gitignore b/.gitignore index b8b26f36d..2329333da 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ TODO.md CHANGELOG.md /captures build.sh +/app/src/main/assets/client_secrets.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbf8dffff..4ceda610b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,3 +52,20 @@ When creating a fork, remember to: - To avoid having your data polluting the main app's analytics and crash report services: - If you want to use Firebase analytics, replace [`google-services.json`](https://github.com/komikku-app/komikku/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/komikku-app/komikku/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/` diff --git a/README.md b/README.md index 8c3bc22b9..60e60d979 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # ![app icon](./.github/readme-images/app-icon.png) Komikku -Tachiyomi is a free and open source manga reader for Android 6.0 and above. This version of Tachiyomi, Komikku was based off TachiyomiSY. This version is meant to push forward in the ways of usability and features. Komikku 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 TachiyomiSY, J2K and Neko. +Mihon/Tachiyomi is a free and open source manga reader for Android 6.0 and above. This version of Mihon/Tachiyomi, Komikku was based off TachiyomiSY. 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 SY, J2K and Neko. ![screenshots of app](./.github/readme-images/screens.png) @@ -12,6 +12,7 @@ Tachiyomi is a free and open source manga reader for Android 6.0 and above. This Features of Komikku include: - [x] Built-in & official extensions repository +- [x] Show list of related titles (must enable in Settings/Browse) for all sources. Some sources might need fix for it to work. - [x] Bulk selection to add to library & change categories of multiple entries all at once, everywhere. It can detect duplication being added and give option to allow/skip one by one or allow/skip all duplication. Also allow long-click to add/remove single entry to/from library, everywhere. @@ -25,7 +26,7 @@ Also allow long-click to add/remove single entry to/from library, everywhere. - [ ] Support other sources just like E-Hentai: Lucious... - [ ] History of searched query -Features of Tachiyomi(original) include: +Features of Mihon/Tachiyomi(original) include: * Online reading from a variety of sources * Local reading of downloaded content * A configurable reader with multiple viewers, reading directions and other settings. @@ -57,7 +58,6 @@ Features of TachiyomiSY include: * Page preload customization * Customize image cache size * Batch import of custom sources and featured extensions -* Automatic CAPTCHA solving * Advanced source settings page, searching, enable/disable all * Click tag for local search, long click tag for global search * Merge multiple of the same manga from different sources diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 694cbf384..bf144b8ae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,12 @@ +import mihon.buildlogic.getBuildTime +import mihon.buildlogic.getCommitCount +import mihon.buildlogic.getGitSha import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - id("com.android.application") + id("mihon.android.application") + id("mihon.android.application.compose") id("com.mikepenz.aboutlibraries.plugin") - kotlin("android") kotlin("plugin.parcelize") kotlin("plugin.serialization") // id("com.github.zellius.shortcut-helper") @@ -26,7 +29,7 @@ android { defaultConfig { applicationId = "app.komikku" - versionCode = 65 + versionCode = 67 versionName = "1.10.5" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") @@ -120,7 +123,6 @@ android { buildFeatures { viewBinding = true - compose = true buildConfig = true // Disable some unused things @@ -133,10 +135,6 @@ android { abortOnError = false checkReleaseBuilds = false } - - composeOptions { - kotlinCompilerExtensionVersion = compose.versions.compiler.get() - } } dependencies { @@ -154,7 +152,6 @@ dependencies { implementation(projects.presentationWidget) // Compose - implementation(platform(compose.bom)) implementation(compose.activity) implementation(compose.foundation) implementation(compose.material3.core) @@ -165,7 +162,6 @@ dependencies { debugImplementation(compose.ui.tooling) implementation(compose.ui.tooling.preview) implementation(compose.ui.util) - implementation(compose.accompanist.webview) implementation(compose.accompanist.systemuicontroller) implementation(androidx.paging.runtime) @@ -215,7 +211,7 @@ dependencies { // Disk implementation(libs.disklrucache) implementation(libs.unifile) - implementation(libs.junrar) + implementation(libs.bundles.archive) // SY --> implementation(libs.zip4j) // SY <-- @@ -247,6 +243,12 @@ dependencies { implementation(libs.bundles.voyager) implementation(libs.compose.materialmotion) implementation(libs.swipe) + implementation(libs.compose.webview) + implementation(libs.compose.grid) + + + implementation(libs.google.api.services.drive) + implementation(libs.google.api.client.oauth) // Logging implementation(libs.logcat) @@ -265,6 +267,8 @@ dependencies { // debugImplementation(libs.leakcanary.android) implementation(libs.leakcanary.plumber) + testImplementation(kotlinx.coroutines.test) + // SY --> // Text distance (EH) implementation(sylibs.simularity) @@ -317,25 +321,6 @@ tasks { "-opt-in=kotlinx.coroutines.InternalCoroutinesApi", "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", ) - - if (project.findProperty("tachiyomi.enableComposeCompilerMetrics") == "true") { - kotlinOptions.freeCompilerArgs += listOf( - "-P", - "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + - project.layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath, - ) - kotlinOptions.freeCompilerArgs += listOf( - "-P", - "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + - project.layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath, - ) - } - - // https://developer.android.com/jetpack/androidx/releases/compose-compiler#1.5.9 - kotlinOptions.freeCompilerArgs += listOf( - "-P", - "plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true", - ) } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 072585f59..b8ae5c8ba 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,6 +2,7 @@ -keep,allowoptimization class eu.kanade.** -keep,allowoptimization class tachiyomi.** +-keep,allowoptimization class mihon.** # Keep common dependencies used in extensions -keep,allowoptimization class androidx.preference.** { public protected *; } @@ -122,10 +123,19 @@ # XmlUtil -keep public enum nl.adaptivity.xmlutil.EventType { *; } +# Apache Commons Compress +-keep class * extends org.apache.commons.compress.archivers.zip.ZipExtraField { (); } + # Firebase -keep class com.google.firebase.installations.** { *; } -keep interface com.google.firebase.installations.** { *; } +# Google Drive +-keep class com.google.api.services.** { *; } + +# Google OAuth +-keep class com.google.api.client.** { *; } + # SY --> # SqlCipher -keepclassmembers class net.zetetic.database.sqlcipher.SQLiteCustomFunction { *; } @@ -260,6 +270,9 @@ -keep,allowoptimization class * extends uy.kohesive.injekt.api.TypeReference -keep,allowoptimization public class io.requery.android.database.sqlite.SQLiteConnection { *; } + # Keep apache http client + -keep class org.apache.http.** { *; } + # Suggested rules -dontwarn com.oracle.svm.core.annotate.AutomaticFeature -dontwarn com.oracle.svm.core.annotate.Delete @@ -272,4 +285,16 @@ -dontwarn org.slf4j.impl.StaticLoggerBinder -dontwarn java.lang.Module -dontwarn org.graalvm.nativeimage.hosted.RuntimeResourceAccess --dontwarn org.jspecify.annotations.NullMarked \ No newline at end of file +-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 \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 172b84191..1f5dfb478 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -189,6 +189,20 @@ + + + + + + + + + + { ExtensionRepoRepositoryImpl(get()) } + addFactory { ExtensionRepoService(get(), get()) } + addFactory { GetExtensionRepo(get()) } + addFactory { GetExtensionRepoCount(get()) } + addFactory { CreateExtensionRepo(get(), get()) } addFactory { DeleteExtensionRepo(get()) } - addFactory { GetExtensionRepos(get()) } + addFactory { ReplaceExtensionRepo(get()) } + addFactory { UpdateExtensionRepo(get(), get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index 93fcb85b0..c7f5e9b39 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -32,4 +32,6 @@ class BasePreferences( SHIZUKU(MR.strings.ext_installer_shizuku, false), PRIVATE(MR.strings.ext_installer_private, false), } + + fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "") } diff --git a/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt b/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt index 32d2381b1..76d92a3ad 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt @@ -39,4 +39,5 @@ fun Chapter.toDbChapter(): DbChapter = ChapterImpl().also { it.date_upload = dateUpload it.chapter_number = chapterNumber.toFloat() it.source_order = sourceOrder.toInt() + it.last_modified = lastModifiedAt } diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/CreateExtensionRepo.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/CreateExtensionRepo.kt deleted file mode 100644 index 26420c502..000000000 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/CreateExtensionRepo.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eu.kanade.domain.extension.interactor - -import eu.kanade.domain.source.service.SourcePreferences -import tachiyomi.core.common.preference.plusAssign - -class CreateExtensionRepo(private val preferences: SourcePreferences) { - - fun await(name: String): Result { - // Do not allow invalid formats & avoid adding duplicating official repo - if (!name.matches(repoRegex) || name.startsWith(OFFICIAL_REPO_BASE_URL)) { - return Result.InvalidUrl - } - - preferences.extensionRepos() += name.removeSuffix("/index.min.json") - - return Result.Success - } - - sealed interface Result { - data object InvalidUrl : Result - data object Success : Result - } -} - -const val OFFICIAL_REPO_BASE_URL = "https://raw.githubusercontent.com/komikku-app/extensions/repo" -private val repoRegex = """^https://.*/index\.min\.json$""".toRegex() diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/DeleteExtensionRepo.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/DeleteExtensionRepo.kt deleted file mode 100644 index 8e50ebeca..000000000 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/DeleteExtensionRepo.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eu.kanade.domain.extension.interactor - -import eu.kanade.domain.source.service.SourcePreferences -import tachiyomi.core.common.preference.minusAssign - -class DeleteExtensionRepo(private val preferences: SourcePreferences) { - - fun await(repo: String) { - preferences.extensionRepos() -= repo - } -} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionRepos.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionRepos.kt deleted file mode 100644 index 0d3b0e988..000000000 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionRepos.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eu.kanade.domain.extension.interactor - -import eu.kanade.domain.source.service.SourcePreferences -import kotlinx.coroutines.flow.Flow - -class GetExtensionRepos(private val preferences: SourcePreferences) { - - fun subscribe(): Flow> { - return preferences.extensionRepos().changes() - } -} diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index 22e9ee05f..9c2413d96 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -82,4 +82,8 @@ class SourcePreferences( WSRV_NL, } // SY <-- + + // KMK --> + fun relatedMangas() = preferenceStore.getBoolean("related_mangas", false) + // KMK <-- } diff --git a/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt b/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt new file mode 100644 index 000000000..4224c1137 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt @@ -0,0 +1,101 @@ +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 lastSyncEtag() = preferenceStore.getString("sync_etag", "") + + 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(), + + // SY --> + customInfo = preferenceStore.getBoolean("customInfo", true).get(), + readEntries = preferenceStore.getBoolean("readEntries", true).get() + // SY <-- + ) + } + + 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) + + // SY --> + preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo) + preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries) + // SY <-- + } + + 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) + } +} diff --git a/app/src/main/java/eu/kanade/domain/sync/models/SyncSettings.kt b/app/src/main/java/eu/kanade/domain/sync/models/SyncSettings.kt new file mode 100644 index 000000000..720f070c4 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/sync/models/SyncSettings.kt @@ -0,0 +1,17 @@ +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, + + // SY --> + val customInfo: Boolean = true, + val readEntries: Boolean = true + // SY <-- +) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 16871306e..bbd9715e5 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -16,7 +16,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Launch +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -69,6 +70,8 @@ fun ExtensionDetailsScreen( navigateUp: () -> Unit, state: ExtensionDetailsScreenModel.State, onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickWhatsNew: () -> Unit, + onClickReadme: () -> Unit, onClickEnableAll: () -> Unit, onClickDisableAll: () -> Unit, onClickClearCookies: () -> Unit, @@ -95,11 +98,26 @@ fun ExtensionDetailsScreen( AppBarActions( actions = persistentListOf().builder() .apply { - if (url != null) { + if (state.extension?.isUnofficial == false) { add( AppBar.Action( + title = stringResource(MR.strings.whats_new), + icon = Icons.Outlined.History, + onClick = onClickWhatsNew, + ), + ) + add( + AppBar.Action( + title = stringResource(MR.strings.action_faq_and_guides), + icon = Icons.AutoMirrored.Outlined.HelpOutline, + onClick = onClickReadme, + ), + ) + } + if (url != null) { + add( + AppBar.OverflowAction( title = stringResource(MR.strings.action_open_repo), - icon = Icons.AutoMirrored.Outlined.Launch, onClick = { uriHandler.openUri(url) }, diff --git a/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt index b374b3aaa..3b841ee2e 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt @@ -74,7 +74,7 @@ fun FeedScreen( selection: List, // KMK <-- onRefresh: () -> Unit, - getMangaState: @Composable (Manga, CatalogueSource?) -> State, + getMangaState: @Composable (Manga) -> State, ) { when { state.isLoading -> LoadingScreen() @@ -123,7 +123,7 @@ fun FeedScreen( ) { FeedItem( item = item, - getMangaState = { getMangaState(it, item.source) }, + getMangaState = { getMangaState(it) }, onClickManga = onClickManga, // KMK --> onLongClickManga = onLongClickManga, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt index 6d3ad947a..3eb0300c4 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt @@ -38,6 +38,7 @@ fun LibraryToolbar( onClickRefresh: () -> Unit, onClickGlobalUpdate: () -> Unit, onClickOpenRandomManga: () -> Unit, + onClickSyncNow: () -> Unit, // SY --> onClickSyncExh: (() -> Unit)?, // SY <-- @@ -60,6 +61,7 @@ fun LibraryToolbar( onClickRefresh = onClickRefresh, onClickGlobalUpdate = onClickGlobalUpdate, onClickOpenRandomManga = onClickOpenRandomManga, + onClickSyncNow = onClickSyncNow, // SY --> onClickSyncExh = onClickSyncExh, // SY <-- @@ -77,6 +79,7 @@ private fun LibraryRegularToolbar( onClickRefresh: () -> Unit, onClickGlobalUpdate: () -> Unit, onClickOpenRandomManga: () -> Unit, + onClickSyncNow: () -> Unit, // SY --> onClickSyncExh: (() -> Unit)?, // SY <-- @@ -125,7 +128,10 @@ private fun LibraryRegularToolbar( title = stringResource(MR.strings.action_open_random_manga), onClick = onClickOpenRandomManga, ), - + AppBar.OverflowAction( + title = stringResource(SYMR.strings.sync_library), + onClick = onClickSyncNow, + ), ).builder().apply { // SY --> if (onClickSyncExh != null) { diff --git a/app/src/main/java/eu/kanade/presentation/manga/DuplicateMangaDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/DuplicateMangaDialog.kt index d2b70f091..eb9b1a547 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/DuplicateMangaDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/DuplicateMangaDialog.kt @@ -1,20 +1,38 @@ package eu.kanade.presentation.manga +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowColumn import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.SwapVert import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.kanade.presentation.components.AdaptiveSheet +import eu.kanade.presentation.components.TabbedDialogPaddings +import eu.kanade.presentation.more.settings.LocalPreferenceMinHeight +import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget import tachiyomi.i18n.MR -import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource @Composable @@ -22,46 +40,96 @@ fun DuplicateMangaDialog( onDismissRequest: () -> Unit, onConfirm: () -> Unit, onOpenManga: () -> Unit, + onMigrate: () -> Unit, + modifier: Modifier = Modifier, ) { - AlertDialog( + val minHeight = LocalPreferenceMinHeight.current + + AdaptiveSheet( + modifier = modifier, onDismissRequest = onDismissRequest, - title = { - Text(text = stringResource(MR.strings.are_you_sure)) - }, - text = { - Text(text = stringResource(MR.strings.confirm_add_duplicate_manga)) - }, - confirmButton = { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), + ) { + Column( + modifier = Modifier + .padding( + vertical = TabbedDialogPaddings.Vertical, + horizontal = TabbedDialogPaddings.Horizontal, + ) + .fillMaxWidth(), + ) { + Text( + modifier = Modifier.padding(TitlePadding), + text = stringResource(MR.strings.are_you_sure), + style = MaterialTheme.typography.headlineMedium, + ) + + Text( + text = stringResource(MR.strings.confirm_add_duplicate_manga), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(Modifier.height(PaddingSize)) + + TextPreferenceWidget( + title = stringResource(MR.strings.action_show_manga), + icon = Icons.Outlined.Book, + onPreferenceClick = { + onDismissRequest() + onOpenManga() + }, + ) + + HorizontalDivider() + + TextPreferenceWidget( + title = stringResource(MR.strings.action_migrate_duplicate), + icon = Icons.Outlined.SwapVert, + onPreferenceClick = { + onDismissRequest() + onMigrate() + }, + ) + + HorizontalDivider() + + TextPreferenceWidget( + title = stringResource(MR.strings.action_add_anyway), + icon = Icons.Outlined.Add, + onPreferenceClick = { + onDismissRequest() + onConfirm() + }, + ) + + Row( + modifier = Modifier + .sizeIn(minHeight = minHeight) + .clickable { onDismissRequest.invoke() } + .padding(ButtonPadding) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { - TextButton( - onClick = { - onDismissRequest() - onOpenManga() - }, - ) { - Text(text = stringResource(MR.strings.action_show_manga)) - } - - Spacer(modifier = Modifier.weight(1f)) - - TextButton(onClick = onDismissRequest) { - Text(text = stringResource(MR.strings.action_cancel)) - } - TextButton( - onClick = { - onDismissRequest() - onConfirm() - }, - ) { - Text(text = stringResource(MR.strings.action_add)) + OutlinedButton(onClick = onDismissRequest, modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier + .padding(vertical = 8.dp), + text = stringResource(MR.strings.action_cancel), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + fontSize = 16.sp, + ) } } - }, - ) + } + } } +private val PaddingSize = 16.dp + +private val ButtonPadding = PaddingValues(top = 16.dp, bottom = 16.dp) +private val TitlePadding = PaddingValues(bottom = 16.dp, top = 8.dp) + // KMK --> @Composable fun DuplicateMangasDialog( diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 0f1cc335b..4a9ac700b 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -49,6 +50,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastMap +import eu.kanade.presentation.browse.components.GlobalSearchCardRow +import eu.kanade.presentation.browse.components.GlobalSearchResultItem import eu.kanade.presentation.components.relativeDateText import eu.kanade.presentation.manga.components.ChapterDownloadAction import eu.kanade.presentation.manga.components.ChapterHeader @@ -167,6 +170,12 @@ fun MangaScreen( onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit, onInvertSelection: () -> Unit, + + // KMK --> + getMangaState: @Composable ((Manga) -> State), + onRelatedMangaClick: (Manga) -> Unit, + onRelatedMangaLongClick: (Manga) -> Unit, + // KMK <-- ) { val context = LocalContext.current val onCopyTagToClipboard: (tag: String) -> Unit = { @@ -220,6 +229,11 @@ fun MangaScreen( onChapterSelected = onChapterSelected, onAllChapterSelected = onAllChapterSelected, onInvertSelection = onInvertSelection, + // KMK --> + getMangaState = getMangaState, + onRelatedMangaClick = onRelatedMangaClick, + onRelatedMangaLongClick = onRelatedMangaLongClick, + // KMK <-- ) } else { MangaScreenLargeImpl( @@ -266,6 +280,11 @@ fun MangaScreen( onChapterSelected = onChapterSelected, onAllChapterSelected = onAllChapterSelected, onInvertSelection = onInvertSelection, + // KMK --> + getMangaState = getMangaState, + onRelatedMangaClick = onRelatedMangaClick, + onRelatedMangaLongClick = onRelatedMangaLongClick, + // KMK <-- ) } } @@ -328,6 +347,12 @@ private fun MangaScreenSmallImpl( onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit, onInvertSelection: () -> Unit, + + // KMK --> + getMangaState: @Composable ((Manga) -> State), + onRelatedMangaClick: (Manga) -> Unit, + onRelatedMangaLongClick: (Manga) -> Unit, + // KMK <-- ) { val chapterListState = rememberLazyListState() @@ -534,6 +559,29 @@ private fun MangaScreenSmallImpl( ) } + // KMK --> + if (state.relatedMangas?.isNotEmpty() == true) { + item( + key = MangaScreenItem.RELATED_TITLES, + contentType = MangaScreenItem.RELATED_TITLES, + ) { + GlobalSearchResultItem( + title = "Related titles", + subtitle = null, + onLongClick = null, + onClick = { /* Should show a page with grid/list of all the related manga */ }, + ) { + RelatedMangas( + mangas = state.relatedMangas, + getMangaState = { getMangaState(it) }, + onClickManga = onRelatedMangaClick, + onLongClickManga = onRelatedMangaLongClick, + ) + } + } + } + // KMK <-- + // SY --> if (!state.showRecommendationsInOverflow || state.showMergeWithAnother) { item( @@ -654,6 +702,12 @@ fun MangaScreenLargeImpl( onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit, onInvertSelection: () -> Unit, + + // KMK --> + getMangaState: @Composable ((Manga) -> State), + onRelatedMangaClick: (Manga) -> Unit, + onRelatedMangaLongClick: (Manga) -> Unit, + // KMK <-- ) { val layoutDirection = LocalLayoutDirection.current val density = LocalDensity.current @@ -863,6 +917,29 @@ fun MangaScreenLargeImpl( bottom = contentPadding.calculateBottomPadding(), ), ) { + // KMK --> + if (state.relatedMangas?.isNotEmpty() == true) { + item( + key = MangaScreenItem.RELATED_TITLES, + contentType = MangaScreenItem.RELATED_TITLES, + ) { + GlobalSearchResultItem( + title = "Related titles", + subtitle = null, + onLongClick = null, + onClick = { /* Should show a page with grid/list of all the related manga */ }, + ) { + RelatedMangas( + mangas = state.relatedMangas, + getMangaState = { getMangaState(it) }, + onClickManga = onRelatedMangaClick, + onLongClickManga = onRelatedMangaLongClick, + ) + } + } + } + // KMK <-- + item( key = MangaScreenItem.CHAPTER_HEADER, contentType = MangaScreenItem.CHAPTER_HEADER, @@ -900,6 +977,24 @@ fun MangaScreenLargeImpl( } } +// KMK --> +@Composable +fun RelatedMangas( + mangas: List, + getMangaState: @Composable ((Manga) -> State), + onClickManga: (Manga) -> Unit, + onLongClickManga: (Manga) -> Unit, +) { + GlobalSearchCardRow( + titles = mangas, + getManga = getMangaState, + onClick = onClickManga, + onLongClick = onLongClickManga, + selection = emptyList(), + ) +} +// KMK <-- + @Composable private fun SharedMangaBottomActionMenu( selected: List, diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt index f4671eef4..148db03cf 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt @@ -32,4 +32,8 @@ enum class MangaScreenItem { // SY <-- CHAPTER_HEADER, CHAPTER, + + // KMK --> + RELATED_TITLES, + // KMK <-- } diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index a8f39e896..151af0927 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -239,6 +239,7 @@ fun LibraryBottomActionMenu( onClickCleanTitles: (() -> Unit)?, onClickMigrate: (() -> Unit)?, onClickAddToMangaDex: (() -> Unit)?, + onClickResetInfo: (() -> Unit)?, // SY <-- modifier: Modifier = Modifier, ) { @@ -267,7 +268,7 @@ fun LibraryBottomActionMenu( } } // SY --> - val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null + val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null || onClickResetInfo != null val configuration = LocalConfiguration.current val moveMarkPrev = remember { !configuration.isTabletUi() } var overFlowOpen by remember { mutableStateOf(false) } @@ -364,6 +365,12 @@ fun LibraryBottomActionMenu( onClick = onClickAddToMangaDex, ) } + if (onClickResetInfo != null) { + DropdownMenuItem( + text = { Text(text = stringResource(SYMR.strings.reset_info)) }, + onClick = onClickResetInfo, + ) + } } } else { Button( diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 07af01ea8..e58cbd58e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -8,6 +8,8 @@ import android.provider.Settings import android.webkit.WebStorage import android.webkit.WebView import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text @@ -60,7 +62,6 @@ import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.isDevFlavor import eu.kanade.tachiyomi.util.system.isPreviewBuildType -import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isShizukuInstalled import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.setDefaultSettings @@ -113,71 +114,54 @@ object SettingsAdvancedScreen : SearchableSettings { val basePreferences = remember { Injekt.get() } val networkPreferences = remember { Injekt.get() } - return buildList { - addAll( - listOf( - /* SY --> Preference.PreferenceItem.SwitchPreference( - pref = basePreferences.acraEnabled(), - title = stringResource(MR.strings.pref_enable_acra), - subtitle = stringResource(MR.strings.pref_acra_summary), - enabled = isPreviewBuildType || isReleaseBuildType, - ), SY <-- */ - Preference.PreferenceItem.TextPreference( - title = stringResource(MR.strings.pref_dump_crash_logs), - subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary), - onClick = { - scope.launch { - CrashLogUtil(context).dumpLogs() - } - }, - ), - /* SY --> Preference.PreferenceItem.SwitchPreference( - pref = networkPreferences.verboseLogging(), - title = stringResource(MR.strings.pref_verbose_logging), - subtitle = stringResource(MR.strings.pref_verbose_logging_summary), - onValueChanged = { - context.toast(MR.strings.requires_app_restart) - true - }, - ), SY <-- */ - Preference.PreferenceItem.TextPreference( - title = stringResource(MR.strings.pref_debug_info), - onClick = { navigator.push(DebugInfoScreen()) }, - ), - Preference.PreferenceItem.TextPreference( - title = stringResource(MR.strings.pref_onboarding_guide), - onClick = { navigator.push(OnboardingScreen()) }, - ), - ), - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - add( - Preference.PreferenceItem.TextPreference( - title = stringResource(MR.strings.pref_manage_notifications), - onClick = { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - context.startActivity(intent) - }, - ), - ) - } - addAll( - listOf( - getBackgroundActivityGroup(), - getDataGroup(), - getNetworkGroup(networkPreferences = networkPreferences), - getLibraryGroup(), - getExtensionsGroup(basePreferences = basePreferences), - // SY --> - // getDownloaderGroup(), - getDataSaverGroup(), - getDeveloperToolsGroup(), - // SY <-- - ), - ) - } + return listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_dump_crash_logs), + subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary), + onClick = { + scope.launch { + CrashLogUtil(context).dumpLogs() + } + }, + ), + /* SY --> Preference.PreferenceItem.SwitchPreference( + pref = networkPreferences.verboseLogging(), + title = stringResource(MR.strings.pref_verbose_logging), + subtitle = stringResource(MR.strings.pref_verbose_logging_summary), + onValueChanged = { + context.toast(MR.strings.requires_app_restart) + true + }, + ), SY <-- */ + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_debug_info), + onClick = { navigator.push(DebugInfoScreen()) }, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_onboarding_guide), + onClick = { navigator.push(OnboardingScreen()) }, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_manage_notifications), + onClick = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + }, + ), + getBackgroundActivityGroup(), + getDataGroup(), + getNetworkGroup(networkPreferences = networkPreferences), + getLibraryGroup(), + getReaderGroup(basePreferences = basePreferences), + getExtensionsGroup(basePreferences = basePreferences), + // SY --> + // getDownloaderGroup(), + getDataSaverGroup(), + getDeveloperToolsGroup(), + // SY <-- + ) } @Composable @@ -378,6 +362,34 @@ object SettingsAdvancedScreen : SearchableSettings { ) } + @Composable + private fun getReaderGroup( + basePreferences: BasePreferences, + ): Preference.PreferenceGroup { + val context = LocalContext.current + val chooseColorProfile = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + uri?.let { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + basePreferences.displayProfile().set(uri.toString()) + } + } + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_category_reader), + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_display_profile), + subtitle = basePreferences.displayProfile().get(), + onClick = { + chooseColorProfile.launch(arrayOf("*/*")) + }, + ), + ) + ) + } + @Composable private fun getExtensionsGroup( basePreferences: BasePreferences, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt index 4deff07a2..c97e147aa 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -17,6 +18,7 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import kotlinx.collections.immutable.persistentListOf +import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import tachiyomi.core.common.i18n.stringResource import tachiyomi.domain.UnsortedPreferences import tachiyomi.i18n.MR @@ -39,7 +41,9 @@ object SettingsBrowseScreen : SearchableSettings { val navigator = LocalNavigator.currentOrThrow val sourcePreferences = remember { Injekt.get() } - val reposCount by sourcePreferences.extensionRepos().collectAsState() + val getExtensionRepoCount = remember { Injekt.get() } + + val reposCount by getExtensionRepoCount.subscribe().collectAsState(0) // SY --> val scope = rememberCoroutineScope() @@ -52,6 +56,13 @@ object SettingsBrowseScreen : SearchableSettings { Preference.PreferenceGroup( title = stringResource(MR.strings.label_sources), preferenceItems = persistentListOf( + // KMK --> + Preference.PreferenceItem.SwitchPreference( + pref = sourcePreferences.relatedMangas(), + title = stringResource(SYMR.strings.pref_source_related_mangas), + subtitle = stringResource(SYMR.strings.pref_source_related_mangas_summary), + ), + // KMK <-- kotlin.run { val count by sourcePreferences.sourcesTabCategories().collectAsState() Preference.PreferenceItem.TextPreference( @@ -104,7 +115,7 @@ object SettingsBrowseScreen : SearchableSettings { ), Preference.PreferenceItem.TextPreference( title = stringResource(MR.strings.label_extension_repos), - subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size), + subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount), onClick = { navigator.push(ExtensionReposScreen()) }, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index a6f9955a0..46c8caf6a 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -15,16 +15,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MultiChoiceSegmentedButtonRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -35,10 +38,13 @@ import androidx.core.net.toUri import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.hippo.unifile.UniFile +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen import eu.kanade.presentation.more.settings.screen.data.StorageInfo +import eu.kanade.presentation.more.settings.screen.data.SyncSettingsSelector +import eu.kanade.presentation.more.settings.screen.data.SyncTriggerOptionsScreen import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.util.relativeTimeSpanString @@ -46,10 +52,15 @@ import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import eu.kanade.tachiyomi.data.sync.SyncDataJob +import eu.kanade.tachiyomi.data.sync.SyncManager +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.displayablePath @@ -91,13 +102,16 @@ object SettingsDataScreen : SearchableSettings { val backupPreferences = Injekt.get() val storagePreferences = Injekt.get() + val syncPreferences = remember { Injekt.get() } + val syncService by syncPreferences.syncService().collectAsState() + return persistentListOf( getStorageLocationPref(storagePreferences = storagePreferences), Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.pref_storage_location_info)), getBackupAndRestoreGroup(backupPreferences = backupPreferences), getDataGroup(), - ) + ) + getSyncPreferences(syncPreferences = syncPreferences, syncService = syncService) } @Composable @@ -330,4 +344,225 @@ object SettingsDataScreen : SearchableSettings { ), ) } + + @Composable + private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List { + return listOf( + Preference.PreferenceGroup( + title = stringResource(SYMR.strings.pref_sync_service_category), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + pref = syncPreferences.syncService(), + title = stringResource(SYMR.strings.pref_sync_service), + entries = persistentMapOf( + SyncManager.SyncService.NONE.value to stringResource(MR.strings.off), + SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi), + SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive), + ), + onValueChanged = { true }, + ), + ), + ), + ) + getSyncServicePreferences(syncPreferences, syncService) + } + + @Composable + private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List { + 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 { + return when (syncServiceType) { + SyncManager.SyncService.NONE -> emptyList() + SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences) + SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences() + } + } + + @Composable + private fun getAdditionalPreferences(syncPreferences: SyncPreferences): List { + return listOf(getSyncNowPref(), getAutomaticSyncGroup(syncPreferences)) + } + + @Composable + private fun getGoogleDrivePreferences(): List { + val context = LocalContext.current + val googleDriveSync = Injekt.get() + return listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(SYMR.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( + SYMR.strings.google_drive_not_signed_in, + duration = 5000, + ) + GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast( + SYMR.strings.google_drive_sync_data_not_found, + duration = 5000, + ) + GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast( + SYMR.strings.google_drive_sync_data_purged, + duration = 5000, + ) + GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast( + SYMR.strings.google_drive_sync_data_purge_error, + duration = 10000, + ) + } + } + }, + onDismissRequest = { showPurgeDialog = false }, + ) + } + + return Preference.PreferenceItem.TextPreference( + title = stringResource(SYMR.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(SYMR.strings.pref_purge_confirmation_title)) }, + text = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_message)) }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + ) + } + + @Composable + private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List { + val scope = rememberCoroutineScope() + return listOf( + Preference.PreferenceItem.EditTextPreference( + title = stringResource(SYMR.strings.pref_sync_host), + subtitle = stringResource(SYMR.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(SYMR.strings.pref_sync_api_key), + subtitle = stringResource(SYMR.strings.pref_sync_api_key_summ), + pref = syncPreferences.clientAPIKey(), + ), + ) + } + + @Composable + private fun getSyncNowPref(): Preference.PreferenceGroup { + val navigator = LocalNavigator.currentOrThrow + return Preference.PreferenceGroup( + title = stringResource(SYMR.strings.pref_sync_now_group_title), + preferenceItems = persistentListOf( + getSyncOptionsPref(), + Preference.PreferenceItem.TextPreference( + title = stringResource(SYMR.strings.pref_sync_now), + subtitle = stringResource(SYMR.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(SYMR.strings.pref_sync_options), + subtitle = stringResource(SYMR.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(SYMR.strings.pref_sync_automatic_category), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + pref = syncIntervalPref, + title = stringResource(SYMR.strings.pref_sync_interval), + entries = persistentMapOf( + 0 to stringResource(MR.strings.off), + 30 to stringResource(SYMR.strings.update_30min), + 60 to stringResource(SYMR.strings.update_1hour), + 180 to stringResource(SYMR.strings.update_3hour), + 360 to stringResource(MR.strings.update_6hour), + 720 to stringResource(MR.strings.update_12hour), + 1440 to stringResource(MR.strings.update_24hour), + 2880 to stringResource(MR.strings.update_48hour), + 10080 to stringResource(MR.strings.update_weekly), + ), + onValueChanged = { + SyncDataJob.setupTask(context, it) + true + }, + ), + Preference.PreferenceItem.InfoPreference( + stringResource(SYMR.strings.last_synchronization, relativeTimeSpanString(lastSync)), + ), + ), + ) + } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt index 072013415..98eaf84de 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt @@ -61,6 +61,9 @@ object SettingsDownloadScreen : SearchableSettings { allCategories = allCategories, ), getDownloadAheadGroup(downloadPreferences = downloadPreferences), + // KMK --> + getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences), + // KMK <-- ) } @@ -190,4 +193,30 @@ object SettingsDownloadScreen : SearchableSettings { ), ) } + + // KMK --> + @Composable + private fun getDownloadCacheRenewInterval( + downloadPreferences: DownloadPreferences, + ): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + title = stringResource(MR.strings.download_cache_renew_interval), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + pref = downloadPreferences.downloadCacheRenewInterval(), + title = stringResource(MR.strings.download_cache_renew_interval), + entries = persistentMapOf( + -1 to stringResource(MR.strings.download_cache_renew_interval_manual), + 1 to stringResource(MR.strings.download_cache_renew_interval_1hour), + 2 to stringResource(MR.strings.download_cache_renew_interval_2hour), + 6 to stringResource(MR.strings.download_cache_renew_interval_6hour), + 12 to stringResource(MR.strings.download_cache_renew_interval_12hour), + 24 to stringResource(MR.strings.download_cache_renew_interval_24hour), + ), + ), + Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.download_cache_renew_interval_info)), + ), + ) + } + // KMK <-- } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 6e9c1c53e..00c657a05 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -235,11 +235,13 @@ object SettingsLibraryScreen : SearchableSettings { pref = libraryPreferences.newShowUpdatesCount(), title = stringResource(MR.strings.pref_library_update_show_tab_badge), ), + // SY --> Preference.PreferenceItem.SwitchPreference( pref = libraryPreferences.libraryReadDuplicateChapters(), - title = stringResource(MR.strings.pref_library_mark_duplicate_chapters), - subtitle = stringResource(MR.strings.pref_library_mark_duplicate_chapters_summary), + title = stringResource(SYMR.strings.pref_library_mark_duplicate_chapters), + subtitle = stringResource(SYMR.strings.pref_library_mark_duplicate_chapters_summary), ), + // SY <-- ), ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt index 3acc668c3..397145009 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -35,6 +35,7 @@ object SettingsReaderScreen : SearchableSettings { // SY --> val forceHorizontalSeekbar by readerPref.forceHorizontalSeekbar().collectAsState() // SY <-- + return listOf( Preference.PreferenceItem.ListPreference( pref = readerPref.defaultReadingMode(), @@ -81,12 +82,6 @@ object SettingsReaderScreen : SearchableSettings { enabled = !forceHorizontalSeekbar, ), // SY <-- - Preference.PreferenceItem.SwitchPreference( - pref = readerPref.trueColor(), - title = stringResource(MR.strings.pref_true_color), - subtitle = stringResource(MR.strings.pref_true_color_summary), - enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O, - ), /* SY --> Preference.PreferenceItem.SwitchPreference( pref = readerPref.pageTransitions(), @@ -178,11 +173,13 @@ object SettingsReaderScreen : SearchableSettings { pref = readerPreferences.skipDupe(), title = stringResource(MR.strings.pref_skip_dupe_chapters), ), + // SY --> Preference.PreferenceItem.SwitchPreference( pref = readerPreferences.markReadDupe(), - title = stringResource(MR.strings.pref_mark_read_dupe_chapters), - subtitle = stringResource(MR.strings.pref_mark_read_dupe_chapters_summary), + title = stringResource(SYMR.strings.pref_mark_read_dupe_chapters), + subtitle = stringResource(SYMR.strings.pref_mark_read_dupe_chapters_summary), ), + // SY <-- Preference.PreferenceItem.SwitchPreference( pref = readerPreferences.alwaysShowChapterTransition(), title = stringResource(MR.strings.pref_always_show_chapter_transition), @@ -570,10 +567,14 @@ object SettingsReaderScreen : SearchableSettings { .toMap() .toImmutableMap(), ), - Preference.PreferenceItem.SwitchPreference( - pref = readerPreferences.cacheArchiveMangaOnDisk(), - title = stringResource(SYMR.strings.cache_archived_manga_to_disk), - subtitle = stringResource(SYMR.strings.cache_archived_manga_to_disk_subtitle), + Preference.PreferenceItem.ListPreference( + pref = readerPreferences.archiveReaderMode(), + title = stringResource(SYMR.strings.pref_archive_reader_mode), + subtitle = stringResource(SYMR.strings.pref_archive_reader_mode_summary), + entries = ReaderPreferences.archiveModeTypes + .mapIndexed { index, it -> index to stringResource(it) } + .toMap() + .toImmutableMap(), ), ), ) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt index a62dc368e..b3e2b570e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt @@ -56,10 +56,9 @@ import tachiyomi.presentation.core.icons.Reddit import tachiyomi.presentation.core.icons.X import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale object AboutScreen : Screen() { @@ -293,11 +292,15 @@ object AboutScreen : Screen() { internal fun getFormattedBuildTime(): String { return try { - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'", Locale.US) - .withZone(ZoneId.of("UTC")) - val buildTime = LocalDateTime.from(df.parse(BuildConfig.BUILD_TIME)) - - buildTime!!.toDateTimestampString(UiPreferences.dateFormat(Injekt.get().dateFormat().get())) + LocalDateTime.ofInstant( + Instant.parse(BuildConfig.BUILD_TIME), + ZoneId.systemDefault(), + ) + .toDateTimestampString( + UiPreferences.dateFormat( + Injekt.get().dateFormat().get(), + ), + ) } catch (e: Exception) { BuildConfig.BUILD_TIME } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/OpenSourceLicensesScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/OpenSourceLicensesScreen.kt index e39de8ed6..3c2309b32 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/OpenSourceLicensesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/OpenSourceLicensesScreen.kt @@ -32,12 +32,13 @@ class OpenSourceLicensesScreen : Screen() { .fillMaxSize(), contentPadding = contentPadding, onLibraryClick = { - val libraryLicenseScreen = OpenSourceLibraryLicenseScreen( - name = it.library.name, - website = it.library.website, - license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), + navigator.push( + OpenSourceLibraryLicenseScreen( + name = it.name, + website = it.website, + license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), + ) ) - navigator.push(libraryLicenseScreen) }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt index 4801829d5..03c9acd7c 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt @@ -8,11 +8,14 @@ import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.collectLatest import tachiyomi.presentation.core.screens.LoadingScreen @@ -42,17 +45,19 @@ class ExtensionReposScreen( ExtensionReposScreen( state = successState, onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, + onOpenWebsite = { context.openInBrowser(it.website) }, onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, + onClickRefresh = { screenModel.refreshRepos() }, navigateUp = navigator::pop, ) when (val dialog = successState.dialog) { null -> {} - RepoDialog.Create -> { + is RepoDialog.Create -> { ExtensionRepoCreateDialog( onDismissRequest = screenModel::dismissDialog, onCreate = { screenModel.createRepo(it) }, - repos = successState.repos, + repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(), ) } is RepoDialog.Delete -> { @@ -62,6 +67,15 @@ class ExtensionReposScreen( repo = dialog.repo, ) } + + is RepoDialog.Conflict -> { + ExtensionRepoConflictDialog( + onDismissRequest = screenModel::dismissDialog, + onMigrate = { screenModel.replaceRepo(dialog.newRepo) }, + oldRepo = dialog.oldRepo, + newRepo = dialog.newRepo, + ) + } } LaunchedEffect(Unit) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt index d694618fb..4131bc6fd 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt @@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import dev.icerock.moko.resources.StringResource -import eu.kanade.domain.extension.interactor.CreateExtensionRepo -import eu.kanade.domain.extension.interactor.DeleteExtensionRepo -import eu.kanade.domain.extension.interactor.GetExtensionRepos import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import mihon.domain.extensionrepo.interactor.CreateExtensionRepo +import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo +import mihon.domain.extensionrepo.interactor.GetExtensionRepo +import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo +import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo +import mihon.domain.extensionrepo.model.ExtensionRepo import tachiyomi.core.common.util.lang.launchIO import tachiyomi.i18n.MR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class ExtensionReposScreenModel( - private val getExtensionRepos: GetExtensionRepos = Injekt.get(), + private val getExtensionRepo: GetExtensionRepo = Injekt.get(), private val createExtensionRepo: CreateExtensionRepo = Injekt.get(), private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(), + private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(), + private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(), ) : StateScreenModel(RepoScreenState.Loading) { private val _events: Channel = Channel(Int.MAX_VALUE) @@ -29,7 +34,7 @@ class ExtensionReposScreenModel( init { screenModelScope.launchIO { - getExtensionRepos.subscribe() + getExtensionRepo.subscribeAll() .collectLatest { repos -> mutableState.update { RepoScreenState.Success( @@ -43,25 +48,51 @@ class ExtensionReposScreenModel( /** * Creates and adds a new repo to the database. * - * @param name The name of the repo to create. + * @param baseUrl The baseUrl of the repo to create. */ - fun createRepo(name: String) { + fun createRepo(baseUrl: String) { screenModelScope.launchIO { - when (createExtensionRepo.await(name)) { - is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) + when (val result = createExtensionRepo.await(baseUrl)) { + CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) + CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists) + is CreateExtensionRepo.Result.DuplicateFingerprint -> { + showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo)) + } else -> {} } } } /** - * Deletes the given repo from the database. + * Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found. * - * @param repo The repo to delete. + * @param newRepo The repo to insert */ - fun deleteRepo(repo: String) { + fun replaceRepo(newRepo: ExtensionRepo) { screenModelScope.launchIO { - deleteExtensionRepo.await(repo) + replaceExtensionRepo.await(newRepo) + } + } + + /** + * Refreshes information for each repository. + */ + fun refreshRepos() { + val status = state.value + + if (status is RepoScreenState.Success) { + screenModelScope.launchIO { + updateExtensionRepo.awaitAll() + } + } + } + + /** + * Deletes the given repo from the database + */ + fun deleteRepo(baseUrl: String) { + screenModelScope.launchIO { + deleteExtensionRepo.await(baseUrl) } } @@ -87,11 +118,13 @@ class ExtensionReposScreenModel( sealed class RepoEvent { sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent() data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name) + data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists) } sealed class RepoDialog { data object Create : RepoDialog() data class Delete(val repo: String) : RepoDialog() + data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog() } sealed class RepoScreenState { @@ -101,7 +134,8 @@ sealed class RepoScreenState { @Immutable data class Success( - val repos: ImmutableSet, + val repos: ImmutableSet, + val oldRepos: ImmutableSet? = null, val dialog: RepoDialog? = null, ) : RepoScreenState() { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt index 7d837b32d..20a924d11 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ElevatedCard @@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import eu.kanade.tachiyomi.util.system.copyToClipboard import kotlinx.collections.immutable.ImmutableSet +import mihon.domain.extensionrepo.model.ExtensionRepo import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource @Composable fun ExtensionReposContent( - repos: ImmutableSet, + repos: ImmutableSet, lazyListState: LazyListState, paddingValues: PaddingValues, + onOpenWebsite: (ExtensionRepo) -> Unit, onClickDelete: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -45,7 +48,8 @@ fun ExtensionReposContent( ExtensionRepoListItem( modifier = Modifier.animateItemPlacement(), repo = it, - onDelete = { onClickDelete(it) }, + onOpenWebsite = { onOpenWebsite(it) }, + onDelete = { onClickDelete(it.baseUrl) }, ) } } @@ -54,7 +58,8 @@ fun ExtensionReposContent( @Composable private fun ExtensionRepoListItem( - repo: String, + repo: ExtensionRepo, + onOpenWebsite: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { @@ -74,16 +79,27 @@ private fun ExtensionRepoListItem( verticalAlignment = Alignment.CenterVertically, ) { Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) - Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium)) + Text( + text = repo.name, + modifier = Modifier.padding(start = MaterialTheme.padding.medium), + style = MaterialTheme.typography.titleMedium, + ) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { + IconButton(onClick = onOpenWebsite) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = stringResource(MR.strings.action_open_in_browser), + ) + } + IconButton( onClick = { - val url = "$repo/index.min.json" + val url = "${repo.baseUrl}/index.min.json" context.copyToClipboard(url, url) }, ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt index b4ef8b575..5022f44f0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import kotlinx.collections.immutable.ImmutableSet import kotlinx.coroutines.delay +import mihon.domain.extensionrepo.model.ExtensionRepo import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource import kotlin.time.Duration.Companion.seconds @@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds fun ExtensionRepoCreateDialog( onDismissRequest: () -> Unit, onCreate: (String) -> Unit, - repos: ImmutableSet, + repoUrls: ImmutableSet, ) { var name by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { repos.contains(name) } + val nameAlreadyExists = remember(name) { repoUrls.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -115,3 +116,36 @@ fun ExtensionRepoDeleteDialog( }, ) } + +@Composable +fun ExtensionRepoConflictDialog( + oldRepo: ExtensionRepo, + newRepo: ExtensionRepo, + onDismissRequest: () -> Unit, + onMigrate: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { + onMigrate() + onDismissRequest() + }, + ) { + Text(text = stringResource(MR.strings.action_replace_repo)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + title = { + Text(text = stringResource(MR.strings.action_replace_repo_title)) + }, + text = { + Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt index 1bd680d06..b07ba4101 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt @@ -5,12 +5,17 @@ package eu.kanade.presentation.more.settings.screen.browse.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState +import mihon.domain.extensionrepo.model.ExtensionRepo import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.padding @@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus fun ExtensionReposScreen( state: RepoScreenState.Success, onClickCreate: () -> Unit, + onOpenWebsite: (ExtensionRepo) -> Unit, onClickDelete: (String) -> Unit, + onClickRefresh: () -> Unit, navigateUp: () -> Unit, ) { val lazyListState = rememberLazyListState() @@ -33,6 +40,14 @@ fun ExtensionReposScreen( navigateUp = navigateUp, title = stringResource(MR.strings.label_extension_repos), scrollBehavior = scrollBehavior, + actions = { + IconButton(onClick = onClickRefresh) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource(resource = MR.strings.action_webview_refresh), + ) + } + }, ) }, floatingActionButton = { @@ -55,6 +70,7 @@ fun ExtensionReposScreen( lazyListState = lazyListState, paddingValues = paddingValues + topSmallPaddingValues + PaddingValues(horizontal = MaterialTheme.padding.medium), + onOpenWebsite = onOpenWebsite, onClickDelete = onClickDelete, ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt new file mode 100644 index 000000000..eebb91f52 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt @@ -0,0 +1,153 @@ +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.i18n.sy.SYMR +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(SYMR.strings.pref_choose_what_to_sync), + navigateUp = navigator::pop, + scrollBehavior = it, + ) + }, + ) { contentPadding -> + LazyColumnWithAction( + contentPadding = contentPadding, + actionLabel = stringResource(SYMR.strings.label_sync), + actionEnabled = state.options.anyEnabled(), + onClickAction = { + if (!SyncDataJob.isRunning(context)) { + model.syncNow(context) + navigator.pop() + } else { + context.toast(SYMR.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, + 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( + 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, + + // SY --> + customInfo = syncSettings.customInfo, + readEntries = syncSettings.readEntries, + // SY <-- + ) + } + + 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, + + // SY --> + customInfo = backupOptions.customInfo, + readEntries = backupOptions.readEntries, + // SY <-- + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt new file mode 100644 index 000000000..301ebc0d4 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt @@ -0,0 +1,102 @@ +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.i18n.sy.SYMR +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(SYMR.strings.pref_sync_options), + navigateUp = navigator::pop, + scrollBehavior = it, + ) + }, + ) { contentPadding -> + LazyColumnWithAction( + contentPadding = contentPadding, + actionLabel = stringResource(MR.strings.action_save), + actionEnabled = true, + onClickAction = { + navigator.pop() + }, + ) { + item { + SectionCard(SYMR.strings.label_triggers) { + Options(SyncTriggerOptions.mainOptions, state, model) + } + } + } + } + } + + @Composable + private fun Options( + options: ImmutableList, + 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( + 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(), + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index 4df8622c8..d4d7a3583 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.FlipToBack import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.SelectAll @@ -50,6 +51,7 @@ fun UpdateScreen( onClickCover: (UpdatesItem) -> Unit, onSelectAll: (Boolean) -> Unit, onInvertSelection: () -> Unit, + onCalendarClicked: () -> Unit, onUpdateLibrary: () -> Boolean, onDownloadChapter: (List, ChapterDownloadAction) -> Unit, onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, @@ -63,6 +65,7 @@ fun UpdateScreen( Scaffold( topBar = { scrollBehavior -> UpdatesAppBar( + onCalendarClicked = { onCalendarClicked() }, onUpdateLibrary = { onUpdateLibrary() }, actionModeCounter = state.selected.size, onSelectAll = { onSelectAll(true) }, @@ -132,6 +135,7 @@ fun UpdateScreen( @Composable private fun UpdatesAppBar( + onCalendarClicked: () -> Unit, onUpdateLibrary: () -> Unit, // For action mode actionModeCounter: Int, @@ -147,6 +151,11 @@ private fun UpdatesAppBar( actions = { AppBarActions( persistentListOf( + AppBar.Action( + title = stringResource(MR.strings.action_view_upcoming), + icon = Icons.Outlined.CalendarMonth, + onClick = onCalendarClicked, + ), AppBar.Action( title = stringResource(MR.strings.action_update_library), icon = Icons.Outlined.Refresh, diff --git a/app/src/main/java/eu/kanade/presentation/webview/EhLoginWebViewScreen.kt b/app/src/main/java/eu/kanade/presentation/webview/EhLoginWebViewScreen.kt index 1a6fd4670..5c94ee8ec 100644 --- a/app/src/main/java/eu/kanade/presentation/webview/EhLoginWebViewScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/webview/EhLoginWebViewScreen.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.google.accompanist.web.AccompanistWebViewClient -import com.google.accompanist.web.LoadingState -import com.google.accompanist.web.WebContent -import com.google.accompanist.web.WebView -import com.google.accompanist.web.rememberWebViewNavigator -import com.google.accompanist.web.rememberWebViewState +import com.kevinnzou.web.AccompanistWebViewClient +import com.kevinnzou.web.LoadingState +import com.kevinnzou.web.WebContent +import com.kevinnzou.web.WebView +import com.kevinnzou.web.rememberWebViewNavigator +import com.kevinnzou.web.rememberWebViewState import eu.kanade.presentation.components.AppBar import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.util.system.setDefaultSettings diff --git a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt index f1779da58..508790fff 100644 --- a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt +++ b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt @@ -28,11 +28,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import com.google.accompanist.web.AccompanistWebViewClient -import com.google.accompanist.web.LoadingState -import com.google.accompanist.web.WebView -import com.google.accompanist.web.rememberWebViewNavigator -import com.google.accompanist.web.rememberWebViewState +import com.kevinnzou.web.AccompanistWebViewClient +import com.kevinnzou.web.LoadingState +import com.kevinnzou.web.WebView +import com.kevinnzou.web.rememberWebViewNavigator +import com.kevinnzou.web.rememberWebViewState import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.WarningBanner diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 22ec6fb41..2f41b9f61 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -17,8 +17,6 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import coil3.ImageLoader import coil3.SingletonImageLoader -import coil3.disk.DiskCache -import coil3.disk.directory import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.allowRgb565 import coil3.request.crossfade @@ -36,6 +34,7 @@ import com.google.firebase.ktx.initialize import eu.kanade.domain.DomainModule import eu.kanade.domain.SYDomainModule import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.tachiyomi.crash.CrashActivity @@ -47,6 +46,7 @@ import eu.kanade.tachiyomi.data.coil.PagePreviewFetcher import eu.kanade.tachiyomi.data.coil.PagePreviewKeyer import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.di.AppModule import eu.kanade.tachiyomi.di.PreferenceModule import eu.kanade.tachiyomi.di.SYPreferenceModule @@ -71,8 +71,12 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import logcat.LogPriority import logcat.LogcatLogger +import mihon.core.migration.Migrator +import mihon.core.migration.migrations.migrations import org.conscrypt.Conscrypt import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.preference.Preference +import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.storage.service.StorageManager import tachiyomi.i18n.MR @@ -168,25 +172,49 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor /*if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) { LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE)) }*/ + + val syncPreferences: SyncPreferences = Injekt.get() + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppStart + ) { + SyncDataJob.startNow(this@App) + } + + initializeMigrator() + } + + private fun initializeMigrator() { + val preferenceStore = Injekt.get() + // SY --> + val preference = preferenceStore.getInt(Preference.appStateKey("eh_last_version_code"), 0) + // SY <-- + logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" } + Migrator.initialize( + old = preference.get(), + new = BuildConfig.VERSION_CODE, + migrations = migrations, + onMigrationComplete = { + logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" } + preference.set(BuildConfig.VERSION_CODE) + }, + ) } override fun newImageLoader(context: Context): ImageLoader { return ImageLoader.Builder(this).apply { val callFactoryLazy = lazy { Injekt.get().client } - val diskCacheLazy = lazy { CoilDiskCache.get(this@App) } components { add(OkHttpNetworkFetcherFactory(callFactoryLazy::value)) add(TachiyomiImageDecoder.Factory()) - add(MangaCoverFetcher.MangaFactory(callFactoryLazy, diskCacheLazy)) - add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy, diskCacheLazy)) + add(MangaCoverFetcher.MangaFactory(callFactoryLazy)) + add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy)) add(MangaKeyer()) add(MangaCoverKeyer()) // SY --> add(PagePreviewKeyer()) - add(PagePreviewFetcher.Factory(callFactoryLazy, diskCacheLazy)) + add(PagePreviewFetcher.Factory(callFactoryLazy)) // SY <-- } - diskCache(diskCacheLazy::value) crossfade((300 * this@App.animatorDurationScale).toInt()) allowRgb565(DeviceUtil.isLowRamDevice(this@App)) if (networkPreferences.verboseLogging().get()) logger(DebugLogger()) @@ -199,6 +227,13 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor override fun onStart(owner: LifecycleOwner) { SecureActivityDelegate.onApplicationStart() + + val syncPreferences: SyncPreferences = Injekt.get() + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppResume + ) { + SyncDataJob.startNow(this@App) + } } override fun onStop(owner: LifecycleOwner) { @@ -232,6 +267,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to modify notification channels" } } + + val syncPreferences: SyncPreferences = Injekt.get() + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppStart) { + SyncDataJob.startNow(this@App) + } } // EXH @@ -329,24 +370,3 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor } private const val ACTION_DISABLE_INCOGNITO_MODE = "tachi.action.DISABLE_INCOGNITO_MODE" - -/** - * Direct copy of Coil's internal SingletonDiskCache so that [MangaCoverFetcher] can access it. - */ -private object CoilDiskCache { - - private const val FOLDER_NAME = "image_cache" - private var instance: DiskCache? = null - - @Synchronized - fun get(context: Context): DiskCache { - return instance ?: run { - val safeCacheDir = context.cacheDir.apply { mkdirs() } - // Create the singleton disk cache instance. - DiskCache.Builder() - .directory(safeCacheDir.resolve(FOLDER_NAME)) - .build() - .also { instance = it } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt deleted file mode 100644 index 192274e3e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ /dev/null @@ -1,464 +0,0 @@ -package eu.kanade.tachiyomi - -import android.content.Context -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import eu.kanade.domain.base.BasePreferences -import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.core.security.SecurityPreferences -import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob -import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.network.NetworkPreferences -import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE -import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences -import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.system.workManager -import tachiyomi.core.common.preference.Preference -import tachiyomi.core.common.preference.PreferenceStore -import tachiyomi.core.common.preference.TriState -import tachiyomi.core.common.preference.getAndSet -import tachiyomi.core.common.preference.getEnum -import tachiyomi.core.common.preference.minusAssign -import tachiyomi.core.common.preference.plusAssign -import tachiyomi.domain.backup.service.BackupPreferences -import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED -import tachiyomi.i18n.MR -import java.io.File - -object Migrations { - - // TODO NATIVE TACHIYOMI MIGRATIONS ARE FUCKED UP DUE TO DIFFERING VERSION NUMBERS - - /** - * Performs a migration when the application is updated. - * - * @return true if a migration is performed, false otherwise. - */ - fun upgrade( - context: Context, - preferenceStore: PreferenceStore, - basePreferences: BasePreferences, - uiPreferences: UiPreferences, - networkPreferences: NetworkPreferences, - sourcePreferences: SourcePreferences, - securityPreferences: SecurityPreferences, - libraryPreferences: LibraryPreferences, - readerPreferences: ReaderPreferences, - backupPreferences: BackupPreferences, - trackerManager: TrackerManager, - ): Boolean { - val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0) - val oldVersion = lastVersionCode.get() - if (oldVersion < BuildConfig.VERSION_CODE) { - lastVersionCode.set(BuildConfig.VERSION_CODE) - - // Always set up background tasks to ensure they're running - LibraryUpdateJob.setupTask(context) - BackupCreateJob.setupTask(context) - - // Fresh install - if (oldVersion == 0) { - return false - } - - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - - if (oldVersion < 15) { - // Delete internal chapter cache dir. - File(context.cacheDir, "chapter_disk_cache").deleteRecursively() - } - if (oldVersion < 19) { - // Move covers to external files dir. - val oldDir = File(context.externalCacheDir, "cover_disk_cache") - if (oldDir.exists()) { - val destDir = context.getExternalFilesDir("covers") - if (destDir != null) { - oldDir.listFiles()?.forEach { - it.renameTo(File(destDir, it.name)) - } - } - } - } - if (oldVersion < 26) { - // Delete external chapter cache dir. - val extCache = context.externalCacheDir - if (extCache != null) { - val chapterCache = File(extCache, "chapter_disk_cache") - if (chapterCache.exists()) { - chapterCache.deleteRecursively() - } - } - } - if (oldVersion < 44) { - // Reset sorting preference if using removed sort by source - val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0) - - if (oldSortingMode == 5) { // SOURCE = 5 - prefs.edit { - putInt(libraryPreferences.sortingMode().key(), 0) // ALPHABETICAL = 0 - } - } - } - if (oldVersion < 52) { - // Migrate library filters to tri-state versions - fun convertBooleanPrefToTriState(key: String): Int { - val oldPrefValue = prefs.getBoolean(key, false) - return if (oldPrefValue) { - 1 - } else { - 0 - } - } - prefs.edit { - putInt( - libraryPreferences.filterDownloaded().key(), - convertBooleanPrefToTriState("pref_filter_downloaded_key"), - ) - remove("pref_filter_downloaded_key") - - putInt( - libraryPreferences.filterUnread().key(), - convertBooleanPrefToTriState("pref_filter_unread_key"), - ) - remove("pref_filter_unread_key") - - putInt( - libraryPreferences.filterCompleted().key(), - convertBooleanPrefToTriState("pref_filter_completed_key"), - ) - remove("pref_filter_completed_key") - } - } - if (oldVersion < 54) { - // Force MAL log out due to login flow change - // v52: switched from scraping to WebView - // v53: switched from WebView to OAuth - if (trackerManager.myAnimeList.isLoggedIn) { - trackerManager.myAnimeList.logout() - context.toast(MR.strings.myanimelist_relogin) - } - } - if (oldVersion < 57) { - // Migrate DNS over HTTPS setting - val wasDohEnabled = prefs.getBoolean("enable_doh", false) - if (wasDohEnabled) { - prefs.edit { - putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE) - remove("enable_doh") - } - } - } - if (oldVersion < 59) { - // Reset rotation to Free after replacing Lock - if (prefs.contains("pref_rotation_type_key")) { - prefs.edit { - putInt("pref_rotation_type_key", 1) - } - } - } - if (oldVersion < 60) { - // Migrate Rotation and Viewer values to default values for viewer_flags - val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) { - 1 -> ReaderOrientation.FREE.flagValue - 2 -> ReaderOrientation.PORTRAIT.flagValue - 3 -> ReaderOrientation.LANDSCAPE.flagValue - 4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue - 5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue - else -> ReaderOrientation.FREE.flagValue - } - - // Reading mode flag and prefValue is the same value - val newReadingMode = prefs.getInt("pref_default_viewer_key", 1) - - prefs.edit { - putInt("pref_default_orientation_type_key", newOrientation) - remove("pref_rotation_type_key") - putInt("pref_default_reading_mode_key", newReadingMode) - remove("pref_default_viewer_key") - } - } - if (oldVersion < 61) { - // Handle removed every 1 or 2 hour library updates - val updateInterval = libraryPreferences.autoUpdateInterval().get() - if (updateInterval == 1 || updateInterval == 2) { - libraryPreferences.autoUpdateInterval().set(3) - LibraryUpdateJob.setupTask(context, 3) - } - } - if (oldVersion < 64) { - val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0) - val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true) - - @Suppress("DEPRECATION") - val newSortingMode = when (oldSortingMode) { - 0 -> "ALPHABETICAL" - 1 -> "LAST_READ" - 2 -> "LAST_CHECKED" - 3 -> "UNREAD" - 4 -> "TOTAL_CHAPTERS" - 6 -> "LATEST_CHAPTER" - 8 -> "DATE_FETCHED" - 7 -> "DATE_ADDED" - else -> "ALPHABETICAL" - } - - val newSortingDirection = when (oldSortingDirection) { - true -> "ASCENDING" - else -> "DESCENDING" - } - - prefs.edit(commit = true) { - remove(libraryPreferences.sortingMode().key()) - remove("library_sorting_ascending") - } - - prefs.edit { - putString(libraryPreferences.sortingMode().key(), newSortingMode) - putString("library_sorting_ascending", newSortingDirection) - } - } - if (oldVersion < 70) { - if (sourcePreferences.enabledLanguages().isSet()) { - sourcePreferences.enabledLanguages() += "all" - } - } - if (oldVersion < 71) { - // Handle removed every 3, 4, 6, and 8 hour library updates - val updateInterval = libraryPreferences.autoUpdateInterval().get() - if (updateInterval in listOf(3, 4, 6, 8)) { - libraryPreferences.autoUpdateInterval().set(12) - LibraryUpdateJob.setupTask(context, 12) - } - } - if (oldVersion < 72) { - val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true) - if (!oldUpdateOngoingOnly) { - libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED - } - } - if (oldVersion < 75) { - val oldSecureScreen = prefs.getBoolean("secure_screen", false) - if (oldSecureScreen) { - securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS) - } - if ( - DeviceUtil.isMiui && - basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller.PACKAGEINSTALLER - ) { - basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY) - } - } - if (oldVersion < 77) { - val oldReaderTap = prefs.getBoolean("reader_tap", false) - if (!oldReaderTap) { - readerPreferences.navigationModePager().set(5) - readerPreferences.navigationModeWebtoon().set(5) - } - } - if (oldVersion < 81) { - // Handle renamed enum values - prefs.edit { - val newSortingMode = when ( - val oldSortingMode = prefs.getString( - libraryPreferences.sortingMode().key(), - "ALPHABETICAL", - ) - ) { - "LAST_CHECKED" -> "LAST_MANGA_UPDATE" - "UNREAD" -> "UNREAD_COUNT" - "DATE_FETCHED" -> "CHAPTER_FETCH_DATE" - else -> oldSortingMode - } - putString(libraryPreferences.sortingMode().key(), newSortingMode) - } - } - if (oldVersion < 82) { - prefs.edit { - val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit - val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!! - putString(libraryPreferences.sortingMode().key(), "$sort,$direction") - remove("library_sorting_ascending") - } - } - if (oldVersion < 84) { - if (backupPreferences.backupInterval().get() == 0) { - backupPreferences.backupInterval().set(12) - BackupCreateJob.setupTask(context) - } - } - if (oldVersion < 85) { - val preferences = listOf( - libraryPreferences.filterChapterByRead(), - libraryPreferences.filterChapterByDownloaded(), - libraryPreferences.filterChapterByBookmarked(), - libraryPreferences.sortChapterBySourceOrNumber(), - libraryPreferences.displayChapterByNameOrNumber(), - libraryPreferences.sortChapterByAscendingOrDescending(), - ) - - prefs.edit { - preferences.forEach { preference -> - val key = preference.key() - val value = prefs.getInt(key, Int.MIN_VALUE) - if (value == Int.MIN_VALUE) return@forEach - remove(key) - putLong(key, value.toLong()) - } - } - } - if (oldVersion < 86) { - if (uiPreferences.themeMode().isSet()) { - prefs.edit { - val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit - putString(uiPreferences.themeMode().key(), themeMode.uppercase()) - } - } - } - if (oldVersion < 92) { - val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) - trackingQueuePref.all.forEach { - val (_, lastChapterRead) = it.value.toString().split(":") - trackingQueuePref.edit { - remove(it.key) - putFloat(it.key, lastChapterRead.toFloat()) - } - } - } - if (oldVersion < 96) { - LibraryUpdateJob.cancelAllWorks(context) - LibraryUpdateJob.setupTask(context) - } - if (oldVersion < 97) { - // Removed background jobs - context.workManager.cancelAllWorkByTag("UpdateChecker") - context.workManager.cancelAllWorkByTag("ExtensionUpdate") - prefs.edit { - remove("automatic_ext_updates") - } - } - if (oldVersion < 99) { - val prefKeys = listOf( - "pref_filter_library_downloaded", - "pref_filter_library_unread", - "pref_filter_library_started", - "pref_filter_library_bookmarked", - "pref_filter_library_completed", - ) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" } - - prefKeys.forEach { key -> - val pref = preferenceStore.getInt(key, 0) - prefs.edit { - remove(key) - - val newValue = when (pref.get()) { - 1 -> TriState.ENABLED_IS - 2 -> TriState.ENABLED_NOT - else -> TriState.DISABLED - } - - preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue) - } - } - } - if (oldVersion < 105) { - val pref = libraryPreferences.autoUpdateDeviceRestrictions() - if (pref.isSet() && "battery_not_low" in pref.get()) { - pref.getAndSet { it - "battery_not_low" } - } - } - if (oldVersion < 106) { - val pref = preferenceStore.getInt("relative_time", 7) - if (pref.get() == 0) { - uiPreferences.relativeTime().set(false) - } - } - if (oldVersion < 113) { - val prefsToReplace = listOf( - "pref_download_only", - "incognito_mode", - "last_catalogue_source", - "trusted_signatures", - "last_app_closed", - "library_update_last_timestamp", - "library_unseen_updates_count", - "last_used_category", - "last_app_check", - "last_ext_check", - "last_version_code", - "storage_dir", - ) - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key in prefsToReplace }, - newKey = { Preference.appStateKey(it) }, - ) - - // Deleting old download cache index files, but might as well clear it all out - context.cacheDir.deleteRecursively() - } - if (oldVersion < 114) { - sourcePreferences.extensionRepos().getAndSet { - it.map { repo -> "https://raw.githubusercontent.com/$repo/repo" }.toSet() - } - } - if (oldVersion < 116) { - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") }, - newKey = { Preference.privateKey(it) }, - ) - } - if (oldVersion < 117) { - prefs.edit { - remove(Preference.appStateKey("trusted_signatures")) - } - } - return true - } - - return false - } -} - -@Suppress("UNCHECKED_CAST") -private fun replacePreferences( - preferenceStore: PreferenceStore, - filterPredicate: (Map.Entry) -> Boolean, - newKey: (String) -> String, -) { - preferenceStore.getAll() - .filter(filterPredicate) - .forEach { (key, value) -> - when (value) { - is Int -> { - preferenceStore.getInt(newKey(key)).set(value) - preferenceStore.getInt(key).delete() - } - is Long -> { - preferenceStore.getLong(newKey(key)).set(value) - preferenceStore.getLong(key).delete() - } - is Float -> { - preferenceStore.getFloat(newKey(key)).set(value) - preferenceStore.getFloat(key).delete() - } - is String -> { - preferenceStore.getString(newKey(key)).set(value) - preferenceStore.getString(key).delete() - } - is Boolean -> { - preferenceStore.getBoolean(newKey(key)).set(value) - preferenceStore.getBoolean(key).delete() - } - is Set<*> -> (value as? Set)?.let { - preferenceStore.getStringSet(newKey(key)).set(value) - preferenceStore.getStringSet(key).delete() - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt index 73ccbd3dc..182f840fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt @@ -132,34 +132,34 @@ class BackupCreator( } } - private suspend fun backupCategories(options: BackupOptions): List { + suspend fun backupCategories(options: BackupOptions): List { if (!options.categories) return emptyList() return categoriesBackupCreator.backupCategories() } - private suspend fun backupMangas(mangas: List, options: BackupOptions): List { + suspend fun backupMangas(mangas: List, options: BackupOptions): List { return mangaBackupCreator.backupMangas(mangas, options) } - private fun backupSources(mangas: List): List { + fun backupSources(mangas: List): List { return sourcesBackupCreator.backupSources(mangas) } - private fun backupAppPreferences(options: BackupOptions): List { + fun backupAppPreferences(options: BackupOptions): List { if (!options.appSettings) return emptyList() return preferenceBackupCreator.backupAppPreferences(includePrivatePreferences = options.privateSettings) } - private fun backupSourcePreferences(options: BackupOptions): List { + fun backupSourcePreferences(options: BackupOptions): List { if (!options.sourceSettings) return emptyList() return preferenceBackupCreator.backupSourcePreferences(includePrivatePreferences = options.privateSettings) } // SY --> - private suspend fun backupSavedSearches(): List { + suspend fun backupSavedSearches(): List { return savedSearchBackupCreator.backupSavedSearches() } // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index ba7379c9f..395393f3e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -134,6 +134,7 @@ private fun Manga.toBackupManga(/* SY --> */customMangaInfo: CustomMangaInfo?/* updateStrategy = this.updateStrategy, lastModifiedAt = this.lastModifiedAt, favoriteModifiedAt = this.favoriteModifiedAt, + version = this.version, // SY --> ).also { backupManga -> customMangaInfo?.let { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt index 3bf95c6c0..d729efe16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber import tachiyomi.domain.chapter.model.Chapter +@Suppress("MagicNumber") @Serializable data class BackupChapter( // in 1.x some of these values have different names @@ -21,6 +22,7 @@ data class BackupChapter( @ProtoNumber(9) var chapterNumber: Float = 0F, @ProtoNumber(10) var sourceOrder: Long = 0, @ProtoNumber(11) var lastModifiedAt: Long = 0, + @ProtoNumber(12) var version: Long = 0, ) { fun toChapterImpl(): Chapter { return Chapter.create().copy( @@ -35,36 +37,40 @@ data class BackupChapter( dateUpload = this@BackupChapter.dateUpload, sourceOrder = this@BackupChapter.sourceOrder, lastModifiedAt = this@BackupChapter.lastModifiedAt, + version = this@BackupChapter.version, ) } } -val backupChapterMapper = - { _: Long, - _: Long, - url: String, - name: String, - scanlator: String?, - read: Boolean, - bookmark: Boolean, - lastPageRead: Long, - chapterNumber: Double, - source_order: Long, - dateFetch: Long, - dateUpload: Long, - lastModifiedAt: Long, - -> - BackupChapter( - url = url, - name = name, - chapterNumber = chapterNumber.toFloat(), - scanlator = scanlator, - read = read, - bookmark = bookmark, - lastPageRead = lastPageRead, - dateFetch = dateFetch, - dateUpload = dateUpload, - sourceOrder = source_order, - lastModifiedAt = lastModifiedAt, - ) - } +val backupChapterMapper = { + _: Long, + _: Long, + url: String, + name: String, + scanlator: String?, + read: Boolean, + bookmark: Boolean, + lastPageRead: Long, + chapterNumber: Double, + sourceOrder: Long, + dateFetch: Long, + dateUpload: Long, + lastModifiedAt: Long, + version: Long, + _: Long, + -> + BackupChapter( + url = url, + name = name, + chapterNumber = chapterNumber.toFloat(), + scanlator = scanlator, + read = read, + bookmark = bookmark, + lastPageRead = lastPageRead, + dateFetch = dateFetch, + dateUpload = dateUpload, + sourceOrder = sourceOrder, + lastModifiedAt = lastModifiedAt, + version = version, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt index e6a4f6948..a62bc942e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt @@ -6,7 +6,10 @@ import kotlinx.serialization.protobuf.ProtoNumber import tachiyomi.domain.manga.model.CustomMangaInfo import tachiyomi.domain.manga.model.Manga -@Suppress("DEPRECATION") +@Suppress( + "DEPRECATION", + "MagicNumber", +) @Serializable data class BackupManga( // in 1.x some of these values have different names @@ -40,6 +43,7 @@ data class BackupManga( @ProtoNumber(106) var lastModifiedAt: Long = 0, @ProtoNumber(107) var favoriteModifiedAt: Long? = null, @ProtoNumber(108) var excludedScanlators: List = emptyList(), + @ProtoNumber(109) var version: Long = 0, // SY specific values @ProtoNumber(600) var mergedMangaReferences: List = emptyList(), @@ -76,6 +80,7 @@ data class BackupManga( updateStrategy = this@BackupManga.updateStrategy, lastModifiedAt = this@BackupManga.lastModifiedAt, favoriteModifiedAt = this@BackupManga.favoriteModifiedAt, + version = this@BackupManga.version, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt index a84e6af90..d58213aac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt @@ -34,7 +34,7 @@ class BackupRestorer( private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), - private val mangaRestorer: MangaRestorer = MangaRestorer(), + private val mangaRestorer: MangaRestorer = MangaRestorer(isSync), // SY --> private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(), // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt index f98af1045..36539b211 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt @@ -17,14 +17,19 @@ class CategoriesRestorer( if (backupCategories.isNotEmpty()) { val dbCategories = getCategories.await() val dbCategoriesByName = dbCategories.associateBy { it.name } + var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0 - val categories = backupCategories.map { - dbCategoriesByName[it.name] - ?: handler.awaitOneExecutable { - categoriesQueries.insert(it.name, it.order, it.flags) - categoriesQueries.selectLastInsertedRowId() - }.let { id -> it.toCategory(id) } - } + val categories = backupCategories + .sortedBy { it.order } + .distinctBy { it.name } + .map { + val newOrder = nextOrder++ + dbCategoriesByName[it.name] + ?: handler.awaitOneExecutable { + categoriesQueries.insert(it.name, newOrder, it.flags) + categoriesQueries.selectLastInsertedRowId() + }.let { id -> it.toCategory(id).copy(order = newOrder) } + } libraryPreferences.categorizedDisplaySettings().set( (dbCategories + categories) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 79e5def68..e62417e4d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -33,6 +33,8 @@ import java.util.Date import kotlin.math.max class MangaRestorer( + private var isSync: Boolean = false, + private val handler: DatabaseHandler = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId = Injekt.get(), @@ -47,7 +49,6 @@ class MangaRestorer( private val getFlatMetadataById: GetFlatMetadataById = Injekt.get(), // SY <-- ) { - private var now = ZonedDateTime.now() private var currentFetchWindow = fetchInterval.getWindow(now) @@ -97,6 +98,11 @@ class MangaRestorer( customManga = backupManga.getCustomMangaInfo(), // SY <-- ) + + if (isSync) { + mangasQueries.resetIsSyncing() + chaptersQueries.resetIsSyncing() + } } } @@ -105,7 +111,7 @@ class MangaRestorer( } private suspend fun restoreExistingManga(manga: Manga, dbManga: Manga): Manga { - return if (manga.lastModifiedAt > dbManga.lastModifiedAt) { + return if (manga.version > dbManga.version) { updateManga(dbManga.copyFrom(manga).copy(id = dbManga.id)) } else { updateManga(manga.copyFrom(dbManga).copy(id = dbManga.id)) @@ -124,10 +130,11 @@ class MangaRestorer( ogStatus = newer.status, // SY <-- initialized = this.initialized || newer.initialized, + version = newer.version, ) } - private suspend fun updateManga(manga: Manga): Manga { + suspend fun updateManga(manga: Manga): Manga { handler.await(true) { mangasQueries.update( source = manga.source, @@ -150,6 +157,8 @@ class MangaRestorer( dateAdded = manga.dateAdded, mangaId = manga.id, updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), + version = manga.version, + isSyncing = 1, ) } return manga @@ -161,6 +170,7 @@ class MangaRestorer( return manga.copy( initialized = manga.description != null, id = insertManga(manga), + version = manga.version, ) } @@ -169,36 +179,15 @@ class MangaRestorer( .associateBy { it.url } val (existingChapters, newChapters) = backupChapters - .mapNotNull { - val chapter = it.toChapterImpl().copy(mangaId = manga.id) - + .mapNotNull { backupChapter -> + val chapter = backupChapter.toChapterImpl().copy(mangaId = manga.id) val dbChapter = dbChaptersByUrl[chapter.url] - ?: // New chapter - return@mapNotNull chapter - if (chapter.forComparison() == dbChapter.forComparison()) { - // Same state; skip - return@mapNotNull null + when { + dbChapter == null -> chapter // New chapter + chapter.forComparison() == dbChapter.forComparison() -> null // Same state; skip + else -> updateChapterBasedOnSyncState(chapter, dbChapter) } - - // Update to an existing chapter - var updatedChapter = chapter - .copyFrom(dbChapter) - .copy( - id = dbChapter.id, - bookmark = chapter.bookmark || dbChapter.bookmark, - ) - if (dbChapter.read && !updatedChapter.read) { - updatedChapter = updatedChapter.copy( - read = true, - lastPageRead = dbChapter.lastPageRead, - ) - } else if (updatedChapter.lastPageRead == 0L && dbChapter.lastPageRead != 0L) { - updatedChapter = updatedChapter.copy( - lastPageRead = dbChapter.lastPageRead, - ) - } - updatedChapter } .partition { it.id > 0 } @@ -206,8 +195,29 @@ class MangaRestorer( updateExistingChapters(existingChapters) } + private fun updateChapterBasedOnSyncState(chapter: Chapter, dbChapter: Chapter): Chapter { + return if (isSync) { + chapter.copy( + id = dbChapter.id, + bookmark = chapter.bookmark || dbChapter.bookmark, + read = chapter.read, + lastPageRead = chapter.lastPageRead, + ) + } else { + chapter.copyFrom(dbChapter).let { + when { + dbChapter.read && !it.read -> it.copy(read = true, lastPageRead = dbChapter.lastPageRead) + it.lastPageRead == 0L && dbChapter.lastPageRead != 0L -> it.copy( + lastPageRead = dbChapter.lastPageRead, + ) + else -> it + } + } + } + } + private fun Chapter.forComparison() = - this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L, lastModifiedAt = 0L) + this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L, lastModifiedAt = 0L, version = 0L) private suspend fun insertNewChapters(chapters: List) { handler.await(true) { @@ -224,6 +234,7 @@ class MangaRestorer( chapter.sourceOrder, chapter.dateFetch, chapter.dateUpload, + chapter.version, ) } } @@ -245,6 +256,8 @@ class MangaRestorer( dateFetch = null, dateUpload = null, chapterId = chapter.id, + version = chapter.version, + isSyncing = 1, ) } } @@ -277,6 +290,7 @@ class MangaRestorer( coverLastModified = manga.coverLastModified, dateAdded = manga.dateAdded, updateStrategy = manga.updateStrategy, + version = manga.version, ) mangasQueries.selectLastInsertedRowId() } @@ -299,7 +313,7 @@ class MangaRestorer( restoreCategories(manga, categories, backupCategories) restoreChapters(manga, chapters) restoreTracking(manga, tracks) - restoreHistory(history) + restoreHistory(manga, history) restoreExcludedScanlators(manga, excludedScanlators) updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow) // SY --> @@ -345,13 +359,14 @@ class MangaRestorer( } } - private suspend fun restoreHistory(backupHistory: List) { + private suspend fun restoreHistory(manga: Manga, backupHistory: List) { val toUpdate = backupHistory.mapNotNull { history -> - val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(history.url) } + val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(manga.id, history.url) } val item = history.getHistoryImpl() if (dbHistory == null) { - val chapter = handler.awaitOneOrNull { chaptersQueries.getChapterByUrl(history.url) } + val chapter = handler.awaitList { chaptersQueries.getChapterByUrl(history.url) } + .find { it.manga_id == manga.id } return@mapNotNull if (chapter == null) { // Chapter doesn't exist; skip null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt index 0556d9d1f..11ff6a277 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt @@ -46,6 +46,7 @@ import java.io.IOException * Available request parameter: * - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true */ +@Suppress("LongParameterList") class MangaCoverFetcher( private val url: String?, private val isLibraryManga: Boolean, @@ -55,7 +56,7 @@ class MangaCoverFetcher( private val diskCacheKeyLazy: Lazy, private val sourceLazy: Lazy, private val callFactoryLazy: Lazy, - private val diskCacheLazy: Lazy, + private val imageLoader: ImageLoader, ) : Fetcher { private val diskCacheKey: String @@ -207,7 +208,7 @@ class MangaCoverFetcher( private fun moveSnapshotToCoverCache(snapshot: DiskCache.Snapshot, cacheFile: File?): File? { if (cacheFile == null) return null return try { - diskCacheLazy.value.run { + imageLoader.diskCache?.run { fileSystem.source(snapshot.data).use { input -> writeSourceToCoverCache(input, cacheFile) } @@ -248,7 +249,7 @@ class MangaCoverFetcher( private fun readFromDiskCache(): DiskCache.Snapshot? { return if (options.diskCachePolicy.readEnabled) { - diskCacheLazy.value.openSnapshot(diskCacheKey) + imageLoader.diskCache?.openSnapshot(diskCacheKey) } else { null } @@ -257,9 +258,10 @@ class MangaCoverFetcher( private fun writeToDiskCache( response: Response, ): DiskCache.Snapshot? { - val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null + val diskCache = imageLoader.diskCache + val editor = diskCache?.openEditor(diskCacheKey) ?: return null try { - diskCacheLazy.value.fileSystem.write(editor.data) { + diskCache.fileSystem.write(editor.data) { response.body.source().readAll(this) } return editor.commitAndOpenSnapshot() @@ -299,7 +301,6 @@ class MangaCoverFetcher( class MangaFactory( private val callFactoryLazy: Lazy, - private val diskCacheLazy: Lazy, ) : Fetcher.Factory { private val coverCache: CoverCache by injectLazy() @@ -312,17 +313,16 @@ class MangaCoverFetcher( options = options, coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) }, customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) }, - diskCacheKeyLazy = lazy { MangaKeyer().key(data, options) }, + diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! }, sourceLazy = lazy { sourceManager.get(data.source) as? HttpSource }, callFactoryLazy = callFactoryLazy, - diskCacheLazy = diskCacheLazy, + imageLoader = imageLoader, ) } } class MangaCoverFactory( private val callFactoryLazy: Lazy, - private val diskCacheLazy: Lazy, ) : Fetcher.Factory { private val coverCache: CoverCache by injectLazy() @@ -335,10 +335,10 @@ class MangaCoverFetcher( options = options, coverFileLazy = lazy { coverCache.getCoverFile(data.url) }, customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.mangaId) }, - diskCacheKeyLazy = lazy { MangaCoverKeyer().key(data, options) }, + diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! }, sourceLazy = lazy { sourceManager.get(data.sourceId) as? HttpSource }, callFactoryLazy = callFactoryLazy, - diskCacheLazy = diskCacheLazy, + imageLoader = imageLoader, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt index 23395f58a..bf3a26742 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt @@ -34,6 +34,7 @@ import java.io.IOException * Disk caching is handled by [PagePreviewCache], otherwise * handled by Coil's [DiskCache]. */ +@Suppress("LongParameterList") class PagePreviewFetcher( private val page: PagePreview, private val options: Options, @@ -43,7 +44,7 @@ class PagePreviewFetcher( private val diskCacheKeyLazy: Lazy, private val sourceLazy: Lazy, private val callFactoryLazy: Lazy, - private val diskCacheLazy: Lazy, + private val imageLoader: ImageLoader, ) : Fetcher { private val diskCacheKey: String @@ -164,7 +165,7 @@ class PagePreviewFetcher( private fun moveSnapshotToPagePreviewCache(snapshot: DiskCache.Snapshot): File? { return try { - diskCacheLazy.value.run { + imageLoader.diskCache?.run { fileSystem.source(snapshot.data).use { input -> writeSourceToPagePreviewCache(input) } @@ -203,15 +204,16 @@ class PagePreviewFetcher( } private fun readFromDiskCache(): DiskCache.Snapshot? { - return if (options.diskCachePolicy.readEnabled) diskCacheLazy.value.openSnapshot(diskCacheKey) else null + return if (options.diskCachePolicy.readEnabled) imageLoader.diskCache?.openSnapshot(diskCacheKey) else null } private fun writeToDiskCache( response: Response, ): DiskCache.Snapshot? { - val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null + val diskCache = imageLoader.diskCache + val editor = diskCache?.openEditor(diskCacheKey) ?: return null try { - diskCacheLazy.value.fileSystem.write(editor.data) { + diskCache.fileSystem.write(editor.data) { response.body.source().readAll(this) } return editor.commitAndOpenSnapshot() @@ -235,7 +237,6 @@ class PagePreviewFetcher( class Factory( private val callFactoryLazy: Lazy, - private val diskCacheLazy: Lazy, ) : Fetcher.Factory { private val pagePreviewCache: PagePreviewCache by injectLazy() @@ -248,10 +249,10 @@ class PagePreviewFetcher( pagePreviewFile = { pagePreviewCache.getImageFile(data.imageUrl) }, isInCache = { pagePreviewCache.isImageInCache(data.imageUrl) }, writeToCache = { pagePreviewCache.putImageToCache(data.imageUrl, it) }, - diskCacheKeyLazy = lazy { PagePreviewKeyer().key(data, options) }, + diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! }, sourceLazy = lazy { sourceManager.get(data.source) as? PagePreviewSource }, callFactoryLazy = callFactoryLazy, - diskCacheLazy = diskCacheLazy, + imageLoader = imageLoader, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt index 78e8832f2..627514370 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt @@ -9,7 +9,6 @@ import coil3.decode.Decoder import coil3.decode.ImageSource import coil3.fetch.SourceFetchResult import coil3.request.Options -import coil3.request.allowRgb565 import eu.kanade.tachiyomi.util.storage.CbzCrypto import net.lingala.zip4j.ZipFile import net.lingala.zip4j.model.FileHeader @@ -46,7 +45,7 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" } - val bitmap = decoder.decode(rgb565 = options.allowRgb565) + val bitmap = decoder.decode() decoder.recycle() check(bitmap != null) { "Failed to decode image" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt index 4ff50483e..f91368084 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt @@ -21,6 +21,8 @@ interface Chapter : SChapter, Serializable { var source_order: Int var last_modified: Long + + var version: Long } fun Chapter.toDomainChapter(): DomainChapter? { @@ -39,5 +41,6 @@ fun Chapter.toDomainChapter(): DomainChapter? { chapterNumber = chapter_number.toDouble(), scanlator = scanlator, lastModifiedAt = last_modified, + version = version, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt index 58ba41dec..a92dd56df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt @@ -28,6 +28,8 @@ class ChapterImpl : Chapter { override var last_modified: Long = 0 + override var version: Long = 0 + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 57c416bde..ef8bec2b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -45,6 +46,7 @@ import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.storage.service.StorageManager @@ -69,6 +71,9 @@ class DownloadCache( ) { private val scope = CoroutineScope(Dispatchers.IO) + // KMK --> + private val downloadPreferences: DownloadPreferences = Injekt.get() + // KMK <-- private val _changes: Channel = Channel(Channel.UNLIMITED) val changes = _changes.receiveAsFlow() @@ -79,7 +84,11 @@ class DownloadCache( * The interval after which this cache should be invalidated. 1 hour shouldn't cause major * issues, as the cache is only used for UI feedback. */ - private val renewInterval = 1.hours.inWholeMilliseconds + private val renewInterval //= 1.hours.inWholeMilliseconds + // KMK --> + get() = downloadPreferences.downloadCacheRenewInterval().get() + .hours.inWholeMilliseconds + // KMK <-- /** * The last time the cache was refreshed. @@ -320,7 +329,7 @@ class DownloadCache( lastRenew = 0L renewalJob?.cancel() diskCacheFile.delete() - renewCache() + renewCache(/* KMK --> */ 0L /* KMK <-- */) } // KMK --> @@ -335,9 +344,9 @@ class DownloadCache( /** * Renews the downloads cache. */ - private fun renewCache() { + private fun renewCache(/* KMK --> */ renewInterval: Long = this.renewInterval /* KMK <-- */) { // Avoid renewing cache if in the process nor too often - if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) { + if (/* KMK --> */ renewInterval < 0L || /* KMK <-- */ lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) { return } @@ -347,19 +356,15 @@ class DownloadCache( } // Try to wait until extensions and sources have loaded - var sources = getSources() - if (sources.isEmpty()) { - withTimeoutOrNull(30.seconds) { - while (!extensionManager.isInitialized) { - delay(2.seconds) - } + // SY --> + var sources = emptyList() + withTimeoutOrNull(30.seconds) { + extensionManager.isInitialized.first { it } + sourceManager.isInitialized.first { it } - while (extensionManager.availableExtensionsFlow.value.isNotEmpty() && sources.isEmpty()) { - delay(2.seconds) - sources = getSources() - } - } + sources = getSources() } + // SY <-- val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 577adbdeb..90ef63638 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.storage.CbzCrypto -import eu.kanade.tachiyomi.util.storage.CbzCrypto.addFilesToZip import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.saveTo @@ -46,6 +45,7 @@ import logcat.LogPriority import nl.adaptivity.xmlutil.serialization.XML import okhttp3.Response import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.storage.addFilesToZip import tachiyomi.core.common.storage.extension import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchNow @@ -572,10 +572,6 @@ class Downloader( tmpDir, imageFile, filenamePrefix, - // SY --> - zip4jFile = null, - zip4jEntry = null, - // SY <-- ) } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to split downloaded image" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 658464d82..fb48efbce 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -21,11 +21,13 @@ import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.model.copyFrom import eu.kanade.domain.manga.model.toSManga +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.model.toDbTrack import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.track.TrackStatus import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.source.model.SManga @@ -801,6 +803,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // SY <-- ): Boolean { val wm = context.workManager + // Check if the LibraryUpdateJob is already running if (wm.isRunning(TAG)) { // Already running either as a scheduled or manual job return false @@ -814,12 +817,41 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet KEY_GROUP_EXTRA to groupExtra, // SY <-- ) - val request = OneTimeWorkRequestBuilder() - .addTag(TAG) - .addTag(WORK_NAME_MANUAL) - .setInputData(inputData) - .build() - wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request) + + val syncPreferences: SyncPreferences = Injekt.get() + + // Always sync the data before library update if syncing is enabled. + if (syncPreferences.isSyncEnabled()) { + // Check if SyncDataJob is already running + if (SyncDataJob.isRunning(context)) { + // SyncDataJob is already running + return false + } + + // Define the SyncDataJob + val syncDataJob = OneTimeWorkRequestBuilder() + .addTag(SyncDataJob.TAG_MANUAL) + .build() + + // Chain SyncDataJob to run before LibraryUpdateJob + val libraryUpdateJob = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_NAME_MANUAL) + .setInputData(inputData) + .build() + + wm.beginUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, syncDataJob) + .then(libraryUpdateJob) + .enqueue() + } else { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_NAME_MANUAL) + .setInputData(inputData) + .build() + + wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request) + } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index df4ca6c97..cd4908141 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -10,6 +10,7 @@ import androidx.core.net.toUri import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity @@ -71,6 +72,8 @@ class NotificationReceiver : BroadcastReceiver() { "application/x-protobuf+gzip", ) ACTION_CANCEL_RESTORE -> cancelRestore(context) + + ACTION_CANCEL_SYNC -> cancelSync(context) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) // Start downloading app update @@ -188,6 +191,15 @@ class NotificationReceiver : BroadcastReceiver() { AppUpdateDownloadJob.stop(context) } + /** + * Method called when user wants to stop a backup restore job. + * + * @param context context of application + */ + private fun cancelSync(context: Context) { + SyncDataJob.stop(context) + } + /** * Method called when user wants to mark manga chapters as read * @@ -240,6 +252,8 @@ class NotificationReceiver : BroadcastReceiver() { private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE" + private const val ACTION_CANCEL_SYNC = "$ID.$NAME.CANCEL_SYNC" + private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" private const val ACTION_START_APP_UPDATE = "$ID.$NAME.ACTION_START_APP_UPDATE" @@ -618,5 +632,25 @@ class NotificationReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } + + /** + * Returns [PendingIntent] that cancels a sync restore job. + * + * @param context context of application + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun cancelSyncPendingBroadcast(context: Context, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_SYNC + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt index beefba353..b7b53d835 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt @@ -79,7 +79,7 @@ class ImageSaver( MediaStore.Images.Media.RELATIVE_PATH to relativePath, MediaStore.Images.Media.DISPLAY_NAME to image.name, MediaStore.Images.Media.MIME_TYPE to type.mime, - MediaStore.Images.Media.DATE_MODIFIED to Instant.now().toEpochMilli(), + MediaStore.Images.Media.DATE_MODIFIED to Instant.now().epochSecond, ) val picture = findUriOrDefault(relativePath, filename) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt new file mode 100644 index 000000000..dae3a4b06 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt @@ -0,0 +1,126 @@ +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.WorkInfo +import androidx.work.WorkQuery +import androidx.work.WorkerParameters +import eu.kanade.domain.sync.SyncPreferences +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.cancelNotification +import eu.kanade.tachiyomi.util.system.isRunning +import eu.kanade.tachiyomi.util.system.setForegroundSafely +import eu.kanade.tachiyomi.util.system.workManager +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat +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 { + if (tags.contains(TAG_AUTO)) { + // Find a running manual worker. If exists, try again later + if (context.workManager.isRunning(TAG_MANUAL)) { + return Result.retry() + } + } + + setForegroundSafely() + + return try { + SyncManager(context).syncData() + Result.success() + } 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" + + fun isRunning(context: Context): Boolean { + return context.workManager.isRunning(TAG_JOB) + } + + fun setupTask(context: Context, prefInterval: Int? = null) { + val syncPreferences = Injekt.get() + val interval = prefInterval ?: syncPreferences.syncInterval().get() + + if (interval > 0) { + val request = PeriodicWorkRequestBuilder( + interval.toLong(), + TimeUnit.MINUTES, + 10, + TimeUnit.MINUTES, + ) + .addTag(TAG_JOB) + .addTag(TAG_AUTO) + .build() + + context.workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request) + } else { + context.workManager.cancelUniqueWork(TAG_AUTO) + } + } + + fun startNow(context: Context) { + val wm = context.workManager + if (wm.isRunning(TAG_JOB)) { + // Already running either as a scheduled or manual job + return + } + val request = OneTimeWorkRequestBuilder() + .addTag(TAG_JOB) + .addTag(TAG_MANUAL) + .build() + context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request) + } + + fun stop(context: Context) { + val wm = context.workManager + val workQuery = WorkQuery.Builder.fromTags(listOf(TAG_JOB, TAG_AUTO, TAG_MANUAL)) + .addStates(listOf(WorkInfo.State.RUNNING)) + .build() + wm.getWorkInfos(workQuery).get() + // Should only return one work but just in case + .forEach { + wm.cancelWorkById(it.id) + + // Re-enqueue cancelled scheduled work + if (it.tags.contains(TAG_AUTO)) { + setupTask(context) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt new file mode 100644 index 000000000..186042ac1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -0,0 +1,349 @@ +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, + + // SY --> + customInfo = syncOptions.customInfo, + readEntries = syncOptions.readEntries, + // SY <-- + ) + + logcat(LogPriority.DEBUG) { "Begin create backup" } + val backup = Backup( + backupManga = backupCreator.backupMangas(databaseManga, backupOptions), + backupCategories = backupCreator.backupCategories(backupOptions), + backupSources = backupCreator.backupSources(databaseManga), + backupPreferences = backupCreator.backupAppPreferences(backupOptions), + backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions), + + // SY --> + backupSavedSearches = backupCreator.backupSavedSearches(), + // SY <-- + ) + logcat(LogPriority.DEBUG) { "End create backup" } + + // Create the SyncData object + val syncData = SyncData( + deviceId = syncPreferences.uniqueDeviceID(), + 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) + + if (remoteBackup == null) { + logcat(LogPriority.DEBUG) { "Skip restore due to network issues" } + // should we call showSyncError? + return + } + + if (remoteBackup === syncData.backup){ + // nothing changed + logcat(LogPriority.DEBUG) { "Skip restore due to remote was overwrite from local" } + syncPreferences.lastSyncTimestamp().set(Date().time) + notifier.showSyncSuccess("Sync completed successfully") + return + } + + // Stop the sync early if the remote backup is null or empty + if (remoteBackup.backupManga?.size == 0) { + 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 + } + + 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 { + return handler.awaitList { mangasQueries.getAllManga(::mapManga) } + } + + private suspend fun getAllMangaThatNeedsSync(): List { + 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, remoteChapters: List): 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> { + val favorites = mutableListOf() + val nonFavorites = mutableListOf() + 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) { + 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) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt new file mode 100644 index 000000000..2321240a6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt @@ -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) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/SyncTriggerOptions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/SyncTriggerOptions.kt new file mode 100644 index 000000000..690483039 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/SyncTriggerOptions.kt @@ -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.sy.SYMR + +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 = SYMR.strings.sync_on_chapter_read, + getter = SyncTriggerOptions::syncOnChapterRead, + setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) }, + ), + Entry( + label = SYMR.strings.sync_on_chapter_open, + getter = SyncTriggerOptions::syncOnChapterOpen, + setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) }, + ), + Entry( + label = SYMR.strings.sync_on_app_start, + getter = SyncTriggerOptions::syncOnAppStart, + setter = { options, enabled -> options.copy(syncOnAppStart = enabled) }, + ), + Entry( + label = SYMR.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 }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt new file mode 100644 index 000000000..8756502f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt @@ -0,0 +1,539 @@ +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.InputStreamContent +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 eu.kanade.tachiyomi.data.backup.models.Backup +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import logcat.LogPriority +import logcat.logcat +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.system.logcat +import tachiyomi.i18n.MR +import tachiyomi.i18n.sy.SYMR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException +import java.io.PipedInputStream +import java.io.PipedOutputStream +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(), + ) + + 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 doSync(syncData: SyncData): Backup? { + beforeSync() + + try { + val remoteSData = pullSyncData() + + if (remoteSData != null ){ + // Get local unique device ID + val localDeviceId = syncPreferences.uniqueDeviceID() + val lastSyncDeviceId = remoteSData.deviceId + + // Log the device IDs + logcat(LogPriority.DEBUG, "SyncService") { + "Local device ID: $localDeviceId, Last sync device ID: $lastSyncDeviceId" + } + + // check if the last sync was done by the same device if so overwrite the remote data with the local data + return if (lastSyncDeviceId == localDeviceId) { + pushSyncData(syncData) + syncData.backup + }else{ + // Merge the local and remote sync data + val mergedSyncData = mergeSyncData(syncData, remoteSData) + pushSyncData(mergedSyncData) + mergedSyncData.backup + } + } + + pushSyncData(syncData) + return syncData.backup + } catch (e: Exception) { + logcat(LogPriority.ERROR, "SyncService") { "Error syncing: ${e.message}" } + return null + } + } + + private suspend fun beforeSync() { + try { + googleDriveService.refreshToken() + val drive = googleDriveService.driveService + ?: throw Exception(context.stringResource(SYMR.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(SYMR.strings.error_before_sync_gdrive) + ": Max retries reached.") + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, throwable = e) { "Error in GoogleDrive beforeSync" } + throw Exception(context.stringResource(SYMR.strings.error_before_sync_gdrive) + ": ${e.message}", e) + } + } + + private fun pullSyncData(): SyncData? { + val drive = googleDriveService.driveService ?: + throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in)) + + val fileList = getAppDataFileList(drive) + if (fileList.isEmpty()) { + logcat(LogPriority.INFO) { "No files found in app data" } + return null + } + + val gdriveFileId = fileList[0].id + logcat(LogPriority.DEBUG) { "Google Drive File ID: $gdriveFileId" } + + try { + drive.files().get(gdriveFileId).executeMediaAsInputStream().use { inputStream -> + GZIPInputStream(inputStream).use { gzipInputStream -> + return Json.decodeFromStream(SyncData.serializer(), gzipInputStream) + } + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, throwable = e) { "Error downloading file" } + throw Exception("Failed to download sync data: ${e.message}", e) + } + } + + private suspend fun pushSyncData(syncData: SyncData) { + val drive = googleDriveService.driveService + ?: throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in)) + + val fileList = getAppDataFileList(drive) + + PipedOutputStream().use { pos -> + PipedInputStream(pos).use { pis -> + withIOContext { + // Start a coroutine or a background thread to write JSON to the PipedOutputStream + launch { + GZIPOutputStream(pos).use { gzipOutputStream -> + Json.encodeToStream(SyncData.serializer(), syncData, gzipOutputStream) + } + } + + if (fileList.isNotEmpty()) { + val fileId = fileList[0].id + val mediaContent = InputStreamContent("application/gzip", pis) + drive.files().update(fileId, null, mediaContent).execute() + logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" } + } else { + val fileMetadata = File().apply { + name = remoteFileName + mimeType = "application/gzip" + parents = listOf("appDataFolder") + } + val mediaContent = InputStreamContent("application/gzip", pis) + val uploadedFile = drive.files().create(fileMetadata, mediaContent) + .setFields("id") + .execute() + logcat(LogPriority.DEBUG) { "Created new sync data file in Google Drive with file ID: ${uploadedFile.id}" } + } + } + } + } + } + + private fun getAppDataFileList(drive: Drive): MutableList { + 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 { + 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(SYMR.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() + + 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 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(SYMR.strings.google_drive_not_signed_in)) + } + + credential.refreshToken = refreshToken + + try { + credential.refreshToken() + val newAccessToken = credential.accessToken + // Save the new access token + syncPreferences.googleDriveAccessToken().set(newAccessToken) + setupGoogleDriveService(newAccessToken, credential.refreshToken) + } 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") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt new file mode 100644 index 000000000..64a6146dd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -0,0 +1,515 @@ +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 deviceId: String = "", + val backup: Backup? = null, +) + +abstract class SyncService( + val context: Context, + val json: Json, + val syncPreferences: SyncPreferences, +) { + abstract suspend fun doSync(syncData: SyncData): Backup?; + + /** + * 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. + */ + protected fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData { + val mergedCategoriesList = + mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories) + + val mergedMangaList = mergeMangaLists( + localSyncData.backup?.backupManga, + remoteSyncData.backup?.backupManga, + localSyncData.backup?.backupCategories ?: emptyList(), + remoteSyncData.backup?.backupCategories ?: emptyList(), + mergedCategoriesList) + + val mergedSourcesList = + mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources) + val mergedPreferencesList = + 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( + deviceId = syncPreferences.uniqueDeviceID(), + 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?, + remoteMangaList: List?, + localCategories: List, + remoteCategories: List, + mergedCategories: List, + ): List { + 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) } + + val localCategoriesMapByOrder = localCategories.associateBy { it.order } + val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order } + val mergedCategoriesMapByName = mergedCategories.associateBy { it.name } + + fun updateCategories(theManga: BackupManga, theMap: Map): BackupManga { + return theManga.copy(categories = theManga.categories.mapNotNull { + theMap[it]?.let { category -> + mergedCategoriesMapByName[category.name]?.order + } + }) + } + + logcat(LogPriority.DEBUG, logTag) { + "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" + } + + 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 -> updateCategories(local, localCategoriesMapByOrder) + local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder) + local != null && remote != null -> { + // Compare versions to decide which manga to keep + if (local.version >= remote.version) { + logcat(LogPriority.DEBUG, logTag) { + "Keeping local version of ${local.title} with merged chapters." + } + updateCategories( + local.copy(chapters = mergeChapters(local.chapters, remote.chapters)), + localCategoriesMapByOrder + ) + } else { + logcat(LogPriority.DEBUG, logTag) { + "Keeping remote version of ${remote.title} with merged chapters." + } + updateCategories( + remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)), + remoteCategoriesMapByOrder + ) + } + } + 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, + remoteChapters: List, + ): List { + 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?, + remoteCategoriesList: List?, + ): List { + 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() + + 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?, + remoteSources: List? + ): List { + 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?, + remotePreferences: List? + ): List { + 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?, + remotePreferences: List? + ): List { + 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, + remotePrefs: List + ): List { + val mergedPrefsMap = (localPrefs + remotePrefs).associateBy { it.key } + return mergedPrefsMap.values.toList() + } + + // SY --> + private fun mergeSavedSearchesLists( + localSearches: List?, + remoteSearches: List? + ): List { + 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 <-- +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt new file mode 100644 index 000000000..2af4d28ff --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt @@ -0,0 +1,180 @@ +package eu.kanade.tachiyomi.data.sync.service + +import android.content.Context +import eu.kanade.domain.sync.SyncPreferences +import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.models.BackupSerializer +import eu.kanade.tachiyomi.data.sync.SyncNotifier +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.PUT +import eu.kanade.tachiyomi.network.await +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.protobuf.ProtoBuf +import logcat.LogPriority +import logcat.logcat +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.http.HttpStatus +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +class SyncYomiSyncService( + context: Context, + json: Json, + syncPreferences: SyncPreferences, + private val notifier: SyncNotifier, + + private val protoBuf: ProtoBuf = Injekt.get(), +) : SyncService(context, json, syncPreferences) { + + private class SyncYomiException(message: String?) : Exception(message) + + override suspend fun doSync(syncData: SyncData): Backup? { + try { + val (remoteData, etag) = pullSyncData() + + val finalSyncData = if (remoteData != null){ + assert(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" } + logcat(LogPriority.DEBUG, "SyncService") { + "Try update remote data with ETag($etag)" + } + mergeSyncData(syncData, remoteData) + } else { + // init or overwrite remote data + logcat(LogPriority.DEBUG) { + "Try overwrite remote data with ETag($etag)" + } + syncData + } + + pushSyncData(finalSyncData, etag) + return finalSyncData.backup + + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" } + notifier.showSyncError(e.message) + return null + } + } + + private suspend fun pullSyncData(): Pair { + val host = syncPreferences.clientHost().get() + val apiKey = syncPreferences.clientAPIKey().get() + val downloadUrl = "$host/api/sync/content" + + val headersBuilder = Headers.Builder().add("X-API-Token", apiKey) + val lastETag = syncPreferences.lastSyncEtag().get() + if (lastETag != "") { + headersBuilder.add("If-None-Match", lastETag) + } + val headers = headersBuilder.build() + + val downloadRequest = GET( + url = downloadUrl, + headers = headers, + ) + + val client = OkHttpClient() + val response = client.newCall(downloadRequest).await() + + if (response.code == HttpStatus.SC_NOT_MODIFIED) { + // not modified + assert(lastETag.isNotEmpty()) + logcat(LogPriority.INFO) { + "Remote server not modified" + } + return Pair(null, lastETag) + } else if (response.code == HttpStatus.SC_NOT_FOUND) { + // maybe got deleted from remote + return Pair(null, "") + } + + if (response.isSuccessful) { + val newETag = response.headers["ETag"] + .takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag") + + val byteArray = response.body.byteStream().use { + return@use it.readBytes() + } + + return try { + val backup = protoBuf.decodeFromByteArray(BackupSerializer, byteArray) + return Pair(SyncData(backup = backup), newETag) + } catch (_: SerializationException) { + logcat(LogPriority.INFO) { + "Bad content responsed from server" + } + // the body is invalid + // return default value so we can overwrite it + Pair(null, "") + } + + } else { + val responseBody = response.body.string() + notifier.showSyncError("Failed to download sync data: $responseBody") + logcat(LogPriority.ERROR) { "SyncError: $responseBody" } + throw SyncYomiException("Failed to download sync data: $responseBody") + } + } + + /** + * Return true if update success + */ + private suspend fun pushSyncData(syncData: SyncData, eTag: String) { + val backup = syncData.backup ?: return + + val host = syncPreferences.clientHost().get() + val apiKey = syncPreferences.clientAPIKey().get() + val uploadUrl = "$host/api/sync/content" + val timeout = 30L + + val headersBuilder = Headers.Builder().add("X-API-Token", apiKey) + if (eTag.isNotEmpty()) { + headersBuilder.add("If-Match", eTag) + } + val headers = headersBuilder.build() + + // Set timeout to 30 seconds + val client = OkHttpClient.Builder() + .connectTimeout(timeout, TimeUnit.SECONDS) + .readTimeout(timeout, TimeUnit.SECONDS) + .writeTimeout(timeout, TimeUnit.SECONDS) + .build() + + val byteArray = protoBuf.encodeToByteArray(BackupSerializer, backup) + if (byteArray.isEmpty()) { + throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error)) + } + val body = byteArray.toRequestBody("application/octet-stream".toMediaType()) + + val uploadRequest = PUT( + url = uploadUrl, + headers = headers, + body = body, + ) + + val response = client.newCall(uploadRequest).await() + + if (response.isSuccessful) { + val newETag = response.headers["ETag"] + .takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag") + syncPreferences.lastSyncEtag().set(newETag) + logcat(LogPriority.DEBUG) { "SyncYomi sync completed" } + + } else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) { + // other clients updated remote data, will try next time + logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" } + + } else { + val responseBody = response.body.string() + notifier.showSyncError("Failed to upload sync data: $responseBody") + logcat(LogPriority.ERROR) { "SyncError: $responseBody" } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 688142689..0e80d4ff1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.saver.ImageSaver +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.network.JavaScriptEngine @@ -185,5 +186,7 @@ class AppModule(val app: Application) : InjektModule { get() // SY <-- } + + addSingletonFactory { GoogleDriveService(app) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index b56c16cae..f358f8ff5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.di import android.app.Application import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences @@ -66,5 +67,9 @@ class PreferenceModule(val app: Application) : InjektModule { addSingletonFactory { BasePreferences(app, get()) } + + addSingletonFactory { + SyncPreferences(get()) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 302d269a4..588dd98c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map @@ -53,8 +54,10 @@ class ExtensionManager( private val trustExtension: TrustExtension = Injekt.get(), ) { - var isInitialized = false - private set + // SY --> + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + // SY <-- /** * API where all the available extensions can be found. @@ -135,9 +138,9 @@ class ExtensionManager( .map { it.extension } // SY --> .filterNotBlacklisted() - // SY <-- - isInitialized = true + _isInitialized.value = true + // SY <-- } // EXH --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index e43ec5cd9..20981417f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,7 +1,6 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context -import eu.kanade.domain.extension.interactor.OFFICIAL_REPO_BASE_URL import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension @@ -12,9 +11,16 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import exh.source.BlacklistedSources +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import logcat.LogPriority +import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.OFFICIAL_REPO_BASE_URL +import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.OFFICIAL_REPO_SIGNATURE +import mihon.domain.extensionrepo.interactor.GetExtensionRepo +import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo +import mihon.domain.extensionrepo.model.ExtensionRepo import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.util.lang.withIOContext @@ -27,8 +33,12 @@ internal class ExtensionApi { private val networkService: NetworkHelper by injectLazy() private val preferenceStore: PreferenceStore by injectLazy() - private val sourcePreferences: SourcePreferences by injectLazy() + private val getExtensionRepo: GetExtensionRepo by injectLazy() + private val updateExtensionRepo: UpdateExtensionRepo by injectLazy() private val extensionManager: ExtensionManager by injectLazy() + // SY --> + private val sourcePreferences: SourcePreferences by injectLazy() + // SY <-- private val json: Json by injectLazy() private val lastExtCheck: Preference by lazy { @@ -36,18 +46,27 @@ internal class ExtensionApi { } suspend fun findExtensions(): List { + val officialRepo = ExtensionRepo( + baseUrl = OFFICIAL_REPO_BASE_URL, + name = "Komikku Official Extensions Repo", + shortName = "Komikku Official", + website = "https://komikku-app.github.io", + signingKeyFingerprint = OFFICIAL_REPO_SIGNATURE, + ) return withIOContext { - // Combine built-in OFFICIAL repo's extensions list with repo's list - val extensions = buildList { - addAll(getExtensions(OFFICIAL_REPO_BASE_URL)) - sourcePreferences.extensionRepos().get().map { addAll(getExtensions(it)) } + buildList { + // Combine built-in OFFICIAL repo's extensions list with repo's list + addAll(getExtensions(officialRepo)) + getExtensionRepo.getAll() + .map { async { getExtensions(it) } } + .awaitAll() + .flatten() } - - extensions } } - private suspend fun getExtensions(repoBaseUrl: String): List { + private suspend fun getExtensions(extRepo: ExtensionRepo): List { + val repoBaseUrl = extRepo.baseUrl return try { val response = networkService.client .newCall(GET("$repoBaseUrl/index.min.json")) @@ -75,6 +94,9 @@ internal class ExtensionApi { return null } + // Update extension repo details + updateExtensionRepo.awaitAll() + val extensions = if (fromAvailableExtensionList) { extensionManager.availableExtensionsFlow.value } else { @@ -126,6 +148,8 @@ internal class ExtensionApi { libVersion = it.extractLibVersion(), lang = it.lang, isNsfw = it.nsfw == 1, + hasReadme = it.hasReadme == 1, + hasChangelog = it.hasChangelog == 1, sources = it.sources?.map(extensionSourceMapper).orEmpty(), apkName = it.apk, iconUrl = "$repoUrl/icon/${it.pkg}.png", @@ -160,6 +184,8 @@ private data class ExtensionJsonObject( val code: Long, val version: String, val nsfw: Int, + val hasReadme: Int = 0, + val hasChangelog: Int = 0, val sources: List?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt index cd41f78f8..eda627589 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.installer import android.app.Service import android.content.pm.PackageManager import android.os.Build +import android.os.Process import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.util.system.getUriSize import eu.kanade.tachiyomi.util.system.toast @@ -51,7 +52,8 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { val size = service.getUriSize(entry.uri) ?: throw IllegalStateException() service.contentResolver.openInputStream(entry.uri)!!.use { val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - "pm install-create --user current -r -i ${service.packageName} -S $size" + val userId = Process.myUserHandle().hashCode() + "pm install-create --user $userId -r -i ${service.packageName} -S $size" } else { "pm install-create -r -i ${service.packageName} -S $size" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 1789f8887..6c2478141 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -13,6 +13,8 @@ sealed class Extension { abstract val libVersion: Double abstract val lang: String? abstract val isNsfw: Boolean + abstract val hasReadme: Boolean + abstract val hasChangelog: Boolean data class Installed( override val name: String, @@ -22,6 +24,8 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, + override val hasReadme: Boolean, + override val hasChangelog: Boolean, val pkgFactory: String?, val sources: List, val icon: Drawable?, @@ -43,6 +47,8 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, + override val hasReadme: Boolean, + override val hasChangelog: Boolean, val sources: List, val apkName: String, val iconUrl: String, @@ -74,5 +80,7 @@ sealed class Extension { val signatureHash: String, override val lang: String? = null, override val isNsfw: Boolean = false, + override val hasReadme: Boolean = false, + override val hasChangelog: Boolean = false, ) : Extension() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 2b607427c..e2db5e5f6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import logcat.LogPriority +import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.OFFICIAL_REPO_SIGNATURE import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.injectLazy import java.io.File @@ -50,6 +51,8 @@ internal object ExtensionLoader { private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" private const val METADATA_NSFW = "tachiyomi.extension.nsfw" + private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme" + private const val METADATA_HAS_CHANGELOG = "tachiyomi.extension.hasChangelog" const val LIB_VERSION_MIN = 1.4 const val LIB_VERSION_MAX = 1.5 @@ -59,9 +62,6 @@ internal object ExtensionLoader { PackageManager.GET_SIGNATURES or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) - // cuong-tran's key - private const val officialSignature = "cbec121aa82ebb02aaa73806992e0368a97d47b5451ed6524816d03084c45905" - private const val PRIVATE_EXTENSION_EXTENSION = "ext" private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") @@ -275,6 +275,9 @@ internal object ExtensionLoader { return LoadResult.Error } + val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1 + val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1 + val classLoader = try { ChildFirstPathClassLoader(appInfo.sourceDir, null, context.classLoader) } catch (e: Exception) { @@ -322,6 +325,8 @@ internal object ExtensionLoader { libVersion = libVersion, lang = lang, isNsfw = isNsfw, + hasReadme = hasReadme, + hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), isUnofficial = !isOfficiallySigned(signatures), @@ -385,7 +390,7 @@ internal object ExtensionLoader { } private fun isTrusted(pkgInfo: PackageInfo, signatureHash: String): Boolean { - if (officialSignature == signatureHash) { + if (OFFICIAL_REPO_SIGNATURE == signatureHash) { return true } return trustExtension.isTrusted(pkgInfo, signatureHash) @@ -395,7 +400,7 @@ internal object ExtensionLoader { * Showing UNOFFICIAL text on extension */ private fun isOfficiallySigned(signatures: List): Boolean { - return signatures.all { it == officialSignature } + return signatures.all { it == OFFICIAL_REPO_SIGNATURE } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt index 1c0f131ac..139034770 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -103,6 +105,7 @@ class AndroidSourceManager( } } sourcesMapFlow.value = mutableMap + _isInitialized.value = true } } @@ -186,6 +189,9 @@ class AndroidSourceManager( } // SY --> + private val _isInitialized = MutableStateFlow(false) + override val isInitialized: StateFlow = _isInitialized.asStateFlow() + override fun getVisibleOnlineSources() = sourcesMapFlow.value.values .filterIsInstance() .filter { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MergedSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MergedSource.kt index c1c3e1ce4..7587dc8fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MergedSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MergedSource.kt @@ -109,8 +109,6 @@ class MergedSource : HttpSource() { suspend fun fetchChaptersForMergedManga( manga: Manga, downloadChapters: Boolean = true, - editScanlators: Boolean = false, - dedupe: Boolean = true, ) { fetchChaptersAndSync(manga, downloadChapters) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt index 28cf67d2c..491d057cb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt @@ -24,6 +24,7 @@ import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.manga.DuplicateMangasDialog import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.util.removeCovers @@ -39,6 +40,7 @@ import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.mapAsCheckboxState import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchNonCancellable +import tachiyomi.domain.UnsortedPreferences import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.model.Category @@ -252,7 +254,7 @@ class BulkFavoriteScreenModel( .orEmpty() } - suspend fun getDuplicateLibraryManga(manga: Manga): Manga? { + private suspend fun getDuplicateLibraryManga(manga: Manga): Manga? { return getDuplicateLibraryManga.await(manga).getOrNull(0) } @@ -349,7 +351,7 @@ class BulkFavoriteScreenModel( } } - fun setDialog(dialog: Dialog?) { + private fun setDialog(dialog: Dialog?) { mutableState.update { it.copy(dialog = dialog) } @@ -393,6 +395,14 @@ fun AddDuplicateMangaDialog(bulkFavoriteScreenModel: BulkFavoriteScreenModel) { onDismissRequest = bulkFavoriteScreenModel::dismissDialog, onConfirm = { bulkFavoriteScreenModel.addFavorite(dialog.manga) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, + onMigrate = { + PreMigrationScreen.navigateToMigration( + Injekt.get().skipPreMigration().get(), + navigator, + dialog.duplicate.id, + dialog.manga.id, + ) + }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt index 4a4a78cde..1797dc89d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -29,11 +30,14 @@ data class ExtensionDetailsScreen( } val navigator = LocalNavigator.currentOrThrow + val uriHandler = LocalUriHandler.current ExtensionDetailsScreen( navigateUp = navigator::pop, state = state, onClickSourcePreferences = { navigator.push(SourcePreferencesScreen(it)) }, + onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) }, + onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) }, onClickEnableAll = { screenModel.toggleSources(true) }, onClickDisableAll = { screenModel.toggleSources(false) }, onClickClearCookies = screenModel::clearCookies, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt index 4266c4730..7f9145b28 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt @@ -29,6 +29,11 @@ import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +private const val URL_EXTENSION_COMMITS = + "https://github.com/komikku-app/komikku-extensions/commits/master" +private const val URL_EXTENSION_BLOB = + "https://github.com/komikku-app/komikku-extensions/blob/master" + class ExtensionDetailsScreenModel( pkgName: String, context: Context, @@ -83,6 +88,31 @@ class ExtensionDetailsScreenModel( } } + fun getChangelogUrl(): String { + val extension = state.value.extension ?: return "" + + val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") + val pkgFactory = extension.pkgFactory + if (extension.hasChangelog) { + return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md") + } + + // Falling back on GitHub commit history because there is no explicit changelog in extension + return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory) + } + + fun getReadmeUrl(): String { + val extension = state.value.extension ?: return "" + + if (!extension.hasReadme) { + return "https://tachiyomi.org/docs/faq/browse/extensions" + } + + val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") + val pkgFactory = extension.pkgFactory + return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md") + } + fun clearCookies() { val extension = state.value.extension ?: return @@ -118,6 +148,22 @@ class ExtensionDetailsScreenModel( ?.let { toggleSource.await(it, enable) } } + private fun createUrl( + url: String, + pkgName: String, + pkgFactory: String?, + path: String = "", + ): String { + return if (!pkgFactory.isNullOrEmpty()) { + when (path.isEmpty()) { + true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory" + else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path + } + } else { + url + "/src/" + pkgName.replace(".", "/") + path + } + } + @Immutable data class State( val extension: Extension.Installed? = null, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt index 9d5b8c759..2f08f074e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt @@ -39,8 +39,10 @@ import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito import exh.source.EnhancedHttpSource +import exh.ui.ifSourcesLoaded import tachiyomi.domain.source.service.SourceManager import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.LoadingScreen import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -48,6 +50,11 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() { @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow @@ -130,7 +137,7 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { // SY --> val source = Injekt.get() .getOrStub(sourceId) - ?.let { source -> + .let { source -> if (source is EnhancedHttpSource) { if (source.enhancedSource is ConfigurableSource) { source.source() @@ -141,7 +148,6 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { source } } - ?: throw NullPointerException("source = null, SOURCE_ID = $SOURCE_ID") // SY <-- val sourceScreen = preferenceManager.createPreferenceScreen(requireContext()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt index 8197def21..4d8e14bdd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt @@ -287,7 +287,7 @@ open class FeedScreenModel( } @Composable - fun getManga(initialManga: DomainManga, source: CatalogueSource?): State { + fun getManga(initialManga: DomainManga): State { return produceState(initialValue = initialManga) { getManga.subscribe(initialManga.url, initialManga.source) .collectLatest { manga -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt index e74005b6d..242fba874 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt @@ -140,7 +140,7 @@ fun Screen.feedTab( selection = bulkFavoriteState.selection, // KMK <-- onRefresh = screenModel::init, - getMangaState = { manga, source -> screenModel.getManga(initialManga = manga, source = source) }, + getMangaState = { manga -> screenModel.getManga(initialManga = manga) }, ) state.dialog?.let { dialog -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt index 4a1690a6e..61a61a47f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt @@ -46,9 +46,15 @@ import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource +import java.io.Serializable import kotlin.math.roundToInt -class PreMigrationScreen(val mangaIds: List) : Screen() { +sealed class MigrationType : Serializable { + data class MangaList(val mangaIds: List) : MigrationType() + data class MangaSingle(val fromMangaId: Long, val toManga: Long?) : MigrationType() +} + +class PreMigrationScreen(val migration: MigrationType) : Screen() { @Composable override fun Content() { val screenModel = rememberScreenModel { PreMigrationScreenModel() } @@ -173,7 +179,7 @@ class PreMigrationScreen(val mangaIds: List) : Screen() { screenModel.onMigrationSheet(false) screenModel.saveEnabledSources() - navigator replace MigrationListScreen(MigrationProcedureConfig(mangaIds, extraParam)) + navigator replace MigrationListScreen(MigrationProcedureConfig(migration, extraParam)) }, ) } @@ -184,10 +190,22 @@ class PreMigrationScreen(val mangaIds: List) : Screen() { navigator.push( if (skipPre) { MigrationListScreen( - MigrationProcedureConfig(mangaIds, null), + MigrationProcedureConfig(MigrationType.MangaList(mangaIds), null), ) } else { - PreMigrationScreen(mangaIds) + PreMigrationScreen(MigrationType.MangaList(mangaIds)) + }, + ) + } + + fun navigateToMigration(skipPre: Boolean, navigator: Navigator, fromMangaId: Long, toManga: Long?) { + navigator.push( + if (skipPre) { + MigrationListScreen( + MigrationProcedureConfig(MigrationType.MangaSingle(fromMangaId, toManga), null), + ) + } else { + PreMigrationScreen(MigrationType.MangaSingle(fromMangaId, toManga)) }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreen.kt index bed4169e9..15744bb5c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreen.kt @@ -121,7 +121,7 @@ class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen ) val onDismissRequest = { screenModel.dialog.value = null } - when (val dialog = dialog) { + when (@Suppress("NAME_SHADOWING") val dialog = dialog) { is MigrationListScreenModel.Dialog.MigrateMangaDialog -> { MigrationMangaDialog( onDismissRequest = onDismissRequest, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt index c570233e1..a44c95fd0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.getNameForMangaInfo import eu.kanade.tachiyomi.source.online.all.EHentai import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags +import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult import eu.kanade.tachiyomi.util.system.toast import exh.eh.EHentaiThrottleManager @@ -104,8 +105,14 @@ class MigrationListScreenModel( init { screenModelScope.launchIO { + val mangaIds = when (val migration = config.migration) { + is MigrationType.MangaList -> { + migration.mangaIds + } + is MigrationType.MangaSingle -> listOf(migration.fromMangaId) + } runMigrations( - config.mangaIds + mangaIds .map { async { val manga = getManga.await(it) ?: return@async null @@ -161,9 +168,13 @@ class MigrationListScreenModel( break } // in case it was removed - if (manga.manga.id !in config.mangaIds) { - continue + when (val migration = config.migration) { + is MigrationType.MangaList -> if (manga.manga.id !in migration.mangaIds) { + continue + } + else -> Unit } + if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) { val mangaObj = manga.manga val mangaSource = sourceManager.getOrStub(mangaObj.source) @@ -175,6 +186,28 @@ class MigrationListScreenModel( } else { sources.filter { it.id != mangaSource.id } } + when (val migration = config.migration) { + is MigrationType.MangaSingle -> if (migration.toManga != null) { + val localManga = getManga.await(migration.toManga) + if (localManga != null) { + val source = sourceManager.get(localManga.source) as? CatalogueSource + if (source != null) { + val chapters = if (source is EHentai) { + source.getChapterList(localManga.toSManga(), throttleManager::throttle) + } else { + source.getChapterList(localManga.toSManga()) + } + try { + syncChaptersWithSource.await(chapters, localManga, source) + } catch (_: Exception) { + } + manga.progress.value = validSources.size to validSources.size + return@async localManga + } + } + } + else -> Unit + } if (useSourceWithMost) { val sourceSemaphore = Semaphore(3) val processedSources = AtomicInteger() @@ -523,13 +556,18 @@ class MigrationListScreenModel( } fun removeManga(item: MigratingManga) { - val ids = config.mangaIds.toMutableList() - val index = ids.indexOf(item.manga.id) - if (index > -1) { - ids.removeAt(index) - config.mangaIds = ids - val index2 = migratingItems.value.orEmpty().indexOf(item) - if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList() + when (val migration = config.migration) { + is MigrationType.MangaList -> { + val ids = migration.mangaIds.toMutableList() + val index = ids.indexOf(item.manga.id) + if (index > -1) { + ids.removeAt(index) + config.migration = MigrationType.MangaList(ids) + val index2 = migratingItems.value.orEmpty().indexOf(item) + if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList() + } + } + is MigrationType.MangaSingle -> Unit } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationProcedureConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationProcedureConfig.kt index c0469b885..7a57710a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationProcedureConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationProcedureConfig.kt @@ -1,8 +1,9 @@ package eu.kanade.tachiyomi.ui.browse.migration.advanced.process +import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType import java.io.Serializable data class MigrationProcedureConfig( - var mangaIds: List, + var migration: MigrationType, val extraSearchParams: String?, ) : Serializable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt index 1b7083e9e..023686920 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt @@ -29,10 +29,12 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen +import exh.ui.ifSourcesLoaded import kotlinx.collections.immutable.persistentListOf import tachiyomi.core.common.Constants import tachiyomi.domain.manga.model.Manga import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.source.local.LocalSource data class SourceSearchScreen( @@ -43,6 +45,11 @@ data class SourceSearchScreen( @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val uriHandler = LocalUriHandler.current val navigator = LocalNavigator.currentOrThrow @@ -148,7 +155,7 @@ data class SourceSearchScreen( } val onDismissRequest = { screenModel.setDialog(null) } - when (val dialog = state.dialog) { + when (state.dialog) { is BrowseSourceScreenModel.Dialog.Filter -> { SourceFilterDialog( onDismissRequest = onDismissRequest, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt index 855e4d5bc..444ccf579 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt @@ -16,6 +16,7 @@ import eu.kanade.presentation.components.TabContent import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreen import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.DelicateCoroutinesApi import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.domain.UnsortedPreferences @@ -55,6 +56,7 @@ fun Screen.migrateSourceTab(): TabContent { // SY --> onClickAll = { source -> // TODO: Jay wtf, need to clean this up sometime + @OptIn(DelicateCoroutinesApi::class) launchIO { val manga = Injekt.get().await() val sourceMangas = diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt index 0ed54e43a..4ef59ac81 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt @@ -54,6 +54,7 @@ import eu.kanade.tachiyomi.ui.browse.AllowDuplicateDialog import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen +import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -61,17 +62,22 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.util.system.toast import exh.md.follows.MangaDexFollowsScreen +import exh.ui.ifSourcesLoaded import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow import tachiyomi.core.common.Constants import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.domain.UnsortedPreferences import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.source.local.LocalSource +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get data class BrowseSourceScreen( private val sourceId: Long, @@ -89,6 +95,11 @@ data class BrowseSourceScreen( @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val screenModel = rememberScreenModel { BrowseSourceScreenModel( sourceId = sourceId, @@ -358,6 +369,16 @@ data class BrowseSourceScreen( onDismissRequest = onDismissRequest, onConfirm = { screenModel.addFavorite(dialog.manga) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, + onMigrate = { + // SY --> + PreMigrationScreen.navigateToMigration( + Injekt.get().skipPreMigration().get(), + navigator, + dialog.duplicate.id, + dialog.manga.id, + ) + // SY <-- + }, ) } is BrowseSourceScreenModel.Dialog.RemoveManga -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 5d42384e0..9a0a85c04 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -455,7 +455,7 @@ open class BrowseSourceScreenModel( val manga: Manga, val initialSelection: ImmutableList>, ) : Dialog - data class Migrate(val newManga: Manga) : Dialog + data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog // SY --> data class DeleteSavedSearch(val idToDelete: Long, val name: String) : Dialog diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt index dfedfc57a..feb5976ae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt @@ -26,15 +26,22 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.util.system.toast import exh.md.follows.MangaDexFollowsScreen +import exh.ui.ifSourcesLoaded import exh.util.nullIfBlank import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.interactor.GetRemoteManga import tachiyomi.domain.source.model.SavedSearch +import tachiyomi.presentation.core.screens.LoadingScreen class SourceFeedScreen(val sourceId: Long) : Screen() { @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val screenModel = rememberScreenModel { SourceFeedScreenModel(sourceId) } val state by screenModel.state.collectAsState() val navigator = LocalNavigator.currentOrThrow diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt index a641c59dd..92f021efc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt @@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog import eu.kanade.tachiyomi.ui.browse.RemoveMangaDialog import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen +import exh.ui.ifSourcesLoaded import tachiyomi.presentation.core.screens.LoadingScreen class GlobalSearchScreen( @@ -31,6 +32,11 @@ class GlobalSearchScreen( @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val navigator = LocalNavigator.currentOrThrow val screenModel = rememberScreenModel { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 43a94a80a..0f7a4534d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -59,6 +59,7 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collectLatest @@ -740,6 +741,7 @@ class LibraryScreenModel( clearSelection() } + @OptIn(DelicateCoroutinesApi::class) fun syncMangaToDex() { launchIO { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences, sourceManager)?.let { mdex -> @@ -750,6 +752,24 @@ class LibraryScreenModel( clearSelection() } } + + fun resetInfo() { + state.value.selection.fastForEach { (manga) -> + val mangaInfo = CustomMangaInfo( + id = manga.id, + title = null, + author = null, + artist = null, + thumbnailUrl = null, + description = null, + genre = null, + status = null, + ) + + setCustomMangaInfo.set(mangaInfo) + } + clearSelection() + } // SY <-- /** @@ -1336,6 +1356,18 @@ class LibraryScreenModel( val showAddToMangadex: Boolean by lazy { selection.any { it.manga.source in mangaDexSourceIds } } + + val showResetInfo: Boolean by lazy { + selection.fastAny { (manga) -> + manga.title != manga.ogTitle || + manga.author != manga.ogAuthor || + manga.artist != manga.ogArtist || + manga.thumbnailUrl != manga.ogThumbnailUrl || + manga.description != manga.ogDescription || + manga.genre != manga.ogGenre || + manga.status != manga.ogStatus + } + } // SY <-- fun getLibraryItemsByCategoryId(categoryId: Long): List? { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 56e4040f1..4010fae42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -41,6 +41,7 @@ import eu.kanade.presentation.more.onboarding.GETTING_STARTED_URL import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -162,6 +163,13 @@ object LibraryTab : Tab { } } }, + onClickSyncNow = { + if (!SyncDataJob.isRunning(context)) { + SyncDataJob.startNow(context) + } else { + context.toast(SYMR.strings.sync_in_progress) + } + }, // SY --> onClickSyncExh = screenModel::openFavoritesSyncDialog.takeIf { state.showSyncExh }, // SY <-- @@ -197,6 +205,7 @@ object LibraryTab : Tab { } }, onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex }, + onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo }, // SY <-- ) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 89d34c337..49606eeba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -50,8 +50,6 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.firebase.analytics.ktx.analytics import com.google.firebase.ktx.Firebase import eu.kanade.domain.base.BasePreferences -import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.components.AppStateBanners import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor @@ -80,7 +78,6 @@ import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim import eu.kanade.tachiyomi.util.system.isPreviewBuildType import eu.kanade.tachiyomi.util.view.setComposeContent -import exh.EXHMigrations import exh.debug.DebugToggles import exh.eh.EHentaiUpdateWorker import exh.log.DebugModeOverlay @@ -97,6 +94,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import logcat.LogPriority +import mihon.core.migration.Migrator import tachiyomi.core.common.Constants import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.system.logcat @@ -105,17 +103,13 @@ import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.util.collectAsState -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.util.LinkedList import androidx.compose.ui.graphics.Color.Companion as ComposeColor class MainActivity : BaseActivity() { - private val sourcePreferences: SourcePreferences by injectLazy() private val libraryPreferences: LibraryPreferences by injectLazy() - private val uiPreferences: UiPreferences by injectLazy() private val preferences: BasePreferences by injectLazy() // SY --> @@ -165,20 +159,7 @@ class MainActivity : BaseActivity() { val didMigration = if (isLaunch) { addAnalytics() - EXHMigrations.upgrade( - context = applicationContext, - basePreferences = preferences, - uiPreferences = uiPreferences, - preferenceStore = Injekt.get(), - networkPreferences = Injekt.get(), - sourcePreferences = sourcePreferences, - securityPreferences = Injekt.get(), - libraryPreferences = libraryPreferences, - readerPreferences = Injekt.get(), - backupPreferences = Injekt.get(), - trackerManager = Injekt.get(), - pagePreviewCache = Injekt.get(), - ) + Migrator.awaitAndRelease() } else { false } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt index 456456ac5..780616365 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt @@ -66,6 +66,7 @@ fun EditMangaDialog( confirmButton = { TextButton( onClick = { + @Suppress("NAME_SHADOWING") val binding = binding ?: return@TextButton onPositiveClick( binding.title.text.toString(), @@ -120,7 +121,7 @@ fun EditMangaDialog( } private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDialogBinding, scope: CoroutineScope) { - loadCover(manga, context, binding) + loadCover(manga, binding) val statusAdapter: ArrayAdapter = ArrayAdapter( context, @@ -188,7 +189,7 @@ private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDial binding.mangaDescription.hint = context.stringResource( SYMR.strings.description_hint, - manga.ogDescription?.takeIf { it.isNotBlank() }?.let { it.replace("\n", " ").chop(20) } ?: "" + manga.ogDescription?.takeIf { it.isNotBlank() }?.replace("\n", " ")?.chop(20) ?: "" ) binding.thumbnailUrl.hint = context.stringResource( @@ -201,6 +202,7 @@ private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDial binding.mangaGenresTags.clearFocus() binding.resetTags.setOnClickListener { resetTags(manga, binding, scope) } + binding.resetInfo.setOnClickListener { resetInfo(manga, binding, scope) } } private fun resetTags(manga: Manga, binding: EditMangaDialogBinding, scope: CoroutineScope) { @@ -211,12 +213,21 @@ private fun resetTags(manga: Manga, binding: EditMangaDialogBinding, scope: Coro } } -private fun loadCover(manga: Manga, context: Context, binding: EditMangaDialogBinding) { +private fun loadCover(manga: Manga, binding: EditMangaDialogBinding) { binding.mangaCover.load(manga) { transformations(RoundedCornersTransformation(4.dpToPx.toFloat())) } } +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, scope: CoroutineScope) { removeAllViews() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 4c4e0a5f2..ca8263d79 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -42,6 +42,12 @@ import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.browse.AddDuplicateMangaDialog +import eu.kanade.tachiyomi.ui.browse.AllowDuplicateDialog +import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel +import eu.kanade.tachiyomi.ui.browse.ChangeMangaCategoryDialog +import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog +import eu.kanade.tachiyomi.ui.browse.RemoveMangaDialog import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen @@ -64,8 +70,10 @@ import exh.recs.RecommendsScreen import exh.source.MERGED_SOURCE_ID import exh.source.getMainSource import exh.source.isMdBasedSource +import exh.ui.ifSourcesLoaded import exh.ui.metadata.MetadataViewScreen import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take @@ -98,6 +106,11 @@ class MangaScreen( @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val navigator = LocalNavigator.currentOrThrow val context = LocalContext.current val haptic = LocalHapticFeedback.current @@ -115,6 +128,11 @@ class MangaScreen( val successState = state as MangaScreenModel.State.Success val isHttpSource = remember { successState.source is HttpSource } + // KMK --> + val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() } + val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState() + // KMK <-- + LaunchedEffect(successState.manga, screenModel.source) { if (isHttpSource) { try { @@ -219,6 +237,11 @@ class MangaScreen( onChapterSelected = screenModel::toggleSelection, onAllChapterSelected = screenModel::toggleAllSelection, onInvertSelection = screenModel::invertSelection, + // KMK --> + getMangaState = { screenModel.getManga(initialManga = it) }, + onRelatedMangaClick = { navigator.push(MangaScreen(it.id, true)) }, + onRelatedMangaLongClick = { bulkFavoriteScreenModel.addRemoveManga(it, haptic) }, + // KMK <-- ) var showScanlatorsDialog by remember { mutableStateOf(false) } @@ -245,11 +268,19 @@ class MangaScreen( }, ) } - is MangaScreenModel.Dialog.DuplicateManga -> DuplicateMangaDialog( - onDismissRequest = onDismissRequest, - onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) }, - onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, - ) + + is MangaScreenModel.Dialog.DuplicateManga -> { + DuplicateMangaDialog( + onDismissRequest = onDismissRequest, + onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) }, + onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, + onMigrate = { + // SY --> + migrateManga(navigator, dialog.duplicate, screenModel.manga!!.id) + // SY <-- + }, + ) + } MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog( onDismissRequest = onDismissRequest, manga = successState.manga, @@ -336,6 +367,22 @@ class MangaScreen( onConfirm = screenModel::setExcludedScanlators, ) } + + // KMK --> + when (bulkFavoriteState.dialog) { + is BulkFavoriteScreenModel.Dialog.AddDuplicateManga -> + AddDuplicateMangaDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.RemoveManga -> + RemoveMangaDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.ChangeMangaCategory -> + ChangeMangaCategoryDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory -> + ChangeMangasCategoryDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.AllowDuplicate -> + AllowDuplicateDialog(bulkFavoriteScreenModel) + else -> {} + } + // KMK <-- } private fun continueReading(context: Context, unreadChapter: Chapter?) { @@ -451,12 +498,13 @@ class MangaScreen( /** * Initiates source migration for the specific manga. */ - private fun migrateManga(navigator: Navigator, manga: Manga) { + private fun migrateManga(navigator: Navigator, manga: Manga, toMangaId: Long? = null) { // SY --> PreMigrationScreen.navigateToMigration( Injekt.get().skipPreMigration().get(), navigator, - listOf(manga.id), + manga.id, + toMangaId, ) // SY <-- } @@ -499,6 +547,7 @@ class MangaScreen( navigator.push(SourcesScreen(smartSearchConfig)) } + @OptIn(DelicateCoroutinesApi::class) private fun mergeWithAnother( navigator: Navigator, context: Context, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index 33895fb5a..3b9384094 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.ui.manga import android.content.Context import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.util.fastAny import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope @@ -22,6 +24,7 @@ import eu.kanade.domain.manga.model.PagePreview import eu.kanade.domain.manga.model.chaptersFiltered import eu.kanade.domain.manga.model.copyFrom import eu.kanade.domain.manga.model.downloadedFilter +import eu.kanade.domain.manga.model.toDomainManga import eu.kanade.domain.manga.model.toSManga import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.track.interactor.AddTracks @@ -72,6 +75,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -135,6 +139,7 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import kotlin.math.floor +import androidx.compose.runtime.State as RuntimeState class MangaScreenModel( val context: Context, @@ -204,6 +209,11 @@ class MangaScreenModel( private val filteredChapters: List? get() = successState?.processedChapters + // KMK --> + private val relatedMangas: List + get() = successState?.relatedMangas ?: emptyList() + // KMK <-- + val chapterSwipeStartAction = libraryPreferences.swipeToEndAction().get() val chapterSwipeEndAction = libraryPreferences.swipeToStartAction().get() @@ -431,6 +441,9 @@ class MangaScreenModel( val fetchFromSourceTasks = listOf( async { if (needRefreshInfo) fetchMangaFromSource() }, async { if (needRefreshChapter) fetchChaptersFromSource() }, + // KMK --> + async { fetchRelatedMangasFromSource() }, + // KMK <-- ) fetchFromSourceTasks.awaitAll() } @@ -446,6 +459,9 @@ class MangaScreenModel( val fetchFromSourceTasks = listOf( async { fetchMangaFromSource(manualFetch) }, async { fetchChaptersFromSource(manualFetch) }, + // KMK --> + async { fetchRelatedMangasFromSource() }, + // KMK <-- ) fetchFromSourceTasks.awaitAll() updateSuccessState { it.copy(isRefreshingData = false) } @@ -553,6 +569,19 @@ class MangaScreenModel( } } + // KMK --> + @Composable + fun getManga(initialManga: Manga): RuntimeState { + return produceState(initialValue = initialManga) { + getManga.subscribe(initialManga.url, initialManga.source) + .filterNotNull() + .collectLatest { manga -> + value = manga + } + } + } + // KMK <-- + suspend fun smartSearchMerge(manga: Manga, originalMangaId: Long): Manga { val originalManga = getManga.await(originalMangaId) ?: throw IllegalArgumentException(context.stringResource(SYMR.strings.merge_unknown_entry, originalMangaId)) @@ -976,6 +1005,7 @@ class MangaScreenModel( downloadManager.getQueuedDownloadOrNull(chapter.id) } // SY --> + @Suppress("NAME_SHADOWING") val manga = mergedData?.manga?.get(chapter.mangaId) ?: manga val source = mergedData?.sources?.find { manga.source == it.id }?.takeIf { mergedData.sources.size > 2 } // SY <-- @@ -1049,7 +1079,7 @@ class MangaScreenModel( downloadNewChapters(newChapters) } } else { - state.source.fetchChaptersForMergedManga(state.manga, manualFetch, true, dedupe) + state.source.fetchChaptersForMergedManga(state.manga, manualFetch) } } } catch (e: Throwable) { @@ -1068,6 +1098,41 @@ class MangaScreenModel( } } + // KMK --> + /** + * Requests an list of related mangas from the source. + */ + private suspend fun fetchRelatedMangasFromSource() { + val state = successState ?: return + try { + withIOContext { + if (state.source !is MergedSource) { + val relatedMangas = if (Injekt.get().relatedMangas().get()) + withIOContext { + state.source.getRelatedMangaList(state.manga.toSManga()) + .map { + networkToLocalManga.await(it.toDomainManga(state.source.id)) + } + } + else null + updateSuccessState { it.copy(relatedMangas = relatedMangas) } + } else { + throw UnsupportedOperationException("Fetching related titles for merged entry is not supported") + } + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) + val message = with(context) { e.formattedMessage } + + screenModelScope.launch { + snackbarHostState.showSnackbar(message = message) + } + val newManga = mangaRepository.getMangaById(mangaId) + updateSuccessState { it.copy(manga = newManga) } + } + } + // KMK <-- + /** * @throws IllegalStateException if the swipe action is [LibraryPreferences.ChapterSwipeAction.Disabled] */ @@ -1548,6 +1613,9 @@ class MangaScreenModel( ) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog + /* SY --> + data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog + SY <-- */ data class SetFetchInterval(val manga: Manga) : Dialog // SY --> @@ -1580,6 +1648,12 @@ class MangaScreenModel( updateSuccessState { it.copy(dialog = Dialog.FullCover) } } + /* SY --> + fun showMigrateDialog(duplicate: Manga) { + val manga = successState?.manga ?: return + updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) } + } SY <-- */ + fun setExcludedScanlators(excludedScanlators: Set) { screenModelScope.launchIO { setExcludedScanlators.await(mangaId, excludedScanlators) @@ -1637,6 +1711,9 @@ class MangaScreenModel( val alwaysShowReadingProgress: Boolean, val previewsRowCount: Int, // SY <-- + // KMK --> + val relatedMangas: List? = null, + // KMK <-- ) : State { val processedChapters by lazy { chapters.applyFilters(manga).toList() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt index e0ad25182..1ba697f24 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt @@ -427,6 +427,7 @@ private data class TrackDateSelectorScreen( private val start: Boolean, ) : Screen() { + @Transient private val selectableDates = object : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { val dateToCheck = Instant.ofEpochMilli(utcTimeMillis) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index f1313527c..54e96a1c6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -47,6 +47,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.google.android.material.elevation.SurfaceColors import com.google.android.material.transition.platform.MaterialContainerTransform +import com.hippo.unifile.UniFile import dev.chrisbanes.insetter.applyInsetter import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.manga.model.readingMode @@ -91,6 +92,7 @@ import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setComposeContent import exh.source.isEhBasedSource +import exh.ui.ifSourcesLoaded import exh.util.defaultReaderType import exh.util.mangaType import kotlinx.collections.immutable.persistentSetOf @@ -122,6 +124,7 @@ import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.io.ByteArrayOutputStream import kotlin.time.Duration.Companion.seconds class ReaderActivity : BaseActivity() { @@ -391,6 +394,10 @@ class ReaderActivity : BaseActivity() { ) } + if (!ifSourcesLoaded()) { + return@setComposeContent + } + val isHttpSource = viewModel.getSource() is HttpSource val isFullscreen by readerPreferences.fullscreen().collectAsState() val flashOnPageChange by readerPreferences.flashOnPageChange().collectAsState() @@ -1192,8 +1199,8 @@ class ReaderActivity : BaseActivity() { } .launchIn(lifecycleScope) - readerPreferences.trueColor().changes() - .onEach(::setTrueColor) + preferences.displayProfile().changes() + .onEach { setDisplayProfile(it) } .launchIn(lifecycleScope) readerPreferences.cutoutShort().changes() @@ -1261,13 +1268,19 @@ class ReaderActivity : BaseActivity() { } /** - * Sets the 32-bit color mode according to [enabled]. + * Sets the display profile to [path]. */ - private fun setTrueColor(enabled: Boolean) { - if (enabled) { - SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888) - } else { - SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.RGB_565) + private fun setDisplayProfile(path: String) { + val file = UniFile.fromUri(baseContext, path.toUri()) + if (file != null && file.exists()) { + val inputStream = file.openInputStream() + val outputStream = ByteArrayOutputStream() + inputStream.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + SubsamplingScaleImageView.setDisplayProfile(outputStream.toByteArray()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index e37870783..38ffb1ace 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -14,6 +14,7 @@ import eu.kanade.domain.chapter.model.toDbChapter import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.model.readerOrientation import eu.kanade.domain.manga.model.readingMode +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.ui.UiPreferences @@ -24,6 +25,7 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.saver.Image import eu.kanade.tachiyomi.data.saver.ImageSaver import eu.kanade.tachiyomi.data.saver.Location +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource @@ -63,6 +65,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -124,6 +127,7 @@ class ReaderViewModel @JvmOverloads constructor( private val upsertHistory: UpsertHistory = Injekt.get(), private val updateChapter: UpdateChapter = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), + private val syncPreferences: SyncPreferences = Injekt.get(), // SY --> private val uiPreferences: UiPreferences = Injekt.get(), private val getFlatMetadataById: GetFlatMetadataById = Injekt.get(), @@ -336,6 +340,7 @@ class ReaderViewModel @JvmOverloads constructor( val manga = getManga.await(mangaId) if (manga != null) { // SY --> + sourceManager.isInitialized.first { it } val source = sourceManager.getOrStub(manga.source) val metadataSource = source.getMainSource>() val metadata = if (metadataSource != null) { @@ -384,7 +389,6 @@ class ReaderViewModel @JvmOverloads constructor( context = context, downloadManager = downloadManager, downloadProvider = downloadProvider, - tempFileManager = tempFileManager, manga = manga, source = source, /* SY --> */ sourceManager = sourceManager, @@ -675,6 +679,8 @@ class ReaderViewModel @JvmOverloads constructor( hasExtraPage: Boolean, /* SY <-- */ ) { val pageIndex = page.index + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + val isSyncEnabled = syncPreferences.isSyncEnabled() mutableState.update { it.copy(currentPage = pageIndex + 1) @@ -719,6 +725,11 @@ class ReaderViewModel @JvmOverloads constructor( // SY <-- updateTrackChapterRead(readerChapter) deleteChapterIfNeeded(readerChapter) + + // Check if syncing is enabled for chapter read: + if (isSyncEnabled && syncTriggerOpt.syncOnChapterRead) { + SyncDataJob.startNow(Injekt.get()) + } } updateChapter.await( @@ -728,6 +739,11 @@ class ReaderViewModel @JvmOverloads constructor( lastPageRead = readerChapter.chapter.last_page_read.toLong(), ), ) + + // Check if syncing is enabled for chapter open: + if (isSyncEnabled && syncTriggerOpt.syncOnChapterOpen && readerChapter.chapter.last_page_read == 0) { + SyncDataJob.startNow(Injekt.get()) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 0778e75b8..c05563f63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.online.all.MergedSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.storage.UniFileTempFileManager import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.manga.model.Manga @@ -28,7 +27,6 @@ class ChapterLoader( private val context: Context, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, - private val tempFileManager: UniFileTempFileManager, private val manga: Manga, private val source: Source, // SY --> @@ -121,36 +119,39 @@ class ChapterLoader( source = source, downloadManager = downloadManager, downloadProvider = downloadProvider, - tempFileManager = tempFileManager, ) source is HttpSource -> HttpPageLoader(chapter, source) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { is Format.Directory -> DirectoryPageLoader(format.file) - is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file)) + is Format.Zip -> ZipPageLoader(format.file, context) is Format.Rar -> try { - RarPageLoader(tempFileManager.createTempFile(format.file)) + RarPageLoader(format.file) } catch (e: UnsupportedRarV5Exception) { error(context.stringResource(MR.strings.loader_rar5_error)) } - is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file)) + is Format.Epub -> EpubPageLoader(format.file, context) } } else -> error(context.stringResource(MR.strings.loader_not_implemented_error)) } } // SY <-- - isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider, tempFileManager) + isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { is Format.Directory -> DirectoryPageLoader(format.file) - is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file)) + // SY --> + is Format.Zip -> ZipPageLoader(format.file, context) is Format.Rar -> try { - RarPageLoader(tempFileManager.createTempFile(format.file)) + RarPageLoader(format.file) + // SY <-- } catch (e: UnsupportedRarV5Exception) { error(context.stringResource(MR.strings.loader_rar5_error)) } - is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file)) + // SY --> + is Format.Epub -> EpubPageLoader(format.file, context) + // SY <-- } } source is HttpSource -> HttpPageLoader(chapter, source) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index d0af290f2..ff70fec7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import tachiyomi.core.common.storage.UniFileTempFileManager import tachiyomi.domain.manga.model.Manga import uy.kohesive.injekt.injectLazy @@ -23,7 +22,6 @@ internal class DownloadPageLoader( private val source: Source, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, - private val tempFileManager: UniFileTempFileManager, ) : PageLoader() { private val context: Application by injectLazy() @@ -48,7 +46,9 @@ internal class DownloadPageLoader( } private suspend fun getPagesFromArchive(file: UniFile): List { - val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it } + // SY --> + val loader = ZipPageLoader(file, context).also { zipPageLoader = it } + // SY <-- return loader.getPages() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index 324af51bf..3a4d7388c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -1,16 +1,19 @@ package eu.kanade.tachiyomi.ui.reader.loader +import android.content.Context +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.storage.EpubFile -import java.io.File /** * Loader used to load a chapter from a .epub file. */ -internal class EpubPageLoader(file: File) : PageLoader() { +// SY --> +internal class EpubPageLoader(file: UniFile, context: Context) : PageLoader() { - private val epub = EpubFile(file) + private val epub = EpubFile(file, context) + // SY <-- override var isLocal: Boolean = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt index 9ca277f78..04c357a0e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader.loader import android.app.Application +import android.os.Build import com.github.junrar.Archive import com.github.junrar.rarfile.FileHeader import com.hippo.unifile.UniFile @@ -8,6 +9,14 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import tachiyomi.core.common.storage.UniFileTempFileManager import tachiyomi.core.common.util.system.ImageUtil import uy.kohesive.injekt.injectLazy import java.io.File @@ -19,11 +28,17 @@ import java.util.concurrent.Executors /** * Loader used to load a chapter from a .rar or .cbr file. */ -internal class RarPageLoader(file: File) : PageLoader() { - - private val rar = Archive(file) +internal class RarPageLoader(file: UniFile) : PageLoader() { // SY --> + private val tempFileManager: UniFileTempFileManager by injectLazy() + + private val rar = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + Archive(tempFileManager.createTempFile(file)) + } else { + Archive(file.openInputStream()) + } + private val context: Application by injectLazy() private val readerPreferences: ReaderPreferences by injectLazy() private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also { @@ -31,20 +46,21 @@ internal class RarPageLoader(file: File) : PageLoader() { } init { - if (readerPreferences.cacheArchiveMangaOnDisk().get()) { + if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) { tmpDir.mkdirs() - Archive(file).use { rar -> - rar.fileHeaders.asSequence() - .filterNot { it.isDirectory } - .forEach { header -> - val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/")) - .also { it.createNewFile() } - .outputStream() - getStream(header).use { - it.copyTo(pageOutputStream) + rar.fileHeaders.asSequence() + .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .forEach { header -> + File(tmpDir, header.fileName.substringAfterLast("/")) + .also { it.createNewFile() } + .outputStream() + .use { output -> + rar.getInputStream(header).use { input -> + input.copyTo(output) + } } - } - } + } } } // SY <-- @@ -58,16 +74,37 @@ internal class RarPageLoader(file: File) : PageLoader() { override suspend fun getPages(): List { // SY --> - if (readerPreferences.cacheArchiveMangaOnDisk().get()) { + if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) { return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages() } + val mutex = Mutex() // SY <-- return rar.fileHeaders.asSequence() .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } } .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .mapIndexed { i, header -> + // SY --> + val imageBytesDeferred: Deferred? = + when (readerPreferences.archiveReaderMode().get()) { + ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> { + CoroutineScope(Dispatchers.IO).async { + mutex.withLock { + getStream(header).buffered().use { stream -> + stream.readBytes() + } + } + } + } + + else -> null + } + + val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } } + // SY <-- ReaderPage(i).apply { - stream = { getStream(header) } + // SY --> + stream = { imageBytes?.copyOf()?.inputStream() ?: getStream(header) } + // SY <-- status = Page.State.READY } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt index 6b5b6ec01..049018ffb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt @@ -1,6 +1,6 @@ package eu.kanade.tachiyomi.ui.reader.loader -import android.app.Application +import android.content.Context import android.os.Build import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.source.model.Page @@ -8,107 +8,155 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.CbzCrypto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.apache.commons.compress.archivers.zip.ZipFile import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.storage.UniFileTempFileManager +import tachiyomi.core.common.storage.isEncryptedZip +import tachiyomi.core.common.storage.openReadOnlyChannel +import tachiyomi.core.common.storage.testCbzPassword +import tachiyomi.core.common.storage.unzip import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.i18n.sy.SYMR import uy.kohesive.injekt.injectLazy import java.io.File -import java.nio.charset.StandardCharsets -import java.util.zip.ZipFile +import java.nio.channels.SeekableByteChannel import net.lingala.zip4j.ZipFile as Zip4jFile /** * Loader used to load a chapter from a .zip or .cbz file. */ -internal class ZipPageLoader(file: File) : PageLoader() { +internal class ZipPageLoader(file: UniFile, context: Context) : PageLoader() { // SY --> - private val context: Application by injectLazy() + private val channel: SeekableByteChannel = file.openReadOnlyChannel(context) + private val tempFileManager: UniFileTempFileManager by injectLazy() private val readerPreferences: ReaderPreferences by injectLazy() private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also { it.deleteRecursively() } - private val zip4j: Zip4jFile = Zip4jFile(file) - private val zip: ZipFile? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!zip4j.isEncrypted) ZipFile(file, StandardCharsets.ISO_8859_1) else null + + private val apacheZip: ZipFile? = if (!file.isEncryptedZip() && Build.VERSION.SDK_INT > Build.VERSION_CODES.N) { + ZipFile(channel) + } else { + null + } + + private val tmpFile = + if ( + apacheZip == null && + readerPreferences.archiveReaderMode().get() != ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK + ) { + tempFileManager.createTempFile(file) } else { - if (!zip4j.isEncrypted) ZipFile(file) else null + null + } + + private val zip4j = + if (apacheZip == null && tmpFile != null) { + Zip4jFile(tmpFile) + } else { + null } init { - Zip4jFile(file).use { zip -> - if (zip.isEncrypted) { - if (!CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())) { - this.recycle() - throw IllegalStateException(context.stringResource(SYMR.strings.wrong_cbz_archive_password)) - } - zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz()) - if (readerPreferences.cacheArchiveMangaOnDisk().get()) { - unzip() - } - } else { - if (readerPreferences.cacheArchiveMangaOnDisk().get()) { - unzip() - } + if (file.isEncryptedZip()) { + if (!file.testCbzPassword()) { + this.recycle() + throw IllegalStateException(context.stringResource(SYMR.strings.wrong_cbz_archive_password)) } + zip4j?.setPassword(CbzCrypto.getDecryptedPasswordCbz()) + } + if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) { + file.unzip(tmpDir, onlyCopyImages = true) } } // SY <-- override fun recycle() { super.recycle() - zip?.close() + apacheZip?.close() // SY --> - zip4j.close() + zip4j?.close() tmpDir.deleteRecursively() } - private fun unzip() { - tmpDir.mkdirs() - zip4j.fileHeaders.asSequence() - .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } } - .forEach { entry -> - zip4j.extractFile(entry, tmpDir.absolutePath) - } - } override var isLocal: Boolean = true override suspend fun getPages(): List { - if (readerPreferences.cacheArchiveMangaOnDisk().get()) { + if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) { return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages() } - - if (zip == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - zip4j.charset = StandardCharsets.ISO_8859_1 - } - - return zip4j.fileHeaders.asSequence() - .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } } - .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } - .mapIndexed { i, entry -> - ReaderPage(i).apply { - stream = { zip4j.getInputStream(entry) } - status = Page.State.READY - zip4jFile = zip4j - zip4jEntry = entry - } - }.toList() + return if (apacheZip == null) { + loadZip4j() } else { - // SY <-- - return zip.entries().asSequence() - .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .mapIndexed { i, entry -> - ReaderPage(i).apply { - stream = { zip.getInputStream(entry) } - status = Page.State.READY - } - }.toList() + loadApacheZip(apacheZip) } } + private fun loadZip4j(): List { + val mutex = Mutex() + return zip4j!!.fileHeaders.asSequence() + .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .mapIndexed { i, entry -> + val imageBytesDeferred: Deferred? = + when (readerPreferences.archiveReaderMode().get()) { + ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> { + CoroutineScope(Dispatchers.IO).async { + mutex.withLock { + zip4j.getInputStream(entry).buffered().use { stream -> + stream.readBytes() + } + } + } + } + + else -> null + } + val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } } + ReaderPage(i).apply { + stream = { imageBytes?.copyOf()?.inputStream() ?: zip4j.getInputStream(entry) } + status = Page.State.READY + } + }.toList() + } + + private fun loadApacheZip(zip: ZipFile): List { + val mutex = Mutex() + return zip.entries.asSequence() + .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .mapIndexed { i, entry -> + val imageBytesDeferred: Deferred? = + when (readerPreferences.archiveReaderMode().get()) { + ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> { + CoroutineScope(Dispatchers.IO).async { + mutex.withLock { + zip.getInputStream(entry).buffered().use { stream -> + stream.readBytes() + } + } + } + } + + else -> null + } + val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } } + ReaderPage(i).apply { + stream = { imageBytes?.copyOf()?.inputStream() ?: zip.getInputStream(entry) } + status = Page.State.READY + } + }.toList() + } + // SY <-- + /** * No additional action required to load the page */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt index 20b99d9cb..0a6f2d414 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt @@ -1,8 +1,6 @@ package eu.kanade.tachiyomi.ui.reader.model import eu.kanade.tachiyomi.source.model.Page -import net.lingala.zip4j.ZipFile -import net.lingala.zip4j.model.FileHeader import java.io.InputStream open class ReaderPage( @@ -10,9 +8,6 @@ open class ReaderPage( url: String = "", imageUrl: String? = null, // SY --> - /** zip4j inputStreams do not support mark() and release(), so they must be passed to ImageUtil */ - var zip4jFile: ZipFile? = null, - var zip4jEntry: FileHeader? = null, /** Value to check if this page is used to as if it was too wide */ var shiftedPage: Boolean = false, /** Value to check if a page is can be doubled up, but can't because the next page is too wide */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 9203ed1b0..b37d9f285 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -29,9 +29,6 @@ class ReaderPreferences( fun showReadingMode() = preferenceStore.getBoolean("pref_show_reading_mode", true) - // TODO: default this to true if reader long strip ever goes stable - fun trueColor() = preferenceStore.getBoolean("pref_true_color_key", false) - fun fullscreen() = preferenceStore.getBoolean("fullscreen", true) fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true) @@ -180,9 +177,9 @@ class ReaderPreferences( fun centerMarginType() = preferenceStore.getInt("center_margin_type", PagerConfig.CenterMarginType.NONE) - fun cacheArchiveMangaOnDisk() = preferenceStore.getBoolean("cache_archive_manga_on_disk", false) + fun archiveReaderMode() = preferenceStore.getInt("archive_reader_mode", ArchiveReaderMode.LOAD_FROM_FILE) - fun markReadDupe() = preferenceStore.getBoolean("mark_read_dupe", false) + fun markReadDupe() = preferenceStore.getBoolean("mark_read_dupe", false) // SY <-- enum class TappingInvertMode( @@ -203,6 +200,12 @@ class ReaderPreferences( LOWEST(47), } + object ArchiveReaderMode { + const val LOAD_FROM_FILE = 0 + const val LOAD_INTO_MEMORY = 1 + const val CACHE_TO_DISK = 2 + } + companion object { const val WEBTOON_PADDING_MIN = 0 const val WEBTOON_PADDING_MAX = 25 @@ -264,6 +267,12 @@ class ReaderPreferences( SYMR.strings.center_margin_wide_page, SYMR.strings.center_margin_double_and_wide_page, ) + + val archiveModeTypes = listOf( + SYMR.strings.archive_mode_load_from_file, + SYMR.strings.archive_mode_load_into_memory, + SYMR.strings.archive_mode_cache_to_disk + ) // SY <-- } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt index 3397ee643..91045613d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt @@ -343,8 +343,9 @@ open class ReaderPageImageView @JvmOverloads constructor( .diskCachePolicy(CachePolicy.DISABLED) .target( onSuccess = { result -> - setImageDrawable(result.asDrawable(context.resources)) - (result as? Animatable)?.start() + val drawable = result.asDrawable(context.resources) + setImageDrawable(drawable) + (drawable as? Animatable)?.start() isVisible = true this@ReaderPageImageView.onImageLoaded() }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt index 09c47fc7d..13efc429f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt @@ -21,7 +21,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc var doubleTapAnimDuration = 500 var volumeKeysEnabled = false var volumeKeysInverted = false - var trueColor = false var alwaysShowChapterTransition = true var navigationMode = 0 protected set @@ -58,9 +57,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc readerPreferences.readWithVolumeKeysInverted() .register({ volumeKeysInverted = it }) - readerPreferences.trueColor() - .register({ trueColor = it }, { imagePropertyChangedListener?.invoke() }) - readerPreferences.alwaysShowChapterTransition() .register({ alwaysShowChapterTransition = it }) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 9cb629736..4f5636779 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -230,13 +230,7 @@ class PagerPageHolder( return splitInHalf(imageStream) } - val isDoublePage = ImageUtil.isWideImage( - imageStream, - // SY --> - page.zip4jFile, - page.zip4jEntry, - // SY <-- - ) + val isDoublePage = ImageUtil.isWideImage(imageStream) if (!isDoublePage) { return imageStream } @@ -247,13 +241,7 @@ class PagerPageHolder( } private fun rotateDualPage(imageStream: BufferedInputStream): InputStream { - val isDoublePage = ImageUtil.isWideImage( - imageStream, - // SY --> - page.zip4jFile, - page.zip4jEntry, - // SY <-- - ) + val isDoublePage = ImageUtil.isWideImage(imageStream) return if (isDoublePage) { val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f ImageUtil.rotateImage(imageStream, rotation) @@ -267,13 +255,7 @@ class PagerPageHolder( if (imageStream2 == null) { return if (imageStream is BufferedInputStream && !ImageUtil.isAnimatedAndSupported(imageStream) && - ImageUtil.isWideImage( - imageStream, - // SY --> - page.zip4jFile, - page.zip4jEntry, - // SY <-- - ) && + ImageUtil.isWideImage(imageStream) && viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 && !viewer.config.imageCropBorders ) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt index 9e35ae0cc..53d94d13b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt @@ -164,7 +164,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { return when (item) { is ReaderPage -> PagerPageHolder(readerThemedContext, viewer, item, item2 as? ReaderPage) is ChapterTransition -> PagerTransitionHolder(readerThemedContext, viewer, item) - else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented") + // SY --> else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented") SY <-- } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt index cb9f484a4..852cd76e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt @@ -36,20 +36,21 @@ class WebtoonLayoutManager(context: Context) : LinearLayoutManager(context) { */ fun findLastEndVisibleItemPosition(): Int { ensureLayoutState() - @ViewBoundsCheck.ViewBounds val preferredBoundsFlag = - (ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE) - - val fromIndex = childCount - 1 - val toIndex = -1 - - val child = if (mOrientation == HORIZONTAL) { + val callback = if (mOrientation == HORIZONTAL) { mHorizontalBoundCheck - .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) } else { mVerticalBoundCheck - .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) + }.mCallback + val start = callback.parentStart + val end = callback.parentEnd + for (i in childCount - 1 downTo 0) { + val child = getChildAt(i)!! + val childStart = callback.getChildStart(child) + val childEnd = callback.getChildEnd(child) + if (childEnd <= end || childStart < start) { + return getPosition(child) + } } - - return if (child == null) NO_POSITION else getPosition(child) + return NO_POSITION } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 2dcf62723..d57ade4d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -118,6 +118,7 @@ class WebtoonPageHolder( removeErrorLayout() frame.recycle() progressIndicator.setProgress(0) + progressContainer.isVisible = true } /** @@ -224,13 +225,7 @@ class WebtoonPageHolder( } if (viewer.config.dualPageSplit) { - val isDoublePage = ImageUtil.isWideImage( - imageStream, - // SY --> - page?.zip4jFile, - page?.zip4jEntry, - // SY <-- - ) + val isDoublePage = ImageUtil.isWideImage(imageStream) if (isDoublePage) { val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT return ImageUtil.splitAndMerge(imageStream, upperSide) @@ -241,13 +236,7 @@ class WebtoonPageHolder( } private fun rotateDualPage(imageStream: BufferedInputStream): InputStream { - val isDoublePage = ImageUtil.isWideImage( - imageStream, - // SY --> - page?.zip4jFile, - page?.zip4jEntry, - // SY <-- - ) + val isDoublePage = ImageUtil.isWideImage(imageStream) return if (isDoublePage) { val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f ImageUtil.rotateImage(imageStream, rotation) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt new file mode 100644 index 000000000..7dfcdd824 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.setting.track + +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.i18n.sy.SYMR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class GoogleDriveLoginActivity : BaseOAuthLoginActivity() { + private val googleDriveService = Injekt.get() + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") + val error = data?.getQueryParameter("error") + if (code != null) { + lifecycleScope.launchIO { + googleDriveService.handleAuthorizationCode( + code, + this@GoogleDriveLoginActivity, + onSuccess = { + Toast.makeText( + this@GoogleDriveLoginActivity, + stringResource(SYMR.strings.google_drive_login_success), + Toast.LENGTH_LONG, + ).show() + + returnToSettings() + }, + onFailure = { error -> + Toast.makeText( + this@GoogleDriveLoginActivity, + stringResource(SYMR.strings.google_drive_login_failed, error), + Toast.LENGTH_LONG, + ).show() + returnToSettings() + }, + ) + } + } else if (error != null) { + Toast.makeText( + this@GoogleDriveLoginActivity, + stringResource(SYMR.strings.google_drive_login_failed, error), + Toast.LENGTH_LONG, + ).show() + + returnToSettings() + } else { + returnToSettings() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt index c0ed08aa7..566d061d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt @@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event import kotlinx.coroutines.flow.collectLatest +import mihon.feature.upcoming.UpcomingScreen import tachiyomi.core.common.i18n.stringResource import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource @@ -91,6 +92,7 @@ object UpdatesTab : Tab { val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId) context.startActivity(intent) }, + onCalendarClicked = { navigator.push(UpcomingScreen()) }, ) val onDismissDialog = { screenModel.setDialog(null) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt index c911e3dc2..a8a5018c6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt @@ -63,7 +63,7 @@ class CrashLogUtil( """ - ${it.name} Installed: ${it.versionName} / Available: ${availableExtension?.versionName ?: "?"} - Obsolete: ${it.isObsolete} + Obsolete: ${it.isObsolete} / Unofficial: ${it.isUnofficial} """.trimIndent() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt index 8326fe2f8..8827ae4a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt @@ -39,6 +39,10 @@ fun Long.toLocalDate(): LocalDate { return LocalDate.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault()) } +fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate { + return LocalDate.ofInstant(this, zoneId) +} + fun LocalDate.toRelativeString( context: Context, relative: Boolean = true, @@ -56,14 +60,12 @@ fun LocalDate.toRelativeString( difference.toInt().absoluteValue, difference.toInt().absoluteValue, ) - difference < 1 -> context.stringResource(MR.strings.relative_time_today) difference < 7 -> context.pluralStringResource( MR.plurals.relative_time, difference.toInt(), difference.toInt(), ) - else -> dateFormat.format(this) } } diff --git a/app/src/main/java/exh/EXHMigrations.kt b/app/src/main/java/exh/EXHMigrations.kt index 334618982..81ba03268 100644 --- a/app/src/main/java/exh/EXHMigrations.kt +++ b/app/src/main/java/exh/EXHMigrations.kt @@ -1,670 +1,15 @@ package exh -import android.content.Context -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import eu.kanade.domain.base.BasePreferences -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.core.security.SecurityPreferences -import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob -import eu.kanade.tachiyomi.data.cache.PagePreviewCache -import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.network.NetworkPreferences -import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE -import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.all.NHentai -import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences -import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.workManager -import exh.eh.EHentaiUpdateWorker -import exh.log.xLogE import exh.source.BlacklistedSources import exh.source.EH_SOURCE_ID import exh.source.HBROWSE_SOURCE_ID -import exh.source.MERGED_SOURCE_ID import exh.source.TSUMINO_SOURCE_ID -import exh.util.nullIfBlank -import exh.util.under -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import logcat.LogPriority -import tachiyomi.core.common.preference.Preference -import tachiyomi.core.common.preference.PreferenceStore -import tachiyomi.core.common.preference.TriState -import tachiyomi.core.common.preference.getAndSet -import tachiyomi.core.common.preference.getEnum -import tachiyomi.core.common.preference.minusAssign -import tachiyomi.core.common.util.system.logcat -import tachiyomi.data.DatabaseHandler -import tachiyomi.data.category.CategoryMapper -import tachiyomi.data.chapter.ChapterMapper -import tachiyomi.domain.backup.service.BackupPreferences -import tachiyomi.domain.chapter.interactor.DeleteChapters -import tachiyomi.domain.chapter.interactor.UpdateChapter -import tachiyomi.domain.chapter.model.ChapterUpdate -import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED -import tachiyomi.domain.manga.interactor.GetManga -import tachiyomi.domain.manga.interactor.GetMangaBySource -import tachiyomi.domain.manga.interactor.InsertMergedReference import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.manga.model.MangaUpdate -import tachiyomi.domain.manga.model.MergedMangaReference -import tachiyomi.domain.source.interactor.InsertFeedSavedSearch -import tachiyomi.domain.source.interactor.InsertSavedSearch -import tachiyomi.domain.source.model.FeedSavedSearch -import tachiyomi.domain.source.model.SavedSearch -import tachiyomi.domain.source.service.SourceManager -import uy.kohesive.injekt.injectLazy -import java.io.File import java.net.URI import java.net.URISyntaxException object EXHMigrations { - private val handler: DatabaseHandler by injectLazy() - private val sourceManager: SourceManager by injectLazy() - private val getManga: GetManga by injectLazy() - private val getMangaBySource: GetMangaBySource by injectLazy() - private val updateManga: UpdateManga by injectLazy() - private val updateChapter: UpdateChapter by injectLazy() - private val deleteChapters: DeleteChapters by injectLazy() - private val insertMergedReference: InsertMergedReference by injectLazy() - private val insertSavedSearch: InsertSavedSearch by injectLazy() - private val insertFeedSavedSearch: InsertFeedSavedSearch by injectLazy() - - /** - * Performs a migration when the application is updated. - * - * @return true if a migration is performed, false otherwise. - */ - fun upgrade( - context: Context, - preferenceStore: PreferenceStore, - basePreferences: BasePreferences, - uiPreferences: UiPreferences, - networkPreferences: NetworkPreferences, - sourcePreferences: SourcePreferences, - securityPreferences: SecurityPreferences, - libraryPreferences: LibraryPreferences, - readerPreferences: ReaderPreferences, - backupPreferences: BackupPreferences, - trackerManager: TrackerManager, - pagePreviewCache: PagePreviewCache, - ): Boolean { - val lastVersionCode = preferenceStore.getInt("eh_last_version_code", 0) - val oldVersion = lastVersionCode.get() - try { - if (oldVersion < BuildConfig.VERSION_CODE) { - lastVersionCode.set(BuildConfig.VERSION_CODE) - - LibraryUpdateJob.setupTask(context) - BackupCreateJob.setupTask(context) - EHentaiUpdateWorker.scheduleBackground(context) - - // Fresh install - if (oldVersion == 0) { - return false - } - - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - if (oldVersion under 4) { - updateSourceId(HBROWSE_SOURCE_ID, 6912) - // Migrate BHrowse URLs - val hBrowseManga = runBlocking { getMangaBySource.await(HBROWSE_SOURCE_ID) } - val mangaUpdates = hBrowseManga.map { - MangaUpdate(it.id, url = it.url + "/c00001/") - } - - runBlocking { - updateManga.awaitAll(mangaUpdates) - } - } - if (oldVersion under 6) { - updateSourceId(NHentai.otherId, 6907) - } - if (oldVersion under 7) { - val mergedMangas = runBlocking { getMangaBySource.await(MERGED_SOURCE_ID) } - - if (mergedMangas.isNotEmpty()) { - val mangaConfigs = mergedMangas.mapNotNull { mergedManga -> - readMangaConfig(mergedManga)?.let { mergedManga to it } - } - if (mangaConfigs.isNotEmpty()) { - val mangaToUpdate = mutableListOf() - val mergedMangaReferences = mutableListOf() - mangaConfigs.onEach { mergedManga -> - val newFirst = mergedManga.second.children.firstOrNull()?.url?.let { - if (runBlocking { getManga.await(it, MERGED_SOURCE_ID) } != null) return@onEach - mangaToUpdate += MangaUpdate(id = mergedManga.first.id, url = it) - mergedManga.first.copy(url = it) - } ?: mergedManga.first - mergedMangaReferences += MergedMangaReference( - id = -1, - isInfoManga = false, - getChapterUpdates = false, - chapterSortMode = 0, - chapterPriority = 0, - downloadChapters = false, - mergeId = newFirst.id, - mergeUrl = newFirst.url, - mangaId = newFirst.id, - mangaUrl = newFirst.url, - mangaSourceId = MERGED_SOURCE_ID, - ) - mergedManga.second.children.distinct().forEachIndexed { index, mangaSource -> - val load = mangaSource.load() ?: return@forEachIndexed - mergedMangaReferences += MergedMangaReference( - id = -1, - isInfoManga = index == 0, - getChapterUpdates = true, - chapterSortMode = 0, - chapterPriority = 0, - downloadChapters = true, - mergeId = newFirst.id, - mergeUrl = newFirst.url, - mangaId = load.manga.id, - mangaUrl = load.manga.url, - mangaSourceId = load.source.id, - ) - } - } - runBlocking { - updateManga.awaitAll(mangaToUpdate) - insertMergedReference.awaitAll(mergedMangaReferences) - } - - val loadedMangaList = mangaConfigs - .map { it.second.children } - .flatten() - .mapNotNull { it.load() } - .distinct() - val chapters = - runBlocking { - handler.awaitList { - ehQueries.getChaptersByMangaIds( - mergedMangas.map { it.id }, - ChapterMapper::mapChapter, - ) - } - } - val mergedMangaChapters = - runBlocking { - handler.awaitList { - ehQueries.getChaptersByMangaIds( - loadedMangaList.map { it.manga.id }, - ChapterMapper::mapChapter, - ) - } - } - - val mergedMangaChaptersMatched = mergedMangaChapters.mapNotNull { chapter -> - loadedMangaList.firstOrNull { - it.manga.id == chapter.id - }?.let { it to chapter } - } - val parsedChapters = chapters.filter { - it.read || it.lastPageRead != 0L - }.mapNotNull { chapter -> readUrlConfig(chapter.url)?.let { chapter to it } } - val chaptersToUpdate = mutableListOf() - parsedChapters.forEach { parsedChapter -> - mergedMangaChaptersMatched.firstOrNull { - it.second.url == parsedChapter.second.url && - it.first.source.id == parsedChapter.second.source && - it.first.manga.url == parsedChapter.second.mangaUrl - }?.let { - chaptersToUpdate += ChapterUpdate( - it.second.id, - read = parsedChapter.first.read, - lastPageRead = parsedChapter.first.lastPageRead, - ) - } - } - runBlocking { - deleteChapters.await(mergedMangaChapters.map { it.id }) - updateChapter.awaitAll(chaptersToUpdate) - } - } - } - } - if (oldVersion under 12) { - // Force MAL log out due to login flow change - trackerManager.myAnimeList.logout() - } - if (oldVersion under 14) { - // Migrate DNS over HTTPS setting - val wasDohEnabled = prefs.getBoolean("enable_doh", false) - if (wasDohEnabled) { - prefs.edit { - putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE) - remove("enable_doh") - } - } - } - if (oldVersion under 16) { - // Reset rotation to Free after replacing Lock - if (prefs.contains("pref_rotation_type_key")) { - prefs.edit { - putInt("pref_rotation_type_key", 1) - } - } - // Disable update check for Android 5.x users - // if (BuildConfig.INCLUDE_UPDATER && Build.VERSION.SDK_INT under Build.VERSION_CODES.M) { - // UpdaterJob.cancelTask(context) - // } - } - if (oldVersion under 17) { - // Migrate Rotation and Viewer values to default values for viewer_flags - val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) { - 1 -> ReaderOrientation.FREE.flagValue - 2 -> ReaderOrientation.PORTRAIT.flagValue - 3 -> ReaderOrientation.LANDSCAPE.flagValue - 4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue - 5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue - else -> ReaderOrientation.FREE.flagValue - } - - // Reading mode flag and prefValue is the same value - val newReadingMode = prefs.getInt("pref_default_viewer_key", 1) - - prefs.edit { - putInt("pref_default_orientation_type_key", newOrientation) - remove("pref_rotation_type_key") - putInt("pref_default_reading_mode_key", newReadingMode) - remove("pref_default_viewer_key") - } - - // Delete old mangadex trackers - runBlocking { - handler.await { ehQueries.deleteBySyncId(6) } - } - } - if (oldVersion under 18) { - val readerTheme = readerPreferences.readerTheme().get() - if (readerTheme == 4) { - readerPreferences.readerTheme().set(3) - } - val updateInterval = libraryPreferences.autoUpdateInterval().get() - if (updateInterval == 1 || updateInterval == 2) { - libraryPreferences.autoUpdateInterval().set(3) - } - } - if (oldVersion under 20) { - try { - val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0 /* ALPHABETICAL */) - val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true) - - val newSortingMode = when (oldSortingMode) { - 0 -> "ALPHABETICAL" - 1 -> "LAST_READ" - 2 -> "LAST_MANGA_UPDATE" - 3 -> "UNREAD_COUNT" - 4 -> "TOTAL_CHAPTERS" - 6 -> "LATEST_CHAPTER" - 7 -> "DRAG_AND_DROP" - 8 -> "DATE_ADDED" - 9 -> "TAG_LIST" - 10 -> "CHAPTER_FETCH_DATE" - else -> "ALPHABETICAL" - } - - val newSortingDirection = when (oldSortingDirection) { - true -> "ASCENDING" - else -> "DESCENDING" - } - - prefs.edit(commit = true) { - remove(libraryPreferences.sortingMode().key()) - remove("library_sorting_ascending") - } - - prefs.edit { - putString(libraryPreferences.sortingMode().key(), newSortingMode) - putString("library_sorting_ascending", newSortingDirection) - } - } catch (e: Exception) { - logcat(throwable = e) { "Already done migration" } - } - } - if (oldVersion under 22) { - // Handle removed every 3, 4, 6, and 8 hour library updates - val updateInterval = libraryPreferences.autoUpdateInterval().get() - if (updateInterval in listOf(3, 4, 6, 8)) { - libraryPreferences.autoUpdateInterval().set(12) - } - } - if (oldVersion under 23) { - val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true) - if (!oldUpdateOngoingOnly) { - libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED - } - } - if (oldVersion under 24) { - try { - sequenceOf( - "fav-sync", - "fav-sync.management", - "fav-sync.lock", - "fav-sync.note", - ).map { - File(context.filesDir, it) - }.filter(File::exists).forEach { - if (it.isDirectory) { - it.deleteRecursively() - } else { - it.delete() - } - } - } catch (e: Exception) { - xLogE("Failed to delete old favorites database", e) - } - } - if (oldVersion under 27) { - val oldSecureScreen = prefs.getBoolean("secure_screen", false) - if (oldSecureScreen) { - securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS) - } - if ( - DeviceUtil.isMiui && - basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller - .PACKAGEINSTALLER - ) { - basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY) - } - } - if (oldVersion under 28) { - if (prefs.getString("pref_display_mode_library", null) == "NO_TITLE_GRID") { - prefs.edit(commit = true) { - putString("pref_display_mode_library", "COVER_ONLY_GRID") - } - } - } - if (oldVersion under 29) { - if (prefs.getString("pref_display_mode_catalogue", null) == "NO_TITLE_GRID") { - prefs.edit(commit = true) { - putString("pref_display_mode_catalogue", "COMPACT_GRID") - } - } - } - if (oldVersion under 31) { - runBlocking { - val savedSearch = prefs.getStringSet("eh_saved_searches", emptySet())?.mapNotNull { - runCatching { - val content = Json.decodeFromString(it.substringAfter(':')) - SavedSearch( - id = -1, - source = it.substringBefore(':').toLongOrNull() - ?: return@runCatching null, - name = content["name"]!!.jsonPrimitive.content, - query = content["query"]!!.jsonPrimitive.contentOrNull?.nullIfBlank(), - filtersJson = Json.encodeToString(content["filters"]!!.jsonArray), - ) - }.getOrNull() - } - if (!savedSearch.isNullOrEmpty()) { - insertSavedSearch.awaitAll(savedSearch) - } - val feedSavedSearch = prefs.getStringSet("latest_tab_sources", emptySet())?.map { - FeedSavedSearch( - id = -1, - source = it.toLong(), - savedSearch = null, - global = true, - ) - } - if (!feedSavedSearch.isNullOrEmpty()) { - insertFeedSavedSearch.awaitAll(feedSavedSearch) - } - } - prefs.edit(commit = true) { - remove("eh_saved_searches") - remove("latest_tab_sources") - } - } - if (oldVersion under 32) { - val oldReaderTap = prefs.getBoolean("reader_tap", false) - if (!oldReaderTap) { - readerPreferences.navigationModePager().set(5) - readerPreferences.navigationModeWebtoon().set(5) - } - } - if (oldVersion under 38) { - // Handle renamed enum values - val newSortingMode = when ( - val oldSortingMode = prefs.getString(libraryPreferences.sortingMode().key(), "ALPHABETICAL") - ) { - "LAST_CHECKED" -> "LAST_MANGA_UPDATE" - "UNREAD" -> "UNREAD_COUNT" - "DATE_FETCHED" -> "CHAPTER_FETCH_DATE" - "DRAG_AND_DROP" -> "ALPHABETICAL" - else -> oldSortingMode - } - prefs.edit { - putString(libraryPreferences.sortingMode().key(), newSortingMode) - } - runBlocking { - handler.await(true) { - categoriesQueries.getCategories(CategoryMapper::mapCategory).executeAsList() - .filter { (it.flags and 0b00111100L) == 0b00100000L } - .forEach { - categoriesQueries.update( - categoryId = it.id, - flags = it.flags and 0b00111100L.inv(), - name = null, - order = null, - ) - } - } - } - } - if (oldVersion under 39) { - prefs.edit { - val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit - val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!! - putString(libraryPreferences.sortingMode().key(), "$sort,$direction") - remove("library_sorting_ascending") - } - } - if (oldVersion under 40) { - if (backupPreferences.backupInterval().get() == 0) { - backupPreferences.backupInterval().set(12) - } - } - if (oldVersion under 41) { - val preferences = listOf( - libraryPreferences.filterChapterByRead(), - libraryPreferences.filterChapterByDownloaded(), - libraryPreferences.filterChapterByBookmarked(), - libraryPreferences.sortChapterBySourceOrNumber(), - libraryPreferences.displayChapterByNameOrNumber(), - libraryPreferences.sortChapterByAscendingOrDescending(), - ) - - prefs.edit { - preferences.forEach { preference -> - val key = preference.key() - val value = prefs.getInt(key, Int.MIN_VALUE) - if (value == Int.MIN_VALUE) return@forEach - remove(key) - putLong(key, value.toLong()) - } - } - } - if (oldVersion under 42) { - if (uiPreferences.themeMode().isSet()) { - prefs.edit { - val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit - putString(uiPreferences.themeMode().key(), themeMode.uppercase()) - } - } - } - if (oldVersion under 43) { - if (preferenceStore.getBoolean("start_reading_button").get()) { - libraryPreferences.showContinueReadingButton().set(true) - } - } - if (oldVersion under 44) { - val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) - trackingQueuePref.all.forEach { - val (_, lastChapterRead) = it.value.toString().split(":") - trackingQueuePref.edit { - remove(it.key) - putFloat(it.key, lastChapterRead.toFloat()) - } - } - } - if (oldVersion under 45) { - // Force MangaDex log out due to login flow change - trackerManager.mdList.logout() - } - if (oldVersion under 52) { - // Removed background jobs - context.workManager.cancelAllWorkByTag("UpdateChecker") - context.workManager.cancelAllWorkByTag("ExtensionUpdate") - prefs.edit { - remove("automatic_ext_updates") - } - val prefKeys = listOf( - "pref_filter_library_downloaded", - "pref_filter_library_unread", - "pref_filter_library_started", - "pref_filter_library_bookmarked", - "pref_filter_library_completed", - "pref_filter_library_lewd", - ) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" } - - prefKeys.forEach { key -> - val pref = preferenceStore.getInt(key, 0) - prefs.edit { - remove(key) - - val newValue = when (pref.get()) { - 1 -> TriState.ENABLED_IS - 2 -> TriState.ENABLED_NOT - else -> TriState.DISABLED - } - - preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue) - } - } - } - // if (oldVersion under 53) { - // // This was accidentally visible from the reader settings sheet, but should always - // // be disabled in release builds. - // if (isReleaseBuildType) { - // readerPreferences.longStripSplitWebtoon().set(false) - // } - // } - if (oldVersion under 56) { - val pref = libraryPreferences.autoUpdateDeviceRestrictions() - if (pref.isSet() && "battery_not_low" in pref.get()) { - pref.getAndSet { it - "battery_not_low" } - } - } - if (oldVersion under 57) { - val pref = preferenceStore.getInt("relative_time", 7) - if (pref.get() == 0) { - uiPreferences.relativeTime().set(false) - } - } - if (oldVersion under 58) { - pagePreviewCache.clear() - File(context.cacheDir, PagePreviewCache.PARAMETER_CACHE_DIRECTORY).listFiles()?.forEach { - if (it.name == "journal" || it.name.startsWith("journal.")) { - return@forEach - } - - try { - it.delete() - } catch (e: Exception) { - logcat(LogPriority.WARN, e) { "Failed to remove file from cache" } - } - } - } - if (oldVersion under 59) { - val prefsToReplace = listOf( - "pref_download_only", - "incognito_mode", - "last_catalogue_source", - "trusted_signatures", - "last_app_closed", - "library_update_last_timestamp", - "library_unseen_updates_count", - "last_used_category", - "last_app_check", - "last_ext_check", - "last_version_code", - "skip_pre_migration", - "eh_auto_update_stats", - "storage_dir", - ) - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key in prefsToReplace }, - newKey = { Preference.appStateKey(it) }, - ) - - val privatePrefsToReplace = listOf( - "sql_password", - "encrypt_database", - "cbz_password", - "password_protect_downloads", - "eh_ipb_member_id", - "enable_exhentai", - "eh_ipb_member_id", - "eh_ipb_pass_hash", - "eh_igneous", - "eh_ehSettingsProfile", - "eh_exhSettingsProfile", - "eh_settingsKey", - "eh_sessionCookie", - "eh_hathPerksCookie", - ) - - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key in privatePrefsToReplace }, - newKey = { Preference.privateKey(it) }, - ) - - // Deleting old download cache index files, but might as well clear it all out - context.cacheDir.deleteRecursively() - } - if (oldVersion under 60) { - sourcePreferences.extensionRepos().getAndSet { - it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet() - } - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") }, - newKey = { Preference.privateKey(it) }, - ) - prefs.edit { - remove(Preference.appStateKey("trusted_signatures")) - } - } - - // if (oldVersion under 1) { } (1 is current release version) - // do stuff here when releasing changed crap - - return true - } - } catch (e: Exception) { - xLogE("Failed to migrate app from $oldVersion -> ${BuildConfig.VERSION_CODE}!", e) - } - return false - } fun migrateBackupEntry(manga: Manga): Manga { var newManga = manga @@ -716,102 +61,4 @@ object EXHMigrations { orig } } - - @Serializable - private data class UrlConfig( - @SerialName("s") - val source: Long, - @SerialName("u") - val url: String, - @SerialName("m") - val mangaUrl: String, - ) - - @Serializable - private data class MangaConfig( - @SerialName("c") - val children: List, - ) { - companion object { - fun readFromUrl(url: String): MangaConfig? { - return try { - Json.decodeFromString(url) - } catch (e: Exception) { - null - } - } - } - } - - private fun readMangaConfig(manga: Manga): MangaConfig? { - return MangaConfig.readFromUrl(manga.url) - } - - @Serializable - private data class MangaSource( - @SerialName("s") - val source: Long, - @SerialName("u") - val url: String, - ) { - fun load(): LoadedMangaSource? { - val manga = runBlocking { getManga.await(url, source) } ?: return null - val source = sourceManager.getOrStub(source) - return LoadedMangaSource(source, manga) - } - } - - private fun readUrlConfig(url: String): UrlConfig? { - return try { - Json.decodeFromString(url) - } catch (e: Exception) { - null - } - } - - private data class LoadedMangaSource(val source: Source, val manga: Manga) - - private fun updateSourceId(newId: Long, oldId: Long) { - runBlocking { - handler.await { ehQueries.migrateSource(newId, oldId) } - } - } - - @Suppress("UNCHECKED_CAST") - private fun replacePreferences( - preferenceStore: PreferenceStore, - filterPredicate: (Map.Entry) -> Boolean, - newKey: (String) -> String, - ) { - preferenceStore.getAll() - .filter(filterPredicate) - .forEach { (key, value) -> - when (value) { - is Int -> { - preferenceStore.getInt(newKey(key)).set(value) - preferenceStore.getInt(key).delete() - } - is Long -> { - preferenceStore.getLong(newKey(key)).set(value) - preferenceStore.getLong(key).delete() - } - is Float -> { - preferenceStore.getFloat(newKey(key)).set(value) - preferenceStore.getFloat(key).delete() - } - is String -> { - preferenceStore.getString(newKey(key)).set(value) - preferenceStore.getString(key).delete() - } - is Boolean -> { - preferenceStore.getBoolean(newKey(key)).set(value) - preferenceStore.getBoolean(key).delete() - } - is Set<*> -> (value as? Set)?.let { - preferenceStore.getStringSet(newKey(key)).set(value) - preferenceStore.getStringSet(key).delete() - } - } - } - } } diff --git a/app/src/main/java/exh/debug/DebugFunctions.kt b/app/src/main/java/exh/debug/DebugFunctions.kt index 1df0c3a61..09186d238 100644 --- a/app/src/main/java/exh/debug/DebugFunctions.kt +++ b/app/src/main/java/exh/debug/DebugFunctions.kt @@ -1,21 +1,15 @@ package exh.debug import android.app.Application -import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.model.toSManga -import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.cache.PagePreviewCache -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.network.NetworkPreferences +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.source.AndroidSourceManager import eu.kanade.tachiyomi.source.online.all.NHentai -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.system.workManager -import exh.EXHMigrations import exh.eh.EHentaiThrottleManager import exh.eh.EHentaiUpdateWorker import exh.metadata.metadata.EHentaiSearchMetadata @@ -25,10 +19,12 @@ import exh.source.nHentaiSourceIds import exh.util.jobScheduler import kotlinx.coroutines.runBlocking import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator -import tachiyomi.core.common.preference.PreferenceStore +import mihon.core.migration.MigrationContext +import mihon.core.migration.MigrationJobFactory +import mihon.core.migration.MigrationStrategyFactory +import mihon.core.migration.Migrator +import mihon.core.migration.migrations.migrations import tachiyomi.data.DatabaseHandler -import tachiyomi.domain.backup.service.BackupPreferences -import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.interactor.GetAllManga import tachiyomi.domain.manga.interactor.GetExhFavoriteMangaWithMetadata import tachiyomi.domain.manga.interactor.GetFavorites @@ -36,6 +32,8 @@ import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetSearchMetadata import tachiyomi.domain.manga.interactor.InsertFlatMetadata import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.util.UUID @@ -43,16 +41,6 @@ import java.util.UUID object DebugFunctions { private val app: Application by injectLazy() private val handler: DatabaseHandler by injectLazy() - private val prefsStore: PreferenceStore by injectLazy() - private val basePrefs: BasePreferences by injectLazy() - private val uiPrefs: UiPreferences by injectLazy() - private val networkPrefs: NetworkPreferences by injectLazy() - private val sourcePrefs: SourcePreferences by injectLazy() - private val securityPrefs: SecurityPreferences by injectLazy() - private val libraryPrefs: LibraryPreferences by injectLazy() - private val readerPrefs: ReaderPreferences by injectLazy() - private val backupPrefs: BackupPreferences by injectLazy() - private val trackerManager: TrackerManager by injectLazy() private val sourceManager: SourceManager by injectLazy() private val updateManga: UpdateManga by injectLazy() private val getFavorites: GetFavorites by injectLazy() @@ -61,44 +49,21 @@ object DebugFunctions { private val getExhFavoriteMangaWithMetadata: GetExhFavoriteMangaWithMetadata by injectLazy() private val getSearchMetadata: GetSearchMetadata by injectLazy() private val getAllManga: GetAllManga by injectLazy() - private val pagePreviewCache: PagePreviewCache by injectLazy() - fun forceUpgradeMigration() { - val lastVersionCode = prefsStore.getInt("eh_last_version_code", 0) - lastVersionCode.set(1) - EXHMigrations.upgrade( - context = app, - preferenceStore = prefsStore, - basePreferences = basePrefs, - uiPreferences = uiPrefs, - networkPreferences = networkPrefs, - sourcePreferences = sourcePrefs, - securityPreferences = securityPrefs, - libraryPreferences = libraryPrefs, - readerPreferences = readerPrefs, - backupPreferences = backupPrefs, - trackerManager = trackerManager, - pagePreviewCache = pagePreviewCache, - ) + fun forceUpgradeMigration(): Boolean { + val migrationContext = MigrationContext(dryrun = false) + val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope) + val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {}) + val strategy = migrationStrategyFactory.create(1, BuildConfig.VERSION_CODE) + return runBlocking { strategy(migrations).await() } } - fun forceSetupJobs() { - val lastVersionCode = prefsStore.getInt("eh_last_version_code", 0) - lastVersionCode.set(0) - EXHMigrations.upgrade( - context = app, - preferenceStore = prefsStore, - basePreferences = basePrefs, - uiPreferences = uiPrefs, - networkPreferences = networkPrefs, - sourcePreferences = sourcePrefs, - securityPreferences = securityPrefs, - libraryPreferences = libraryPrefs, - readerPreferences = readerPrefs, - backupPreferences = backupPrefs, - trackerManager = trackerManager, - pagePreviewCache = pagePreviewCache, - ) + fun forceSetupJobs(): Boolean { + val migrationContext = MigrationContext(dryrun = false) + val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope) + val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {}) + val strategy = migrationStrategyFactory.create(0, BuildConfig.VERSION_CODE) + return runBlocking { strategy(migrations).await() } } fun resetAgedFlagInEXHManga() { @@ -340,4 +305,14 @@ object DebugFunctions { } fun exportProtobufScheme() = ProtoBufSchemaGenerator.generateSchemaText(Backup.serializer().descriptor) + + fun killSyncJobs() { + val context = Injekt.get() + SyncDataJob.stop(context) + } + + fun killLibraryJobs() { + val context = Injekt.get() + LibraryUpdateJob.stop(context) + } } diff --git a/app/src/main/java/exh/eh/EHentaiUpdateHelper.kt b/app/src/main/java/exh/eh/EHentaiUpdateHelper.kt index dc28a993d..d06566bc7 100644 --- a/app/src/main/java/exh/eh/EHentaiUpdateHelper.kt +++ b/app/src/main/java/exh/eh/EHentaiUpdateHelper.kt @@ -254,6 +254,7 @@ class EHentaiUpdateHelper(context: Context) { scanlator = null, sourceOrder = -1, lastModifiedAt = 0, + version = 0, ) } } diff --git a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt index 1b1a9d23c..906e04b05 100644 --- a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt +++ b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt @@ -2,6 +2,7 @@ package exh.favorites import android.content.Context import android.net.wifi.WifiManager +import android.os.Build import android.os.PowerManager import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.tachiyomi.network.POST @@ -136,10 +137,18 @@ class FavoritesSyncHelper(val context: Context) { } ignore { wifiLock?.release() } wifiLock = ignore { - context.wifiManager.createWifiLock( - WifiManager.WIFI_MODE_FULL_HIGH_PERF, - "teh:ExhFavoritesSyncWifi", - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.wifiManager.createWifiLock( + WifiManager.WIFI_MODE_FULL_LOW_LATENCY, + "teh:ExhFavoritesSyncWifi", + ) + } else { + @Suppress("DEPRECATION") + context.wifiManager.createWifiLock( + WifiManager.WIFI_MODE_FULL_HIGH_PERF, + "teh:ExhFavoritesSyncWifi", + ) + } } // Do not update galleries while syncing favorites diff --git a/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt b/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt index ed30d3919..5371417cf 100644 --- a/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt +++ b/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt @@ -25,18 +25,29 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.AllowDuplicateDialog import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog +import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen +import exh.ui.ifSourcesLoaded import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.domain.UnsortedPreferences import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.LoadingScreen +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class MangaDexFollowsScreen(private val sourceId: Long) : Screen() { @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val navigator = LocalNavigator.currentOrThrow val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current @@ -143,6 +154,14 @@ class MangaDexFollowsScreen(private val sourceId: Long) : Screen() { onDismissRequest = onDismissRequest, onConfirm = { screenModel.addFavorite(dialog.manga) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, + onMigrate = { + PreMigrationScreen.navigateToMigration( + Injekt.get().skipPreMigration().get(), + navigator, + dialog.duplicate.id, + dialog.manga.id, + ) + } ) } is BrowseSourceScreenModel.Dialog.RemoveManga -> { diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt index 772d5c0b5..33cd209c5 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt @@ -24,17 +24,23 @@ import eu.kanade.tachiyomi.ui.browse.ChangeMangaCategoryDialog import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog import eu.kanade.tachiyomi.ui.browse.RemoveMangaDialog import eu.kanade.tachiyomi.ui.manga.MangaScreen +import exh.ui.ifSourcesLoaded import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.LoadingScreen class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() { @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val screenModel = rememberScreenModel { MangaDexSimilarScreenModel(mangaId, sourceId) } - val state by screenModel.state.collectAsState() val navigator = LocalNavigator.currentOrThrow // KMK --> diff --git a/app/src/main/java/exh/recs/RecommendsScreen.kt b/app/src/main/java/exh/recs/RecommendsScreen.kt index c1d385aac..0aa37cb33 100644 --- a/app/src/main/java/exh/recs/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/RecommendsScreen.kt @@ -25,15 +25,21 @@ import eu.kanade.tachiyomi.ui.browse.ChangeMangaCategoryDialog import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog import eu.kanade.tachiyomi.ui.browse.RemoveMangaDialog import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen +import exh.ui.ifSourcesLoaded import tachiyomi.domain.manga.model.Manga import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.LoadingScreen class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { @Composable override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + val screenModel = rememberScreenModel { RecommendsScreenModel(mangaId, sourceId) } - val state by screenModel.state.collectAsState() val navigator = LocalNavigator.currentOrThrow // KMK --> diff --git a/app/src/main/java/exh/ui/SourceUtil.kt b/app/src/main/java/exh/ui/SourceUtil.kt new file mode 100644 index 000000000..653ea61d2 --- /dev/null +++ b/app/src/main/java/exh/ui/SourceUtil.kt @@ -0,0 +1,13 @@ +package exh.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +@Composable +fun ifSourcesLoaded(): Boolean { + return remember { Injekt.get().isInitialized }.collectAsState().value +} diff --git a/app/src/main/java/exh/ui/intercept/InterceptActivity.kt b/app/src/main/java/exh/ui/intercept/InterceptActivity.kt index a9341acf7..7effc5d81 100644 --- a/app/src/main/java/exh/ui/intercept/InterceptActivity.kt +++ b/app/src/main/java/exh/ui/intercept/InterceptActivity.kt @@ -28,19 +28,22 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.view.setComposeContent import exh.GalleryAddEvent import exh.GalleryAdder -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import tachiyomi.core.common.Constants import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager import tachiyomi.i18n.MR import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class InterceptActivity : BaseActivity() { private var statusJob: Job? = null @@ -108,7 +111,11 @@ class InterceptActivity : BaseActivity() { private fun processLink() { if (Intent.ACTION_VIEW == intent.action) { - loadGallery(intent.dataString!!) + lifecycleScope.launchIO { + // wait for sources to load + Injekt.get().isInitialized.first { it } + loadGallery(intent.dataString!!) + } } } @@ -167,8 +174,7 @@ class InterceptActivity : BaseActivity() { private val galleryAdder = GalleryAdder() - @Synchronized - fun loadGallery(gallery: String) { + suspend fun loadGallery(gallery: String) { // Do not load gallery if already loading if (status.value is InterceptResult.Idle) { status.value = InterceptResult.Loading @@ -178,7 +184,9 @@ class InterceptActivity : BaseActivity() { .setTitle(MR.strings.label_sources.getString(this)) .setSingleChoiceItems(sources.map { it.toString() }.toTypedArray(), 0) { dialog, index -> dialog.dismiss() - loadGalleryEnd(gallery, sources[index]) + lifecycleScope.launchIO { + loadGalleryEnd(gallery, sources[index]) + } } .show() } else { @@ -187,15 +195,12 @@ class InterceptActivity : BaseActivity() { } } - private fun loadGalleryEnd(gallery: String, source: UrlImportableSource? = null) { - // Load gallery async - lifecycleScope.launch(Dispatchers.IO) { - val result = galleryAdder.addGallery(this@InterceptActivity, gallery, forceSource = source) + private suspend fun loadGalleryEnd(gallery: String, source: UrlImportableSource? = null) { + val result = galleryAdder.addGallery(this@InterceptActivity, gallery, forceSource = source) - status.value = when (result) { - is GalleryAddEvent.Success -> InterceptResult.Success(result.manga.id, result.manga, result.chapter) - is GalleryAddEvent.Fail -> InterceptResult.Failure(result.logMessage) - } + status.value = when (result) { + is GalleryAddEvent.Success -> InterceptResult.Success(result.manga.id, result.manga, result.chapter) + is GalleryAddEvent.Fail -> InterceptResult.Failure(result.logMessage) } } diff --git a/app/src/main/java/exh/ui/metadata/MetadataViewScreen.kt b/app/src/main/java/exh/ui/metadata/MetadataViewScreen.kt index 91f525dfa..952a8d307 100644 --- a/app/src/main/java/exh/ui/metadata/MetadataViewScreen.kt +++ b/app/src/main/java/exh/ui/metadata/MetadataViewScreen.kt @@ -50,7 +50,7 @@ class MetadataViewScreen(private val mangaId: Long, private val sourceId: Long) ) }, ) { paddingValues -> - when (val state = state) { + when (@Suppress("NAME_SHADOWING") val state = state) { MetadataViewState.Loading -> LoadingScreen() MetadataViewState.MetadataNotFound -> EmptyScreen(MR.strings.no_results_found) MetadataViewState.SourceNotFound -> EmptyScreen(MR.strings.source_empty_screen) diff --git a/app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt b/app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt new file mode 100644 index 000000000..ca80625ca --- /dev/null +++ b/app/src/main/java/mihon/core/designsystem/utils/WindowSize.kt @@ -0,0 +1,23 @@ +package mihon.core.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp + +@Composable +@ReadOnlyComposable +fun isMediumWidthWindow(): Boolean { + val configuration = LocalConfiguration.current + return configuration.screenWidthDp > MediumWidthWindowSize.value +} + +@Composable +@ReadOnlyComposable +fun isExpandedWidthWindow(): Boolean { + val configuration = LocalConfiguration.current + return configuration.screenWidthDp > ExpandedWidthWindowSize.value +} + +val MediumWidthWindowSize = 600.dp +val ExpandedWidthWindowSize = 840.dp diff --git a/app/src/main/java/mihon/core/migration/MigrateUtils.kt b/app/src/main/java/mihon/core/migration/MigrateUtils.kt new file mode 100644 index 000000000..951c4d01c --- /dev/null +++ b/app/src/main/java/mihon/core/migration/MigrateUtils.kt @@ -0,0 +1,53 @@ +package mihon.core.migration + +import kotlinx.coroutines.runBlocking +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.data.DatabaseHandler + +object MigrateUtils { + fun updateSourceId(migrationContext: MigrationContext, newId: Long, oldId: Long) { + val handler = migrationContext.get() ?: return + runBlocking { + handler.await { ehQueries.migrateSource(newId, oldId) } + } + } + + @Suppress("UNCHECKED_CAST") + fun replacePreferences( + preferenceStore: PreferenceStore, + filterPredicate: (Map.Entry) -> Boolean, + newKey: (String) -> String, + ) { + preferenceStore.getAll() + .filter(filterPredicate) + .forEach { (key, value) -> + when (value) { + is Int -> { + preferenceStore.getInt(newKey(key)).set(value) + preferenceStore.getInt(key).delete() + } + is Long -> { + preferenceStore.getLong(newKey(key)).set(value) + preferenceStore.getLong(key).delete() + } + is Float -> { + preferenceStore.getFloat(newKey(key)).set(value) + preferenceStore.getFloat(key).delete() + } + is String -> { + preferenceStore.getString(newKey(key)).set(value) + preferenceStore.getString(key).delete() + } + is Boolean -> { + preferenceStore.getBoolean(newKey(key)).set(value) + preferenceStore.getBoolean(key).delete() + } + is Set<*> -> (value as? Set)?.let { + preferenceStore.getStringSet(newKey(key)).set(value) + preferenceStore.getStringSet(key).delete() + } + } + } + } +} + diff --git a/app/src/main/java/mihon/core/migration/Migration.kt b/app/src/main/java/mihon/core/migration/Migration.kt new file mode 100644 index 000000000..2c7283bb2 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/Migration.kt @@ -0,0 +1,22 @@ +package mihon.core.migration + +interface Migration { + val version: Float + + suspend operator fun invoke(migrationContext: MigrationContext): Boolean + + val isAlways: Boolean + get() = version == ALWAYS + + companion object { + const val ALWAYS = -1f + + fun of(version: Float, action: suspend (MigrationContext) -> Boolean): Migration = object : Migration { + override val version: Float = version + + override suspend operator fun invoke(migrationContext: MigrationContext): Boolean { + return action(migrationContext) + } + } + } +} diff --git a/app/src/main/java/mihon/core/migration/MigrationCompletedListener.kt b/app/src/main/java/mihon/core/migration/MigrationCompletedListener.kt new file mode 100644 index 000000000..52f3324a2 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/MigrationCompletedListener.kt @@ -0,0 +1,3 @@ +package mihon.core.migration + +typealias MigrationCompletedListener = () -> Unit diff --git a/app/src/main/java/mihon/core/migration/MigrationContext.kt b/app/src/main/java/mihon/core/migration/MigrationContext.kt new file mode 100644 index 000000000..3d0473f27 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/MigrationContext.kt @@ -0,0 +1,10 @@ +package mihon.core.migration + +import uy.kohesive.injekt.Injekt + +class MigrationContext(val dryrun: Boolean) { + + inline fun get(): T? { + return Injekt.getInstanceOrNull(T::class.java) + } +} diff --git a/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt b/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt new file mode 100644 index 000000000..801411013 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt @@ -0,0 +1,31 @@ +package mihon.core.migration + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import tachiyomi.core.common.util.system.logcat + +class MigrationJobFactory( + private val migrationContext: MigrationContext, + private val scope: CoroutineScope +) { + + @SuppressWarnings("MaxLineLength") + fun create(migrations: List): Deferred = with(scope) { + return migrations.sortedBy { it.version } + .fold(CompletableDeferred(true)) { acc: Deferred, migration: Migration -> + if (!migrationContext.dryrun) { + logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" } + async(start = CoroutineStart.UNDISPATCHED) { + val prev = acc.await() + migration(migrationContext) || prev + } + } else { + logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" } + CompletableDeferred(true) + } + } + } +} diff --git a/app/src/main/java/mihon/core/migration/MigrationStrategy.kt b/app/src/main/java/mihon/core/migration/MigrationStrategy.kt new file mode 100644 index 000000000..9fd5f4f91 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/MigrationStrategy.kt @@ -0,0 +1,55 @@ +package mihon.core.migration + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch + +interface MigrationStrategy { + operator fun invoke(migrations: List): Deferred +} + +class DefaultMigrationStrategy( + private val migrationJobFactory: MigrationJobFactory, + private val migrationCompletedListener: MigrationCompletedListener, + private val scope: CoroutineScope +) : MigrationStrategy { + + override operator fun invoke(migrations: List): Deferred = with(scope) { + if (migrations.isEmpty()) { + return@with CompletableDeferred(false) + } + + val chain = migrationJobFactory.create(migrations) + + launch { + if (chain.await()) migrationCompletedListener() + }.start() + + chain + } +} + +class InitialMigrationStrategy(private val strategy: DefaultMigrationStrategy) : MigrationStrategy { + + override operator fun invoke(migrations: List): Deferred { + return strategy(migrations.filter { it.isAlways }) + } +} + +class NoopMigrationStrategy(val state: Boolean) : MigrationStrategy { + + override fun invoke(migrations: List): Deferred { + return CompletableDeferred(state) + } +} + +class VersionRangeMigrationStrategy( + private val versions: IntRange, + private val strategy: DefaultMigrationStrategy +) : MigrationStrategy { + + override operator fun invoke(migrations: List): Deferred { + return strategy(migrations.filter { it.isAlways || it.version.toInt() in versions }) + } +} diff --git a/app/src/main/java/mihon/core/migration/MigrationStrategyFactory.kt b/app/src/main/java/mihon/core/migration/MigrationStrategyFactory.kt new file mode 100644 index 000000000..7e06fecb3 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/MigrationStrategyFactory.kt @@ -0,0 +1,23 @@ +package mihon.core.migration + +class MigrationStrategyFactory( + private val factory: MigrationJobFactory, + private val migrationCompletedListener: MigrationCompletedListener, +) { + + fun create(old: Int, new: Int): MigrationStrategy { + val versions = (old + 1)..new + val strategy = when { + old == 0 -> InitialMigrationStrategy( + strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope), + ) + + old >= new -> NoopMigrationStrategy(false) + else -> VersionRangeMigrationStrategy( + versions = versions, + strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope), + ) + } + return strategy + } +} diff --git a/app/src/main/java/mihon/core/migration/Migrator.kt b/app/src/main/java/mihon/core/migration/Migrator.kt new file mode 100644 index 000000000..11f22a8c9 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/Migrator.kt @@ -0,0 +1,41 @@ +package mihon.core.migration + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking + +object Migrator { + + private var result: Deferred? = null + val scope = CoroutineScope(Dispatchers.Main + Job()) + + fun initialize( + old: Int, + new: Int, + migrations: List, + dryrun: Boolean = false, + onMigrationComplete: () -> Unit + ) { + val migrationContext = MigrationContext(dryrun) + val migrationJobFactory = MigrationJobFactory(migrationContext, scope) + val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete) + val strategy = migrationStrategyFactory.create(old, new) + result = strategy(migrations) + } + + suspend fun await(): Boolean { + val result = result ?: CompletableDeferred(false) + return result.await() + } + + fun release() { + result = null + } + + fun awaitAndRelease(): Boolean = runBlocking { + await().also { release() } + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/AlwaysBackupMigration.kt b/app/src/main/java/mihon/core/migration/migrations/AlwaysBackupMigration.kt new file mode 100644 index 000000000..5aafb8ce3 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/AlwaysBackupMigration.kt @@ -0,0 +1,19 @@ +package mihon.core.migration.migrations + +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.backup.service.BackupPreferences + +class AlwaysBackupMigration : Migration { + override val version: Float = 40f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val backupPreferences = migrationContext.get() ?: return@withIOContext false + if (backupPreferences.backupInterval().get() == 0) { + backupPreferences.backupInterval().set(12) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ChangeMiuiExtensionInstallerMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ChangeMiuiExtensionInstallerMigration.kt new file mode 100644 index 000000000..cdee8ae4d --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ChangeMiuiExtensionInstallerMigration.kt @@ -0,0 +1,24 @@ +package mihon.core.migration.migrations + +import eu.kanade.domain.base.BasePreferences +import eu.kanade.tachiyomi.util.system.DeviceUtil +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class ChangeMiuiExtensionInstallerMigration : Migration { + override val version: Float = 27f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val basePreferences = migrationContext.get() ?: return@withIOContext false + if ( + DeviceUtil.isMiui && + basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller + .PACKAGEINSTALLER + ) { + basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ChangeThemeModeToUppercaseMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ChangeThemeModeToUppercaseMigration.kt new file mode 100644 index 000000000..0df2fede1 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ChangeThemeModeToUppercaseMigration.kt @@ -0,0 +1,27 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.domain.ui.UiPreferences +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class ChangeThemeModeToUppercaseMigration : Migration { + override val version: Float = 42f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val uiPreferences = migrationContext.get() ?: return@withIOContext false + if (uiPreferences.themeMode().isSet()) { + prefs.edit { + val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit + putString(uiPreferences.themeMode().key(), themeMode.uppercase()) + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ChangeTrackingQueueTypeMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ChangeTrackingQueueTypeMigration.kt new file mode 100644 index 000000000..18dbb3616 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ChangeTrackingQueueTypeMigration.kt @@ -0,0 +1,26 @@ +package mihon.core.migration.migrations + +import android.content.Context +import androidx.core.content.edit +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class ChangeTrackingQueueTypeMigration : Migration { + override val version: Float = 44f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) + trackingQueuePref.all.forEach { + val (_, lastChapterRead) = it.value.toString().split(":") + trackingQueuePref.edit { + remove(it.key) + putFloat(it.key, lastChapterRead.toFloat()) + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ClearBrokenPagePreviewCacheMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ClearBrokenPagePreviewCacheMigration.kt new file mode 100644 index 000000000..63d4b6005 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ClearBrokenPagePreviewCacheMigration.kt @@ -0,0 +1,33 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import logcat.LogPriority +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.system.logcat +import java.io.File + +class ClearBrokenPagePreviewCacheMigration : Migration { + override val version: Float = 58f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val pagePreviewCache = migrationContext.get() ?: return@withIOContext false + pagePreviewCache.clear() + File(context.cacheDir, PagePreviewCache.PARAMETER_CACHE_DIRECTORY).listFiles()?.forEach { + if (it.name == "journal" || it.name.startsWith("journal.")) { + return@forEach + } + + try { + it.delete() + } catch (e: Exception) { + logcat(LogPriority.WARN, e) { "Failed to remove file from cache" } + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/DelegateHBrowseMigration.kt b/app/src/main/java/mihon/core/migration/migrations/DelegateHBrowseMigration.kt new file mode 100644 index 000000000..6bdf8c28d --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/DelegateHBrowseMigration.kt @@ -0,0 +1,28 @@ +package mihon.core.migration.migrations + +import eu.kanade.domain.manga.interactor.UpdateManga +import exh.source.HBROWSE_SOURCE_ID +import mihon.core.migration.MigrateUtils +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.manga.interactor.GetMangaBySource +import tachiyomi.domain.manga.model.MangaUpdate + +class DelegateHBrowseMigration : Migration { + override val version: Float = 4f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val getMangaBySource = migrationContext.get() ?: return@withIOContext false + val updateManga = migrationContext.get() ?: return@withIOContext false + MigrateUtils.updateSourceId(migrationContext, HBROWSE_SOURCE_ID, 6912) + + // Migrate BHrowse URLs + val hBrowseManga = getMangaBySource.await(HBROWSE_SOURCE_ID) + val mangaUpdates = hBrowseManga.map { + MangaUpdate(it.id, url = it.url + "/c00001/") + } + updateManga.awaitAll(mangaUpdates) + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/DelegateNHentaiMigration.kt b/app/src/main/java/mihon/core/migration/migrations/DelegateNHentaiMigration.kt new file mode 100644 index 000000000..e86c2da7c --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/DelegateNHentaiMigration.kt @@ -0,0 +1,17 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.source.online.all.NHentai +import mihon.core.migration.MigrateUtils +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class DelegateNHentaiMigration : Migration { + override val version: Float = 6f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + MigrateUtils.updateSourceId(migrationContext, NHentai.otherId, 6907) + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/DeleteOldEhFavoritesDatabaseMigration.kt b/app/src/main/java/mihon/core/migration/migrations/DeleteOldEhFavoritesDatabaseMigration.kt new file mode 100644 index 000000000..814ef5358 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/DeleteOldEhFavoritesDatabaseMigration.kt @@ -0,0 +1,36 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import exh.log.xLogE +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import java.io.File + +class DeleteOldEhFavoritesDatabaseMigration : Migration { + override val version: Float = 24f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + try { + sequenceOf( + "fav-sync", + "fav-sync.management", + "fav-sync.lock", + "fav-sync.note", + ).map { + File(context.filesDir, it) + }.filter(File::exists).forEach { + if (it.isDirectory) { + it.deleteRecursively() + } else { + it.delete() + } + } + } catch (e: Exception) { + xLogE("Failed to delete old favorites database", e) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/DeleteOldMangaDexTracksMigration.kt b/app/src/main/java/mihon/core/migration/migrations/DeleteOldMangaDexTracksMigration.kt new file mode 100644 index 000000000..528d8d769 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/DeleteOldMangaDexTracksMigration.kt @@ -0,0 +1,17 @@ +package mihon.core.migration.migrations + +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.data.DatabaseHandler + +class DeleteOldMangaDexTracksMigration : Migration { + override val version: Float = 17f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val handler = migrationContext.get() ?: return@withIOContext false + // Delete old mangadex trackers + handler.await { ehQueries.deleteBySyncId(6) } + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/LogoutFromMALMigration.kt b/app/src/main/java/mihon/core/migration/migrations/LogoutFromMALMigration.kt new file mode 100644 index 000000000..5d4d5ffb2 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/LogoutFromMALMigration.kt @@ -0,0 +1,17 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.data.track.TrackerManager +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class LogoutFromMALMigration : Migration { + override val version: Float = 12f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + // Force MAL log out due to login flow change + migrationContext.get()?.myAnimeList?.logout() + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/LogoutFromMangaDexMigration.kt b/app/src/main/java/mihon/core/migration/migrations/LogoutFromMangaDexMigration.kt new file mode 100644 index 000000000..ae3e23cd9 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/LogoutFromMangaDexMigration.kt @@ -0,0 +1,17 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.data.track.TrackerManager +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class LogoutFromMangaDexMigration : Migration { + override val version: Float = 45f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + // Force MangaDex log out due to login flow change + migrationContext.get()?.mdList?.logout() + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MergedMangaRewriteMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MergedMangaRewriteMigration.kt new file mode 100644 index 000000000..983bbe347 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MergedMangaRewriteMigration.kt @@ -0,0 +1,191 @@ +package mihon.core.migration.migrations + +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.tachiyomi.source.Source +import exh.source.MERGED_SOURCE_ID +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.data.DatabaseHandler +import tachiyomi.data.chapter.ChapterMapper +import tachiyomi.domain.chapter.interactor.DeleteChapters +import tachiyomi.domain.chapter.interactor.UpdateChapter +import tachiyomi.domain.chapter.model.ChapterUpdate +import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.interactor.GetMangaBySource +import tachiyomi.domain.manga.interactor.InsertMergedReference +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaUpdate +import tachiyomi.domain.manga.model.MergedMangaReference +import tachiyomi.domain.source.service.SourceManager + +class MergedMangaRewriteMigration : Migration { + override val version: Float = 7f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val handler = migrationContext.get() ?: return@withIOContext false + val getMangaBySource = migrationContext.get() ?: return@withIOContext false + val getManga = migrationContext.get() ?: return@withIOContext false + val updateManga = migrationContext.get() ?: return@withIOContext false + val insertMergedReference = migrationContext.get() ?: return@withIOContext false + val sourceManager = migrationContext.get() ?: return@withIOContext false + val deleteChapters = migrationContext.get() ?: return@withIOContext false + val updateChapter = migrationContext.get() ?: return@withIOContext false + val mergedMangas = getMangaBySource.await(MERGED_SOURCE_ID) + + if (mergedMangas.isNotEmpty()) { + val mangaConfigs = mergedMangas.mapNotNull { mergedManga -> + readMangaConfig(mergedManga)?.let { mergedManga to it } + } + if (mangaConfigs.isNotEmpty()) { + val mangaToUpdate = mutableListOf() + val mergedMangaReferences = mutableListOf() + mangaConfigs.onEach { mergedManga -> + val newFirst = mergedManga.second.children.firstOrNull()?.url?.let { + if (getManga.await(it, MERGED_SOURCE_ID) != null) return@onEach + mangaToUpdate += MangaUpdate(id = mergedManga.first.id, url = it) + mergedManga.first.copy(url = it) + } ?: mergedManga.first + mergedMangaReferences += MergedMangaReference( + id = -1, + isInfoManga = false, + getChapterUpdates = false, + chapterSortMode = 0, + chapterPriority = 0, + downloadChapters = false, + mergeId = newFirst.id, + mergeUrl = newFirst.url, + mangaId = newFirst.id, + mangaUrl = newFirst.url, + mangaSourceId = MERGED_SOURCE_ID, + ) + mergedManga.second.children.distinct().forEachIndexed { index, mangaSource -> + val load = mangaSource.load(getManga, sourceManager) ?: return@forEachIndexed + mergedMangaReferences += MergedMangaReference( + id = -1, + isInfoManga = index == 0, + getChapterUpdates = true, + chapterSortMode = 0, + chapterPriority = 0, + downloadChapters = true, + mergeId = newFirst.id, + mergeUrl = newFirst.url, + mangaId = load.manga.id, + mangaUrl = load.manga.url, + mangaSourceId = load.source.id, + ) + } + } + + updateManga.awaitAll(mangaToUpdate) + insertMergedReference.awaitAll(mergedMangaReferences) + + val loadedMangaList = mangaConfigs + .map { it.second.children } + .flatten() + .mapNotNull { it.load(getManga, sourceManager) } + .distinct() + val chapters = + handler.awaitList { + ehQueries.getChaptersByMangaIds( + mergedMangas.map { it.id }, + ChapterMapper::mapChapter, + ) + } + + val mergedMangaChapters = + handler.awaitList { + ehQueries.getChaptersByMangaIds( + loadedMangaList.map { it.manga.id }, + ChapterMapper::mapChapter, + ) + } + + val mergedMangaChaptersMatched = mergedMangaChapters.mapNotNull { chapter -> + loadedMangaList.firstOrNull { + it.manga.id == chapter.id + }?.let { it to chapter } + } + val parsedChapters = chapters.filter { + it.read || it.lastPageRead != 0L + }.mapNotNull { chapter -> readUrlConfig(chapter.url)?.let { chapter to it } } + val chaptersToUpdate = mutableListOf() + parsedChapters.forEach { parsedChapter -> + mergedMangaChaptersMatched.firstOrNull { + it.second.url == parsedChapter.second.url && + it.first.source.id == parsedChapter.second.source && + it.first.manga.url == parsedChapter.second.mangaUrl + }?.let { + chaptersToUpdate += ChapterUpdate( + it.second.id, + read = parsedChapter.first.read, + lastPageRead = parsedChapter.first.lastPageRead, + ) + } + } + + deleteChapters.await(mergedMangaChapters.map { it.id }) + updateChapter.awaitAll(chaptersToUpdate) + } + } + return@withIOContext true + } + + + @Serializable + private data class UrlConfig( + @SerialName("s") + val source: Long, + @SerialName("u") + val url: String, + @SerialName("m") + val mangaUrl: String, + ) + + @Serializable + private data class MangaConfig( + @SerialName("c") + val children: List, + ) { + companion object { + fun readFromUrl(url: String): MangaConfig? { + return try { + Json.decodeFromString(url) + } catch (e: Exception) { + null + } + } + } + } + + private fun readMangaConfig(manga: Manga): MangaConfig? { + return MangaConfig.readFromUrl(manga.url) + } + + @Serializable + private data class MangaSource( + @SerialName("s") + val source: Long, + @SerialName("u") + val url: String, + ) { + suspend fun load(getManga: GetManga, sourceManager: SourceManager): LoadedMangaSource? { + val manga = getManga.await(url, source) ?: return null + val source = sourceManager.getOrStub(source) + return LoadedMangaSource(source, manga) + } + } + + private fun readUrlConfig(url: String): UrlConfig? { + return try { + Json.decodeFromString(url) + } catch (e: Exception) { + null + } + } + + private data class LoadedMangaSource(val source: Source, val manga: Manga) +} diff --git a/app/src/main/java/mihon/core/migration/migrations/Migrations.kt b/app/src/main/java/mihon/core/migration/migrations/Migrations.kt new file mode 100644 index 000000000..514825365 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/Migrations.kt @@ -0,0 +1,48 @@ +package mihon.core.migration.migrations + +import mihon.core.migration.Migration + +val migrations: List + get() = listOf( + SetupBackupCreateMigration(), + SetupLibraryUpdateMigration(), + SetupEHentaiUpdateMigration(), + SetupSyncDataMigration(), + DelegateHBrowseMigration(), + DelegateNHentaiMigration(), + MergedMangaRewriteMigration(), + LogoutFromMALMigration(), + MoveDOHSettingMigration(), + ResetRotationSettingMigration(), + ResetReaderSettingsMigration(), + DeleteOldMangaDexTracksMigration(), + RemoveOldReaderThemeMigration(), + RemoveShorterLibraryUpdatesMigration(), + MoveLibrarySortingSettingsMigration(), + RemoveShortLibraryUpdatesMigration(), + MoveLibraryNonCompleteSettingMigration(), + DeleteOldEhFavoritesDatabaseMigration(), + MoveSecureScreenSettingMigration(), + ChangeMiuiExtensionInstallerMigration(), + MoveCoverOnlyGridSettingMigration(), + MoveCatalogueCoverOnlyGridSettingMigration(), + MoveLatestToFeedMigration(), + MoveReaderTapSettingMigration(), + MoveSortingModeSettingsMigration(), + MoveSortingModeSettingMigration(), + AlwaysBackupMigration(), + ResetFilterAndSortSettingsMigration(), + ChangeThemeModeToUppercaseMigration(), + MoveReadingButtonSettingMigration(), + ChangeTrackingQueueTypeMigration(), + LogoutFromMangaDexMigration(), + RemoveUpdateCheckerJobsMigration(), + RemoveBatteryNotLowRestrictionMigration(), + MoveRelativeTimeSettingMigration(), + ClearBrokenPagePreviewCacheMigration(), + MoveSettingsToPrivateOrAppStateMigration(), + MoveExtensionRepoSettingsMigration(), + MoveCacheToDiskSettingMigration(), + MoveEncryptionSettingsToAppStateMigration(), + TrustExtensionRepositoryMigration(), + ) diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveCacheToDiskSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveCacheToDiskSettingMigration.kt new file mode 100644 index 000000000..e0ff302cc --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveCacheToDiskSettingMigration.kt @@ -0,0 +1,24 @@ +package mihon.core.migration.migrations + +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class MoveCacheToDiskSettingMigration : Migration { + override val version: Float = 66f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val readerPreferences = migrationContext.get() ?: return@withIOContext false + val cacheImagesToDisk = prefs.getBoolean("cache_archive_manga_on_disk", false) + if (cacheImagesToDisk) { + readerPreferences.archiveReaderMode().set(ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveCatalogueCoverOnlyGridSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveCatalogueCoverOnlyGridSettingMigration.kt new file mode 100644 index 000000000..2d769f64c --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveCatalogueCoverOnlyGridSettingMigration.kt @@ -0,0 +1,24 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class MoveCatalogueCoverOnlyGridSettingMigration : Migration { + override val version: Float = 29f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + if (prefs.getString("pref_display_mode_catalogue", null) == "NO_TITLE_GRID") { + prefs.edit(commit = true) { + putString("pref_display_mode_catalogue", "COMPACT_GRID") + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveCoverOnlyGridSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveCoverOnlyGridSettingMigration.kt new file mode 100644 index 000000000..ad31967f0 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveCoverOnlyGridSettingMigration.kt @@ -0,0 +1,24 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class MoveCoverOnlyGridSettingMigration : Migration { + override val version: Float = 28f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + if (prefs.getString("pref_display_mode_library", null) == "NO_TITLE_GRID") { + prefs.edit(commit = true) { + putString("pref_display_mode_library", "COVER_ONLY_GRID") + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveDOHSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveDOHSettingMigration.kt new file mode 100644 index 000000000..2e3656462 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveDOHSettingMigration.kt @@ -0,0 +1,30 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.network.NetworkPreferences +import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class MoveDOHSettingMigration : Migration { + override val version: Float = 14f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val networkPreferences = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + // Migrate DNS over HTTPS setting + val wasDohEnabled = prefs.getBoolean("enable_doh", false) + if (wasDohEnabled) { + prefs.edit { + putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE) + remove("enable_doh") + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveEncryptionSettingsToAppStateMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveEncryptionSettingsToAppStateMigration.kt new file mode 100644 index 000000000..ab817dd06 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveEncryptionSettingsToAppStateMigration.kt @@ -0,0 +1,45 @@ +package mihon.core.migration.migrations + +import android.widget.Toast +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.util.system.toast +import mihon.core.migration.MigrateUtils +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.Preference +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.lang.withUIContext + +class MoveEncryptionSettingsToAppStateMigration : Migration { + override val version: Float = 66f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val preferenceStore = migrationContext.get() ?: return@withIOContext false + if (prefs.getBoolean(Preference.privateKey("encrypt_database"), false)) { + withUIContext { + context.toast( + "Restart the app to load your encrypted library", + Toast.LENGTH_LONG + ) + } + } + + val appStatePrefsToReplace = listOf( + "__PRIVATE_sql_password", + "__PRIVATE_encrypt_database", + "__PRIVATE_cbz_password", + ) + + MigrateUtils.replacePreferences( + preferenceStore = preferenceStore, + filterPredicate = { it.key in appStatePrefsToReplace }, + newKey = { Preference.appStateKey(it.replace("__PRIVATE_", "").trim()) }, + ) + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveExtensionRepoSettingsMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveExtensionRepoSettingsMigration.kt new file mode 100644 index 000000000..a29731fe1 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveExtensionRepoSettingsMigration.kt @@ -0,0 +1,37 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.App +import mihon.core.migration.MigrateUtils +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.Preference +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.preference.getAndSet +import tachiyomi.core.common.util.lang.withIOContext + +class MoveExtensionRepoSettingsMigration : Migration { + override val version: Float = 60f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val preferenceStore = migrationContext.get() ?: return@withIOContext false + val sourcePreferences = migrationContext.get() ?: return@withIOContext false + sourcePreferences.extensionRepos().getAndSet { + it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet() + } + MigrateUtils.replacePreferences( + preferenceStore = preferenceStore, + filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") }, + newKey = { Preference.privateKey(it) }, + ) + prefs.edit { + remove(Preference.appStateKey("trusted_signatures")) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveLatestToFeedMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveLatestToFeedMigration.kt new file mode 100644 index 000000000..4eea4b709 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveLatestToFeedMigration.kt @@ -0,0 +1,63 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import exh.util.nullIfBlank +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.source.interactor.InsertFeedSavedSearch +import tachiyomi.domain.source.interactor.InsertSavedSearch +import tachiyomi.domain.source.model.FeedSavedSearch +import tachiyomi.domain.source.model.SavedSearch + +class MoveLatestToFeedMigration : Migration { + override val version: Float = 31f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val insertSavedSearch = migrationContext.get() ?: return@withIOContext false + val insertFeedSavedSearch = migrationContext.get() ?: return@withIOContext false + val savedSearch = prefs.getStringSet("eh_saved_searches", emptySet())?.mapNotNull { + runCatching { + val content = Json.decodeFromString(it.substringAfter(':')) + SavedSearch( + id = -1, + source = it.substringBefore(':').toLongOrNull() + ?: return@runCatching null, + name = content["name"]!!.jsonPrimitive.content, + query = content["query"]!!.jsonPrimitive.contentOrNull?.nullIfBlank(), + filtersJson = Json.encodeToString(content["filters"]!!.jsonArray), + ) + }.getOrNull() + } + if (!savedSearch.isNullOrEmpty()) { + insertSavedSearch.awaitAll(savedSearch) + } + val feedSavedSearch = prefs.getStringSet("latest_tab_sources", emptySet())?.map { + FeedSavedSearch( + id = -1, + source = it.toLong(), + savedSearch = null, + global = true, + ) + } + if (!feedSavedSearch.isNullOrEmpty()) { + insertFeedSavedSearch.awaitAll(feedSavedSearch) + } + prefs.edit(commit = true) { + remove("eh_saved_searches") + remove("latest_tab_sources") + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveLibraryNonCompleteSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveLibraryNonCompleteSettingMigration.kt new file mode 100644 index 000000000..04a6d4a99 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveLibraryNonCompleteSettingMigration.kt @@ -0,0 +1,25 @@ +package mihon.core.migration.migrations + +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.minusAssign +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class MoveLibraryNonCompleteSettingMigration : Migration { + override val version: Float = 23f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true) + if (!oldUpdateOngoingOnly) { + libraryPreferences.autoUpdateMangaRestrictions() -= LibraryPreferences.MANGA_NON_COMPLETED + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveLibrarySortingSettingsMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveLibrarySortingSettingsMigration.kt new file mode 100644 index 000000000..d91eff916 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveLibrarySortingSettingsMigration.kt @@ -0,0 +1,57 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.library.service.LibraryPreferences + +class MoveLibrarySortingSettingsMigration : Migration { + override val version: Float = 20f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + try { + val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0 /* ALPHABETICAL */) + val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true) + + val newSortingMode = when (oldSortingMode) { + 0 -> "ALPHABETICAL" + 1 -> "LAST_READ" + 2 -> "LAST_MANGA_UPDATE" + 3 -> "UNREAD_COUNT" + 4 -> "TOTAL_CHAPTERS" + 6 -> "LATEST_CHAPTER" + 7 -> "DRAG_AND_DROP" + 8 -> "DATE_ADDED" + 9 -> "TAG_LIST" + 10 -> "CHAPTER_FETCH_DATE" + else -> "ALPHABETICAL" + } + + val newSortingDirection = when (oldSortingDirection) { + true -> "ASCENDING" + else -> "DESCENDING" + } + + prefs.edit(commit = true) { + remove(libraryPreferences.sortingMode().key()) + remove("library_sorting_ascending") + } + + prefs.edit { + putString(libraryPreferences.sortingMode().key(), newSortingMode) + putString("library_sorting_ascending", newSortingDirection) + } + } catch (e: Exception) { + logcat(throwable = e) { "Already done migration" } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveReaderTapSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveReaderTapSettingMigration.kt new file mode 100644 index 000000000..d117a8cdf --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveReaderTapSettingMigration.kt @@ -0,0 +1,25 @@ +package mihon.core.migration.migrations + +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class MoveReaderTapSettingMigration : Migration { + override val version: Float = 32f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val readerPreferences = migrationContext.get() ?: return@withIOContext false + val oldReaderTap = prefs.getBoolean("reader_tap", false) + if (!oldReaderTap) { + readerPreferences.navigationModePager().set(5) + readerPreferences.navigationModeWebtoon().set(5) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveReadingButtonSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveReadingButtonSettingMigration.kt new file mode 100644 index 000000000..7a7398a55 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveReadingButtonSettingMigration.kt @@ -0,0 +1,23 @@ +package mihon.core.migration.migrations + +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class MoveReadingButtonSettingMigration : Migration { + override val version: Float = 43f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + if (prefs.getBoolean("start_reading_button", false)) { + libraryPreferences.showContinueReadingButton().set(true) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveRelativeTimeSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveRelativeTimeSettingMigration.kt new file mode 100644 index 000000000..82d955307 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveRelativeTimeSettingMigration.kt @@ -0,0 +1,22 @@ +package mihon.core.migration.migrations + +import eu.kanade.domain.ui.UiPreferences +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.util.lang.withIOContext + +class MoveRelativeTimeSettingMigration : Migration { + override val version: Float = 57f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val preferenceStore = migrationContext.get() ?: return@withIOContext false + val uiPreferences = migrationContext.get() ?: return@withIOContext false + val pref = preferenceStore.getInt("relative_time", 7) + if (pref.get() == 0) { + uiPreferences.relativeTime().set(false) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveSecureScreenSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveSecureScreenSettingMigration.kt new file mode 100644 index 000000000..b2cd04296 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveSecureScreenSettingMigration.kt @@ -0,0 +1,24 @@ +package mihon.core.migration.migrations + +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.core.security.SecurityPreferences +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class MoveSecureScreenSettingMigration : Migration { + override val version: Float = 27f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val securityPreferences = migrationContext.get() ?: return@withIOContext false + val oldSecureScreen = prefs.getBoolean("secure_screen", false) + if (oldSecureScreen) { + securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveSettingsToPrivateOrAppStateMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveSettingsToPrivateOrAppStateMigration.kt new file mode 100644 index 000000000..de9766cf8 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveSettingsToPrivateOrAppStateMigration.kt @@ -0,0 +1,67 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import mihon.core.migration.MigrateUtils +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.Preference +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.util.lang.withIOContext + +class MoveSettingsToPrivateOrAppStateMigration : Migration { + override val version: Float = 59f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val preferenceStore = migrationContext.get() ?: return@withIOContext false + val prefsToReplace = listOf( + "pref_download_only", + "incognito_mode", + "last_catalogue_source", + "trusted_signatures", + "last_app_closed", + "library_update_last_timestamp", + "library_unseen_updates_count", + "last_used_category", + "last_app_check", + "last_ext_check", + "last_version_code", + "skip_pre_migration", + "eh_auto_update_stats", + "storage_dir", + ) + MigrateUtils.replacePreferences( + preferenceStore = preferenceStore, + filterPredicate = { it.key in prefsToReplace }, + newKey = { Preference.appStateKey(it) }, + ) + + val privatePrefsToReplace = listOf( + "sql_password", + "encrypt_database", + "cbz_password", + "password_protect_downloads", + "eh_ipb_member_id", + "enable_exhentai", + "eh_ipb_member_id", + "eh_ipb_pass_hash", + "eh_igneous", + "eh_ehSettingsProfile", + "eh_exhSettingsProfile", + "eh_settingsKey", + "eh_sessionCookie", + "eh_hathPerksCookie", + ) + + MigrateUtils.replacePreferences( + preferenceStore = preferenceStore, + filterPredicate = { it.key in privatePrefsToReplace }, + newKey = { Preference.privateKey(it) }, + ) + + // Deleting old download cache index files, but might as well clear it all out + context.cacheDir.deleteRecursively() + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveSortingModeSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveSortingModeSettingMigration.kt new file mode 100644 index 000000000..14a97195a --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveSortingModeSettingMigration.kt @@ -0,0 +1,27 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class MoveSortingModeSettingMigration : Migration { + override val version: Float = 39f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit { + val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit + val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!! + putString(libraryPreferences.sortingMode().key(), "$sort,$direction") + remove("library_sorting_ascending") + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/MoveSortingModeSettingsMigration.kt b/app/src/main/java/mihon/core/migration/migrations/MoveSortingModeSettingsMigration.kt new file mode 100644 index 000000000..6026da11c --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/MoveSortingModeSettingsMigration.kt @@ -0,0 +1,49 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.data.DatabaseHandler +import tachiyomi.data.category.CategoryMapper +import tachiyomi.domain.library.service.LibraryPreferences + +class MoveSortingModeSettingsMigration : Migration { + override val version: Float = 38f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val handler = migrationContext.get() ?: return@withIOContext false + // Handle renamed enum values + val newSortingMode = when ( + val oldSortingMode = prefs.getString(libraryPreferences.sortingMode().key(), "ALPHABETICAL") + ) { + "LAST_CHECKED" -> "LAST_MANGA_UPDATE" + "UNREAD" -> "UNREAD_COUNT" + "DATE_FETCHED" -> "CHAPTER_FETCH_DATE" + "DRAG_AND_DROP" -> "ALPHABETICAL" + else -> oldSortingMode + } + prefs.edit { + putString(libraryPreferences.sortingMode().key(), newSortingMode) + } + handler.await(true) { + categoriesQueries.getCategories(CategoryMapper::mapCategory).executeAsList() + .filter { (it.flags and 0b00111100L) == 0b00100000L } + .forEach { + categoriesQueries.update( + categoryId = it.id, + flags = it.flags and 0b00111100L.inv(), + name = null, + order = null, + ) + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/RemoveBatteryNotLowRestrictionMigration.kt b/app/src/main/java/mihon/core/migration/migrations/RemoveBatteryNotLowRestrictionMigration.kt new file mode 100644 index 000000000..4381f3a4f --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/RemoveBatteryNotLowRestrictionMigration.kt @@ -0,0 +1,21 @@ +package mihon.core.migration.migrations + +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.getAndSet +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class RemoveBatteryNotLowRestrictionMigration : Migration { + override val version: Float = 56f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val pref = libraryPreferences.autoUpdateDeviceRestrictions() + if (pref.isSet() && "battery_not_low" in pref.get()) { + pref.getAndSet { it - "battery_not_low" } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/RemoveOldReaderThemeMigration.kt b/app/src/main/java/mihon/core/migration/migrations/RemoveOldReaderThemeMigration.kt new file mode 100644 index 000000000..feeddfb80 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/RemoveOldReaderThemeMigration.kt @@ -0,0 +1,20 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class RemoveOldReaderThemeMigration : Migration { + override val version: Float = 18f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val readerPreferences = migrationContext.get() ?: return@withIOContext false + val readerTheme = readerPreferences.readerTheme().get() + if (readerTheme == 4) { + readerPreferences.readerTheme().set(3) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/RemoveShortLibraryUpdatesMigration.kt b/app/src/main/java/mihon/core/migration/migrations/RemoveShortLibraryUpdatesMigration.kt new file mode 100644 index 000000000..5c8d08cfe --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/RemoveShortLibraryUpdatesMigration.kt @@ -0,0 +1,20 @@ +package mihon.core.migration.migrations + +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class RemoveShortLibraryUpdatesMigration : Migration { + override val version: Float = 22f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val updateInterval = libraryPreferences.autoUpdateInterval().get() + if (updateInterval in listOf(3, 4, 6, 8)) { + libraryPreferences.autoUpdateInterval().set(12) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/RemoveShorterLibraryUpdatesMigration.kt b/app/src/main/java/mihon/core/migration/migrations/RemoveShorterLibraryUpdatesMigration.kt new file mode 100644 index 000000000..5716ca125 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/RemoveShorterLibraryUpdatesMigration.kt @@ -0,0 +1,20 @@ +package mihon.core.migration.migrations + +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class RemoveShorterLibraryUpdatesMigration : Migration { + override val version: Float = 18f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val updateInterval = libraryPreferences.autoUpdateInterval().get() + if (updateInterval == 1 || updateInterval == 2) { + libraryPreferences.autoUpdateInterval().set(3) + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/RemoveUpdateCheckerJobsMigration.kt b/app/src/main/java/mihon/core/migration/migrations/RemoveUpdateCheckerJobsMigration.kt new file mode 100644 index 000000000..eddcf6515 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/RemoveUpdateCheckerJobsMigration.kt @@ -0,0 +1,55 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.util.system.workManager +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.preference.TriState +import tachiyomi.core.common.preference.getEnum +import tachiyomi.core.common.util.lang.withIOContext + +class RemoveUpdateCheckerJobsMigration : Migration { + override val version: Float = 52f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val preferenceStore = migrationContext.get() ?: return@withIOContext false + val trackerManager = migrationContext.get() ?: return@withIOContext false + // Removed background jobs + context.workManager.cancelAllWorkByTag("UpdateChecker") + context.workManager.cancelAllWorkByTag("ExtensionUpdate") + prefs.edit { + remove("automatic_ext_updates") + } + val prefKeys = listOf( + "pref_filter_library_downloaded", + "pref_filter_library_unread", + "pref_filter_library_started", + "pref_filter_library_bookmarked", + "pref_filter_library_completed", + "pref_filter_library_lewd", + ) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" } + + prefKeys.forEach { key -> + val pref = prefs.getInt(key, 0) + prefs.edit { + remove(key) + + val newValue = when (pref) { + 1 -> TriState.ENABLED_IS + 2 -> TriState.ENABLED_NOT + else -> TriState.DISABLED + } + + preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue) + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ResetFilterAndSortSettingsMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ResetFilterAndSortSettingsMigration.kt new file mode 100644 index 000000000..4dced90ed --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ResetFilterAndSortSettingsMigration.kt @@ -0,0 +1,39 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.library.service.LibraryPreferences + +class ResetFilterAndSortSettingsMigration : Migration { + override val version: Float = 41f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val libraryPreferences = migrationContext.get() ?: return@withIOContext false + val preferences = listOf( + libraryPreferences.filterChapterByRead(), + libraryPreferences.filterChapterByDownloaded(), + libraryPreferences.filterChapterByBookmarked(), + libraryPreferences.sortChapterBySourceOrNumber(), + libraryPreferences.displayChapterByNameOrNumber(), + libraryPreferences.sortChapterByAscendingOrDescending(), + ) + + prefs.edit { + preferences.forEach { preference -> + val key = preference.key() + val value = prefs.getInt(key, Int.MIN_VALUE) + if (value == Int.MIN_VALUE) return@forEach + remove(key) + putLong(key, value.toLong()) + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ResetReaderSettingsMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ResetReaderSettingsMigration.kt new file mode 100644 index 000000000..1f7e1fb2f --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ResetReaderSettingsMigration.kt @@ -0,0 +1,39 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class ResetReaderSettingsMigration : Migration { + override val version: Float = 17f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + // Migrate Rotation and Viewer values to default values for viewer_flags + val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) { + 1 -> ReaderOrientation.FREE.flagValue + 2 -> ReaderOrientation.PORTRAIT.flagValue + 3 -> ReaderOrientation.LANDSCAPE.flagValue + 4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue + 5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue + else -> ReaderOrientation.FREE.flagValue + } + + // Reading mode flag and prefValue is the same value + val newReadingMode = prefs.getInt("pref_default_viewer_key", 1) + + prefs.edit { + putInt("pref_default_orientation_type_key", newOrientation) + remove("pref_rotation_type_key") + putInt("pref_default_reading_mode_key", newReadingMode) + remove("pref_default_viewer_key") + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/ResetRotationSettingMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ResetRotationSettingMigration.kt new file mode 100644 index 000000000..b005ea978 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ResetRotationSettingMigration.kt @@ -0,0 +1,25 @@ +package mihon.core.migration.migrations + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.App +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.util.lang.withIOContext + +class ResetRotationSettingMigration : Migration { + override val version: Float = 16f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val context = migrationContext.get() ?: return@withIOContext false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + // Reset rotation to Free after replacing Lock + if (prefs.contains("pref_rotation_type_key")) { + prefs.edit { + putInt("pref_rotation_type_key", 1) + } + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/SetupBackupCreateMigration.kt b/app/src/main/java/mihon/core/migration/migrations/SetupBackupCreateMigration.kt new file mode 100644 index 000000000..44c0b557c --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/SetupBackupCreateMigration.kt @@ -0,0 +1,16 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext + +class SetupBackupCreateMigration : Migration { + override val version: Float = Migration.ALWAYS + + override suspend fun invoke(migrationContext: MigrationContext): Boolean { + val context = migrationContext.get() ?: return false + BackupCreateJob.setupTask(context) + return true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/SetupEHentaiUpdateMigration.kt b/app/src/main/java/mihon/core/migration/migrations/SetupEHentaiUpdateMigration.kt new file mode 100644 index 000000000..00097dd4e --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/SetupEHentaiUpdateMigration.kt @@ -0,0 +1,16 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import exh.eh.EHentaiUpdateWorker +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext + +class SetupEHentaiUpdateMigration : Migration { + override val version: Float = Migration.ALWAYS + + override suspend fun invoke(migrationContext: MigrationContext): Boolean { + val context = migrationContext.get() ?: return false + EHentaiUpdateWorker.scheduleBackground(context) + return true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/SetupLibraryUpdateMigration.kt b/app/src/main/java/mihon/core/migration/migrations/SetupLibraryUpdateMigration.kt new file mode 100644 index 000000000..65482cd89 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/SetupLibraryUpdateMigration.kt @@ -0,0 +1,16 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext + +class SetupLibraryUpdateMigration : Migration { + override val version: Float = Migration.ALWAYS + + override suspend fun invoke(migrationContext: MigrationContext): Boolean { + val context = migrationContext.get() ?: return false + LibraryUpdateJob.setupTask(context) + return true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/SetupSyncDataMigration.kt b/app/src/main/java/mihon/core/migration/migrations/SetupSyncDataMigration.kt new file mode 100644 index 000000000..1f668724c --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/SetupSyncDataMigration.kt @@ -0,0 +1,16 @@ +package mihon.core.migration.migrations + +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.sync.SyncDataJob +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext + +class SetupSyncDataMigration : Migration { + override val version: Float = Migration.ALWAYS + + override suspend fun invoke(migrationContext: MigrationContext): Boolean { + val context = migrationContext.get() ?: return false + SyncDataJob.setupTask(context) + return true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt b/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt new file mode 100644 index 000000000..b5dd80ff5 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt @@ -0,0 +1,35 @@ +package mihon.core.migration.migrations + +import eu.kanade.domain.source.service.SourcePreferences +import logcat.LogPriority +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import mihon.domain.extensionrepo.exception.SaveExtensionRepoException +import mihon.domain.extensionrepo.repository.ExtensionRepoRepository +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.system.logcat + +class TrustExtensionRepositoryMigration : Migration { + override val version: Float = 67f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val sourcePreferences = migrationContext.get() ?: return@withIOContext false + val extensionRepositoryRepository = + migrationContext.get() ?: return@withIOContext false + for ((index, source) in sourcePreferences.extensionRepos().get().withIndex()) { + try { + extensionRepositoryRepository.upsertRepo( + source, + "Repo #${index + 1}", + null, + source, + "NOFINGERPRINT-${index + 1}", + ) + } catch (e: SaveExtensionRepoException) { + logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" } + } + } + sourcePreferences.extensionRepos().delete() + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt new file mode 100644 index 000000000..981c3d540 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt @@ -0,0 +1,27 @@ +package mihon.feature.upcoming + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.ui.manga.MangaScreen + +class UpcomingScreen : Screen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + + val screenModel = rememberScreenModel { UpcomingScreenModel() } + val state by screenModel.state.collectAsState() + + UpcomingScreenContent( + state = state, + setSelectedYearMonth = screenModel::setSelectedYearMonth, + onClickUpcoming = { navigator.push(MangaScreen(it.id)) }, + ) + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt new file mode 100644 index 000000000..0baa1c8ee --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt @@ -0,0 +1,198 @@ +package mihon.feature.upcoming + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.relativeDateText +import eu.kanade.presentation.util.isTabletUi +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.launch +import mihon.feature.upcoming.components.UpcomingItem +import mihon.feature.upcoming.components.calendar.Calendar +import tachiyomi.core.common.Constants +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.components.ListGroupHeader +import tachiyomi.presentation.core.components.TwoPanelBox +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.i18n.stringResource +import java.time.LocalDate +import java.time.YearMonth + +@Composable +fun UpcomingScreenContent( + state: UpcomingScreenModel.State, + setSelectedYearMonth: (YearMonth) -> Unit, + onClickUpcoming: (manga: Manga) -> Unit, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val onClickDay: (LocalDate, Int) -> Unit = { date, offset -> + state.headerIndexes[date]?.let { + scope.launch { + listState.animateScrollToItem(it + offset) + } + } + } + Scaffold( + topBar = { UpcomingToolbar() }, + modifier = modifier, + ) { paddingValues -> + if (isTabletUi()) { + UpcomingScreenLargeImpl( + listState = listState, + items = state.items, + events = state.events, + paddingValues = paddingValues, + selectedYearMonth = state.selectedYearMonth, + setSelectedYearMonth = setSelectedYearMonth, + onClickDay = { onClickDay(it, 0) }, + onClickUpcoming = onClickUpcoming, + ) + } else { + UpcomingScreenSmallImpl( + listState = listState, + items = state.items, + events = state.events, + paddingValues = paddingValues, + selectedYearMonth = state.selectedYearMonth, + setSelectedYearMonth = setSelectedYearMonth, + onClickDay = { onClickDay(it, 1) }, + onClickUpcoming = onClickUpcoming, + ) + } + } +} + +@Composable +private fun UpcomingToolbar() { + val navigator = LocalNavigator.currentOrThrow + val uriHandler = LocalUriHandler.current + + AppBar( + title = stringResource(MR.strings.label_upcoming), + navigateUp = navigator::pop, + actions = { + IconButton(onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = stringResource(MR.strings.upcoming_guide), + ) + } + }, + ) +} + +@Composable +private fun UpcomingScreenSmallImpl( + listState: LazyListState, + items: ImmutableList, + events: ImmutableMap, + paddingValues: PaddingValues, + selectedYearMonth: YearMonth, + setSelectedYearMonth: (YearMonth) -> Unit, + onClickDay: (LocalDate) -> Unit, + onClickUpcoming: (manga: Manga) -> Unit, +) { + FastScrollLazyColumn( + contentPadding = paddingValues, + state = listState, + ) { + item(key = "upcoming-calendar") { + Calendar( + selectedYearMonth = selectedYearMonth, + events = events, + setSelectedYearMonth = setSelectedYearMonth, + onClickDay = onClickDay, + ) + } + items( + items = items, + key = { "upcoming-${it.hashCode()}" }, + contentType = { + when (it) { + is UpcomingUIModel.Header -> "header" + is UpcomingUIModel.Item -> "item" + } + }, + ) { item -> + when (item) { + is UpcomingUIModel.Item -> { + UpcomingItem( + upcoming = item.manga, + onClick = { onClickUpcoming(item.manga) }, + ) + } + is UpcomingUIModel.Header -> { + ListGroupHeader(text = relativeDateText(item.date)) + } + } + } + } +} + +@Composable +private fun UpcomingScreenLargeImpl( + listState: LazyListState, + items: ImmutableList, + events: ImmutableMap, + paddingValues: PaddingValues, + selectedYearMonth: YearMonth, + setSelectedYearMonth: (YearMonth) -> Unit, + onClickDay: (LocalDate) -> Unit, + onClickUpcoming: (manga: Manga) -> Unit, +) { + TwoPanelBox( + modifier = Modifier.padding(paddingValues), + startContent = { + Calendar( + selectedYearMonth = selectedYearMonth, + events = events, + setSelectedYearMonth = setSelectedYearMonth, + onClickDay = onClickDay, + ) + }, + endContent = { + FastScrollLazyColumn(state = listState) { + items( + items = items, + key = { "upcoming-${it.hashCode()}" }, + contentType = { + when (it) { + is UpcomingUIModel.Header -> "header" + is UpcomingUIModel.Item -> "item" + } + }, + ) { item -> + when (item) { + is UpcomingUIModel.Item -> { + UpcomingItem( + upcoming = item.manga, + onClick = { onClickUpcoming(item.manga) }, + ) + } + is UpcomingUIModel.Header -> { + ListGroupHeader(text = relativeDateText(item.date)) + } + } + } + } + }, + ) +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt new file mode 100644 index 000000000..b404a7f96 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenModel.kt @@ -0,0 +1,87 @@ +package mihon.feature.upcoming + +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMapIndexedNotNull +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import eu.kanade.core.util.insertSeparators +import eu.kanade.tachiyomi.util.lang.toLocalDate +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mihon.domain.upcoming.interactor.GetUpcomingManga +import tachiyomi.domain.manga.model.Manga +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.time.LocalDate +import java.time.YearMonth + +class UpcomingScreenModel( + private val getUpcomingManga: GetUpcomingManga = Injekt.get(), +) : StateScreenModel(State()) { + + init { + screenModelScope.launch { + getUpcomingManga.subscribe().collectLatest { + mutableState.update { state -> + val upcomingItems = it.toUpcomingUIModels() + state.copy( + items = upcomingItems, + events = it.toEvents(), + headerIndexes = upcomingItems.getHeaderIndexes(), + ) + } + } + } + } + + private fun List.toUpcomingUIModels(): ImmutableList { + return fastMap { UpcomingUIModel.Item(it) } + .insertSeparators { before, after -> + val beforeDate = before?.manga?.expectedNextUpdate?.toLocalDate() + val afterDate = after?.manga?.expectedNextUpdate?.toLocalDate() + + if (beforeDate != afterDate && afterDate != null) { + UpcomingUIModel.Header(afterDate) + } else { + null + } + } + .toImmutableList() + } + + private fun List.toEvents(): ImmutableMap { + return groupBy { it.expectedNextUpdate?.toLocalDate() ?: LocalDate.MAX } + .mapValues { it.value.size } + .toImmutableMap() + } + + private fun List.getHeaderIndexes(): ImmutableMap { + return fastMapIndexedNotNull { index, upcomingUIModel -> + if (upcomingUIModel is UpcomingUIModel.Header) { + upcomingUIModel.date to index + } else { + null + } + } + .toMap() + .toImmutableMap() + } + + fun setSelectedYearMonth(yearMonth: YearMonth) { + mutableState.update { it.copy(selectedYearMonth = yearMonth) } + } + + data class State( + val selectedYearMonth: YearMonth = YearMonth.now(), + val items: ImmutableList = persistentListOf(), + val events: ImmutableMap = persistentMapOf(), + val headerIndexes: ImmutableMap = persistentMapOf(), + ) +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt new file mode 100644 index 000000000..c394f45f6 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingUIModel.kt @@ -0,0 +1,9 @@ +package mihon.feature.upcoming + +import tachiyomi.domain.manga.model.Manga +import java.time.LocalDate + +sealed interface UpcomingUIModel { + data class Header(val date: LocalDate) : UpcomingUIModel + data class Item(val manga: Manga) : UpcomingUIModel +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt b/app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt new file mode 100644 index 000000000..bf82c73cd --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/UpcomingItem.kt @@ -0,0 +1,54 @@ +package mihon.feature.upcoming.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.manga.components.MangaCover +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.asMangaCover +import tachiyomi.presentation.core.components.material.padding + +private val UpcomingItemHeight = 96.dp + +@Composable +fun UpcomingItem( + upcoming: Manga, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clickable(onClick = onClick) + .height(UpcomingItemHeight) + .padding( + horizontal = MaterialTheme.padding.medium, + vertical = MaterialTheme.padding.small, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.large), + ) { + MangaCover.Book( + modifier = Modifier.fillMaxHeight(), + data = upcoming.asMangaCover(), + ) + Text( + modifier = Modifier.weight(1f), + text = upcoming.title, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt new file mode 100644 index 000000000..da511ddad --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/Calendar.kt @@ -0,0 +1,112 @@ +package mihon.feature.upcoming.components.calendar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEach +import io.woong.compose.grid.SimpleGridCells +import io.woong.compose.grid.VerticalGrid +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import mihon.core.designsystem.utils.isExpandedWidthWindow +import mihon.core.designsystem.utils.isMediumWidthWindow +import tachiyomi.presentation.core.components.material.padding +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.TextStyle +import java.time.temporal.WeekFields +import java.util.Locale + +private val FontSize = 16.sp +private const val DaysOfWeek = 7 + +@Composable +fun Calendar( + selectedYearMonth: YearMonth, + events: ImmutableMap, + setSelectedYearMonth: (YearMonth) -> Unit, + onClickDay: (day: LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CalenderHeader( + yearMonth = selectedYearMonth, + onPreviousClick = { setSelectedYearMonth(selectedYearMonth.minusMonths(1L)) }, + onNextClick = { setSelectedYearMonth(selectedYearMonth.plusMonths(1L)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.padding.small) + .padding(start = MaterialTheme.padding.medium) + ) + CalendarGrid( + selectedYearMonth = selectedYearMonth, + events = events, + onClickDay = onClickDay, + ) + } +} + +@Composable +private fun CalendarGrid( + selectedYearMonth: YearMonth, + events: ImmutableMap, + onClickDay: (day: LocalDate) -> Unit, +) { + val localeFirstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek.value + val weekDays = remember { + (0 until DaysOfWeek) + .map { DayOfWeek.of((localeFirstDayOfWeek - 1 + it) % DaysOfWeek + 1) } + .toImmutableList() + } + + val emptyFieldCount = weekDays.indexOf(selectedYearMonth.atDay(1).dayOfWeek) + val daysInMonth = selectedYearMonth.lengthOfMonth() + + VerticalGrid( + columns = SimpleGridCells.Fixed(DaysOfWeek), + modifier = if (isMediumWidthWindow() && !isExpandedWidthWindow()) { + Modifier.widthIn(max = 360.dp) + } else { + Modifier + } + ) { + weekDays.fastForEach { item -> + Text( + text = item.getDisplayName( + TextStyle.NARROW, + Locale.getDefault(), + ), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = FontSize, + ) + } + repeat(emptyFieldCount) { Box { } } + repeat(daysInMonth) { dayIndex -> + val localDate = selectedYearMonth.atDay(dayIndex + 1) + CalendarDay( + date = localDate, + onDayClick = { onClickDay(localDate) }, + events = events[localDate] ?: 0, + ) + } + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt new file mode 100644 index 000000000..46ed355ab --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarDay.kt @@ -0,0 +1,92 @@ +package mihon.feature.upcoming.components.calendar + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.LocalDate + +private const val MaxEvents = 3 + +@Composable +fun CalendarDay( + date: LocalDate, + events: Int, + onDayClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val today = remember { LocalDate.now() } + + Box( + modifier = modifier + .then( + if (today == date) { + Modifier.border( + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.onBackground + ), + shape = CircleShape, + ) + } else { + Modifier + }, + ) + .clip(shape = CircleShape) + .clickable(onClick = onDayClick) + .circleLayout(), + contentAlignment = Alignment.Center, + ) { + Text( + text = date.dayOfMonth.toString(), + textAlign = TextAlign.Center, + fontSize = 16.sp, + color = if (date.isBefore(today)) { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.38f) + } else { + MaterialTheme.colorScheme.onBackground + }, + fontWeight = FontWeight.SemiBold, + ) + Row(Modifier.offset(y = 12.dp)) { + val size = events.coerceAtMost(MaxEvents) + for (index in 0 until size) { + CalendarIndicator( + index = index, + size = 56.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +private fun Modifier.circleLayout() = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val currentHeight = placeable.height + val currentWidth = placeable.width + val newDiameter = maxOf(currentHeight, currentWidth) + + layout(newDiameter, newDiameter) { + placeable.placeRelative( + x = (newDiameter - currentWidth) / 2, + y = (newDiameter - currentHeight) / 2, + ) + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt new file mode 100644 index 000000000..55498ebb9 --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarHeader.kt @@ -0,0 +1,100 @@ +package mihon.feature.upcoming.components.calendar + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +fun CalenderHeader( + yearMonth: YearMonth, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = yearMonth, + transitionSpec = { getAnimation() }, + label = "Change Month", + ) { monthYear -> + Text( + text = getTitleText(monthYear), + style = MaterialTheme.typography.titleLarge, + ) + } + Row { + IconButton(onClick = onPreviousClick) { + Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev)) + } + IconButton(onClick = onNextClick) { + Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next)) + } + } + } +} + +private const val MonthYearChangeAnimationDuration = 200 + +private fun AnimatedContentTransitionScope.getAnimation(): ContentTransform { + val movingForward = targetState > initialState + + val enterTransition = slideInVertically( + animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration), + ) { height -> if (movingForward) height else -height } + fadeIn( + animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration), + ) + val exitTransition = slideOutVertically( + animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration), + ) { height -> if (movingForward) -height else height } + fadeOut( + animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration), + ) + return (enterTransition togetherWith exitTransition) + .using(SizeTransform(clip = false)) +} + +@Composable +@ReadOnlyComposable +private fun getTitleText(monthYear: YearMonth): String { + val formatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault()) + return formatter.format(monthYear) +} + +@Preview +@Composable +private fun CalenderHeaderPreview() { + CalenderHeader( + yearMonth = YearMonth.now(), + onNextClick = {}, + onPreviousClick = {}, + ) +} diff --git a/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt new file mode 100644 index 000000000..9aaca69de --- /dev/null +++ b/app/src/main/java/mihon/feature/upcoming/components/calendar/CalendarIndicator.kt @@ -0,0 +1,32 @@ +package mihon.feature.upcoming.components.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val IndicatorScale = 12 +private const val IndicatorAlphaMultiplier = 0.3f + +@Composable +fun CalendarIndicator( + index: Int, + size: Dp, + color: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .padding(horizontal = 1.dp) + .clip(shape = CircleShape) + .background(color = color.copy(alpha = (index + 1) * IndicatorAlphaMultiplier)) + .size(size = size.div(IndicatorScale)), + ) +} diff --git a/app/src/main/res/layout/edit_manga_dialog.xml b/app/src/main/res/layout/edit_manga_dialog.xml index 205207812..1f7d67ebb 100644 --- a/app/src/main/res/layout/edit_manga_dialog.xml +++ b/app/src/main/res/layout/edit_manga_dialog.xml @@ -139,6 +139,16 @@ android:text="@string/reset_tags" android:textAllCaps="false" /> +