Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46bf139f01 | |||
| c3fb5c0bec | |||
| 000a4ffc3f | |||
| 7b0b879d65 | |||
| 8a622f6c7d | |||
| 253060a3bc | |||
| b6b33e8c00 | |||
| 2e4f811090 | |||
| 215a1908f7 | |||
| 082acf000c | |||
| 2d1240b274 | |||
| 1e98709cc3 | |||
| 5550ddad4e | |||
| f9148c0c5e | |||
| 089e6268e7 | |||
| 712cd1493f | |||
| bbc8adc3e8 | |||
| 077b673c0a | |||
| 49eacf5178 | |||
| 98d1dddf4a | |||
| 37a616f3db | |||
| ad18696a1a | |||
| 34bb012a1c | |||
| 08c4989aa3 | |||
| 14dae420f5 | |||
| 65ed3c5ae6 | |||
| 5ae3508665 | |||
| e32eb0e009 | |||
| e0812ab5c8 | |||
| df9f79c120 | |||
| 990eb33b98 | |||
| e1bab1172a | |||
| aeeff72bed | |||
| 5895e78b39 | |||
| b24719a3e9 | |||
| d551619d9d | |||
| 06ad6c2e16 | |||
| df7e470e08 | |||
| 03f32ebffd | |||
| ed20d25452 | |||
| 596a8d002f | |||
| 206d824ed2 | |||
| 97ed4e55ad | |||
| 739f7bc848 | |||
| e866e60b19 | |||
| f135daeca5 | |||
| d80c19eb03 | |||
| 2d47147172 | |||
| de3570107e | |||
| 5480495619 | |||
| 694ef5f285 | |||
| 472c97c580 | |||
| 8b098b38f8 | |||
| 5e0585d724 | |||
| 3e438a9e87 | |||
| 8046c1a540 | |||
| 1f3f6cd4df | |||
| a62a5ed650 | |||
| a320903bc0 | |||
| a6c4f01c74 | |||
| a657c65261 | |||
| 5455daf96b | |||
| d40bc2b41b | |||
| 527ca85c39 | |||
| 189714eaf1 | |||
| 90d5104bdc | |||
| ceff887a10 | |||
| 2197bd0451 | |||
| 861a810961 | |||
| 81984c25df | |||
| b21d685a37 | |||
| fb4d9209f8 | |||
| 9ee0034c9a | |||
| 268b483182 | |||
| 2af6e7be32 | |||
| 3ecf86ae35 | |||
| 41bb0e08ba | |||
| 0bb1eb2da1 | |||
| 919df9a7cf | |||
| 2ea488bff5 | |||
| ec30ccccc2 | |||
| 780bdcbe55 | |||
| a90bc4c7fa | |||
| 5e421c6f0e | |||
| 742fdc19ca | |||
| 74505565ef | |||
| f041ed5b2a | |||
| 8762b20ab6 | |||
| 5a71889679 | |||
| a4983eb004 | |||
| 818bc7f75a | |||
| 7de6fa8c23 | |||
| edca9039e5 | |||
| fb1649125c | |||
| 0767526f18 | |||
| 5d1b1408eb | |||
| 2f54f00bf7 | |||
| 87feb58055 | |||
| 28edaca869 | |||
| d14f012bbb | |||
| adc6bbf54f | |||
| 2b064baca1 | |||
| 983a80ba42 |
@@ -40,6 +40,12 @@ jobs:
|
||||
"ignoreCase": true,
|
||||
"labels": ["Cloudflare protected"],
|
||||
"message": "Refer to the **Solving Cloudflare issues** section at https://mihon.app/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection."
|
||||
},
|
||||
{
|
||||
"type": "both",
|
||||
"regex": "^.*(myanimelist|mal).*$",
|
||||
"ignoreCase": true,
|
||||
"message": "For issues with linking MyAnimeList, please follow these steps:\n1. Update Mihon to version 0.16.4 or newer\n2. Change your default User-Agent (`More → Settings → Advanced → Default user agent string`)\n3. Close and restart Mihon\n4. Attempt to link MyAnimeList again\n\nIf you had MyAnimeList linked before, try to unlink it first before trying these steps."
|
||||
}
|
||||
]
|
||||
auto-close-ignore-label: do-not-autoclose
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ When creating a fork, remember to:
|
||||
8. Click publish
|
||||
9. Go to API & Services -> Credentials
|
||||
10. Click Create credentials -> Oauth client ID
|
||||
11. Select Android, give it a name, and set eu.kanade.google.oauth as the package name
|
||||
11. Select Android, give it a name, and set `eu.kanade.google.oauth` as the package name
|
||||
12. To get the SHA-1 key, run `keytool -printcert -jarfile app-standard-universal-release.apk` on your apk, and copy the listed SHA-1
|
||||
13. Expand advanced settings, and enable Custom URL scheme
|
||||
14. After that just download the json, name it to `client_secrets.json` and put it in `app/src/main/assets/`
|
||||
+11
-31
@@ -1,9 +1,12 @@
|
||||
import mihon.buildlogic.getBuildTime
|
||||
import mihon.buildlogic.getCommitCount
|
||||
import mihon.buildlogic.getGitSha
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("mihon.android.application")
|
||||
id("mihon.android.application.compose")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
kotlin("android")
|
||||
kotlin("plugin.parcelize")
|
||||
kotlin("plugin.serialization")
|
||||
// id("com.github.zellius.shortcut-helper")
|
||||
@@ -26,7 +29,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "eu.kanade.tachiyomi.sy"
|
||||
|
||||
versionCode = 66
|
||||
versionCode = 67
|
||||
versionName = "1.10.5"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
@@ -120,7 +123,6 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
buildConfig = true
|
||||
|
||||
// Disable some unused things
|
||||
@@ -133,10 +135,6 @@ android {
|
||||
abortOnError = false
|
||||
checkReleaseBuilds = false
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = compose.versions.compiler.get()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -154,7 +152,6 @@ dependencies {
|
||||
implementation(projects.presentationWidget)
|
||||
|
||||
// Compose
|
||||
implementation(platform(compose.bom))
|
||||
implementation(compose.activity)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3.core)
|
||||
@@ -165,7 +162,6 @@ dependencies {
|
||||
debugImplementation(compose.ui.tooling)
|
||||
implementation(compose.ui.tooling.preview)
|
||||
implementation(compose.ui.util)
|
||||
implementation(compose.accompanist.webview)
|
||||
implementation(compose.accompanist.systemuicontroller)
|
||||
|
||||
implementation(androidx.paging.runtime)
|
||||
@@ -247,6 +243,9 @@ dependencies {
|
||||
implementation(libs.bundles.voyager)
|
||||
implementation(libs.compose.materialmotion)
|
||||
implementation(libs.swipe)
|
||||
implementation(libs.compose.webview)
|
||||
implementation(libs.compose.grid)
|
||||
|
||||
|
||||
implementation(libs.google.api.services.drive)
|
||||
implementation(libs.google.api.client.oauth)
|
||||
@@ -255,7 +254,6 @@ dependencies {
|
||||
implementation(libs.logcat)
|
||||
|
||||
// Crash reports/analytics
|
||||
// implementation(libs.bundles.acra)
|
||||
// "standardImplementation"(libs.firebase.analytics)
|
||||
|
||||
// Shizuku
|
||||
@@ -268,6 +266,8 @@ dependencies {
|
||||
// debugImplementation(libs.leakcanary.android)
|
||||
implementation(libs.leakcanary.plumber)
|
||||
|
||||
testImplementation(kotlinx.coroutines.test)
|
||||
|
||||
// SY -->
|
||||
// Text distance (EH)
|
||||
implementation(sylibs.simularity)
|
||||
@@ -314,31 +314,11 @@ tasks {
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
-keep,allowoptimization class eu.kanade.**
|
||||
-keep,allowoptimization class tachiyomi.**
|
||||
-keep,allowoptimization class mihon.**
|
||||
|
||||
# Keep common dependencies used in extensions
|
||||
-keep,allowoptimization class androidx.preference.** { public protected *; }
|
||||
|
||||
@@ -4,10 +4,7 @@ import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
|
||||
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.download.interactor.DeleteDownload
|
||||
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionRepos
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionSources
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionsByType
|
||||
import eu.kanade.domain.extension.interactor.TrustExtension
|
||||
@@ -26,6 +23,16 @@ import eu.kanade.domain.track.interactor.AddTracks
|
||||
import eu.kanade.domain.track.interactor.RefreshTracks
|
||||
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
||||
import eu.kanade.domain.track.interactor.TrackChapter
|
||||
import mihon.data.repository.ExtensionRepoRepositoryImpl
|
||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
||||
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import mihon.domain.extensionrepo.service.ExtensionRepoService
|
||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||
import tachiyomi.data.category.CategoryRepositoryImpl
|
||||
import tachiyomi.data.chapter.ChapterRepositoryImpl
|
||||
import tachiyomi.data.history.HistoryRepositoryImpl
|
||||
@@ -111,6 +118,7 @@ class DomainModule : InjektModule {
|
||||
addFactory { GetMangaByUrlAndSourceId(get()) }
|
||||
addFactory { GetManga(get()) }
|
||||
addFactory { GetNextChapters(get(), get(), get(), get()) }
|
||||
addFactory { GetUpcomingManga(get()) }
|
||||
addFactory { ResetViewerFlags(get()) }
|
||||
addFactory { SetMangaChapterFlags(get()) }
|
||||
addFactory { FetchInterval(get()) }
|
||||
@@ -171,10 +179,15 @@ class DomainModule : InjektModule {
|
||||
addFactory { ToggleLanguage(get()) }
|
||||
addFactory { ToggleSource(get()) }
|
||||
addFactory { ToggleSourcePin(get()) }
|
||||
addFactory { TrustExtension(get()) }
|
||||
addFactory { TrustExtension(get(), get()) }
|
||||
|
||||
addFactory { CreateExtensionRepo(get()) }
|
||||
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
|
||||
addFactory { ExtensionRepoService(get(), get()) }
|
||||
addFactory { GetExtensionRepo(get()) }
|
||||
addFactory { GetExtensionRepoCount(get()) }
|
||||
addFactory { CreateExtensionRepo(get(), get()) }
|
||||
addFactory { DeleteExtensionRepo(get()) }
|
||||
addFactory { GetExtensionRepos(get()) }
|
||||
addFactory { ReplaceExtensionRepo(get()) }
|
||||
addFactory { UpdateExtensionRepo(get(), get()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package eu.kanade.domain.base
|
||||
|
||||
import android.content.Context
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
|
||||
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -22,8 +20,6 @@ class BasePreferences(
|
||||
|
||||
fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore)
|
||||
|
||||
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", isPreviewBuildType || isReleaseBuildType)
|
||||
|
||||
fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false)
|
||||
|
||||
enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) {
|
||||
@@ -32,4 +28,6 @@ class BasePreferences(
|
||||
SHIZUKU(MR.strings.ext_installer_shizuku, false),
|
||||
PRIVATE(MR.strings.ext_installer_private, false),
|
||||
}
|
||||
|
||||
fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "")
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package eu.kanade.domain.extension.interactor
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import tachiyomi.core.common.preference.plusAssign
|
||||
|
||||
class CreateExtensionRepo(private val preferences: SourcePreferences) {
|
||||
|
||||
fun await(name: String): Result {
|
||||
// Do not allow invalid formats
|
||||
if (!name.matches(repoRegex)) {
|
||||
return Result.InvalidUrl
|
||||
}
|
||||
|
||||
preferences.extensionRepos() += name.removeSuffix("/index.min.json")
|
||||
|
||||
return Result.Success
|
||||
}
|
||||
|
||||
sealed interface Result {
|
||||
data object InvalidUrl : Result
|
||||
data object Success : Result
|
||||
}
|
||||
}
|
||||
|
||||
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
|
||||
@@ -1,11 +0,0 @@
|
||||
package eu.kanade.domain.extension.interactor
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import tachiyomi.core.common.preference.minusAssign
|
||||
|
||||
class DeleteExtensionRepo(private val preferences: SourcePreferences) {
|
||||
|
||||
fun await(repo: String) {
|
||||
preferences.extensionRepos() -= repo
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package eu.kanade.domain.extension.interactor
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class GetExtensionRepos(private val preferences: SourcePreferences) {
|
||||
|
||||
fun subscribe(): Flow<Set<String>> {
|
||||
return preferences.extensionRepos().changes()
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class GetExtensionsByType(
|
||||
extensionManager.installedExtensionsFlow,
|
||||
extensionManager.untrustedExtensionsFlow,
|
||||
extensionManager.availableExtensionsFlow,
|
||||
) { _activeLanguages, _installed, _untrusted, _available ->
|
||||
) { enabledLanguages, _installed, _untrusted, _available ->
|
||||
val (updates, installed) = _installed
|
||||
.filter { (showNsfwSources || !it.isNsfw) }
|
||||
.sortedWith(
|
||||
@@ -41,9 +41,9 @@ class GetExtensionsByType(
|
||||
}
|
||||
.flatMap { ext ->
|
||||
if (ext.sources.isEmpty()) {
|
||||
return@flatMap if (ext.lang in _activeLanguages) listOf(ext) else emptyList()
|
||||
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
|
||||
}
|
||||
ext.sources.filter { it.lang in _activeLanguages }
|
||||
ext.sources.filter { it.lang in enabledLanguages }
|
||||
.map {
|
||||
ext.copy(
|
||||
name = it.name,
|
||||
|
||||
@@ -3,15 +3,18 @@ package eu.kanade.domain.extension.interactor
|
||||
import android.content.pm.PackageInfo
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import tachiyomi.core.common.preference.getAndSet
|
||||
|
||||
class TrustExtension(
|
||||
private val extensionRepoRepository: ExtensionRepoRepository,
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
fun isTrusted(pkgInfo: PackageInfo, signatureHash: String): Boolean {
|
||||
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:$signatureHash"
|
||||
return key in preferences.trustedExtensions().get()
|
||||
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
|
||||
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet()
|
||||
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
|
||||
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
|
||||
}
|
||||
|
||||
fun trust(pkgName: String, versionCode: Long, signatureHash: String) {
|
||||
@@ -19,9 +22,7 @@ class TrustExtension(
|
||||
// Remove previously trusted versions
|
||||
val removed = exts.filterNot { it.startsWith("$pkgName:") }.toMutableSet()
|
||||
|
||||
removed.also {
|
||||
it += "$pkgName:$versionCode:$signatureHash"
|
||||
}
|
||||
removed.also { it += "$pkgName:$versionCode:$signatureHash" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ class SyncPreferences(
|
||||
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
|
||||
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
|
||||
|
||||
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
|
||||
|
||||
fun syncInterval() = preferenceStore.getInt("sync_interval", 0)
|
||||
fun syncService() = preferenceStore.getInt("sync_service", 0)
|
||||
|
||||
@@ -53,6 +55,11 @@ class SyncPreferences(
|
||||
appSettings = preferenceStore.getBoolean("appSettings", true).get(),
|
||||
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
|
||||
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
|
||||
|
||||
// SY -->
|
||||
customInfo = preferenceStore.getBoolean("customInfo", true).get(),
|
||||
readEntries = preferenceStore.getBoolean("readEntries", true).get()
|
||||
// SY <--
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,6 +72,11 @@ class SyncPreferences(
|
||||
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
|
||||
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
|
||||
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)
|
||||
|
||||
// SY -->
|
||||
preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo)
|
||||
preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries)
|
||||
// SY <--
|
||||
}
|
||||
|
||||
fun getSyncTriggerOptions(): SyncTriggerOptions {
|
||||
|
||||
@@ -9,4 +9,9 @@ data class SyncSettings(
|
||||
val appSettings: Boolean = true,
|
||||
val sourceSettings: Boolean = true,
|
||||
val privateSettings: Boolean = false,
|
||||
|
||||
// SY -->
|
||||
val customInfo: Boolean = true,
|
||||
val readEntries: Boolean = true
|
||||
// SY <--
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -362,10 +361,8 @@ private fun InfoText(
|
||||
primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val clickableModifier = if (onClick != null) {
|
||||
Modifier.clickable(interactionSource, indication = null) { onClick() }
|
||||
Modifier.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ fun FeedScreen(
|
||||
onClickDelete: (FeedSavedSearch) -> Unit,
|
||||
onClickManga: (Manga) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
getMangaState: @Composable (Manga, CatalogueSource?) -> State<Manga>,
|
||||
getMangaState: @Composable (Manga) -> State<Manga>,
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen()
|
||||
@@ -119,7 +119,7 @@ fun FeedScreen(
|
||||
) {
|
||||
FeedItem(
|
||||
item = item,
|
||||
getMangaState = { getMangaState(it, item.source) },
|
||||
getMangaState = { getMangaState(it) },
|
||||
onClickManga = onClickManga,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ fun CrashScreen(
|
||||
acceptText = stringResource(MR.strings.pref_dump_crash_logs),
|
||||
onAcceptClick = {
|
||||
scope.launch {
|
||||
CrashLogUtil(context).dumpLogs()
|
||||
CrashLogUtil(context).dumpLogs(exception)
|
||||
}
|
||||
},
|
||||
rejectText = stringResource(MR.strings.crash_screen_restart_application),
|
||||
|
||||
@@ -129,7 +129,7 @@ private fun LibraryRegularToolbar(
|
||||
onClick = onClickOpenRandomManga,
|
||||
),
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(MR.strings.sync_library),
|
||||
title = stringResource(SYMR.strings.sync_library),
|
||||
onClick = onClickSyncNow,
|
||||
),
|
||||
).builder().apply {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
package eu.kanade.presentation.manga
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.SwapVert
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.kanade.presentation.components.AdaptiveSheet
|
||||
import eu.kanade.presentation.components.TabbedDialogPaddings
|
||||
import eu.kanade.presentation.more.settings.LocalPreferenceMinHeight
|
||||
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
@@ -18,42 +35,92 @@ fun DuplicateMangaDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onOpenManga: () -> Unit,
|
||||
onMigrate: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AlertDialog(
|
||||
val minHeight = LocalPreferenceMinHeight.current
|
||||
|
||||
AdaptiveSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.are_you_sure))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(MR.strings.confirm_add_duplicate_manga))
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
vertical = TabbedDialogPaddings.Vertical,
|
||||
horizontal = TabbedDialogPaddings.Horizontal,
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(TitlePadding),
|
||||
text = stringResource(MR.strings.are_you_sure),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(MR.strings.confirm_add_duplicate_manga),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(PaddingSize))
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_show_manga),
|
||||
icon = Icons.Outlined.Book,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onOpenManga()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_migrate_duplicate),
|
||||
icon = Icons.Outlined.SwapVert,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onMigrate()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_add_anyway),
|
||||
icon = Icons.Outlined.Add,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onConfirm()
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.sizeIn(minHeight = minHeight)
|
||||
.clickable { onDismissRequest.invoke() }
|
||||
.padding(ButtonPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onOpenManga()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_show_manga))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onConfirm()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_add))
|
||||
OutlinedButton(onClick = onDismissRequest, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp),
|
||||
text = stringResource(MR.strings.action_cancel),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PaddingSize = 16.dp
|
||||
|
||||
private val ButtonPadding = PaddingValues(top = 16.dp, bottom = 16.dp)
|
||||
private val TitlePadding = PaddingValues(bottom = 16.dp, top = 8.dp)
|
||||
|
||||
+1
-1
@@ -9,13 +9,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.ripple
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -33,12 +32,12 @@ import androidx.compose.material.icons.outlined.Label
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.RemoveDone
|
||||
import androidx.compose.material.icons.outlined.SwapCalls
|
||||
import androidx.compose.material.ripple
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -199,7 +198,7 @@ private fun RowScope.Button(
|
||||
.size(48.dp)
|
||||
.weight(animatedWeight)
|
||||
.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
interactionSource = null,
|
||||
indication = ripple(bounded = false),
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
|
||||
+78
-65
@@ -8,6 +8,8 @@ import android.provider.Settings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
@@ -112,71 +114,54 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
val basePreferences = remember { Injekt.get<BasePreferences>() }
|
||||
val networkPreferences = remember { Injekt.get<NetworkPreferences>() }
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
listOf(
|
||||
/* SY --> Preference.PreferenceItem.SwitchPreference(
|
||||
pref = basePreferences.acraEnabled(),
|
||||
title = stringResource(MR.strings.pref_enable_acra),
|
||||
subtitle = stringResource(MR.strings.pref_acra_summary),
|
||||
enabled = isPreviewBuildType || isReleaseBuildType,
|
||||
), SY <-- */
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_dump_crash_logs),
|
||||
subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
CrashLogUtil(context).dumpLogs()
|
||||
}
|
||||
},
|
||||
),
|
||||
/* SY --> Preference.PreferenceItem.SwitchPreference(
|
||||
pref = networkPreferences.verboseLogging(),
|
||||
title = stringResource(MR.strings.pref_verbose_logging),
|
||||
subtitle = stringResource(MR.strings.pref_verbose_logging_summary),
|
||||
onValueChanged = {
|
||||
context.toast(MR.strings.requires_app_restart)
|
||||
true
|
||||
},
|
||||
), SY <-- */
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_debug_info),
|
||||
onClick = { navigator.push(DebugInfoScreen()) },
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_onboarding_guide),
|
||||
onClick = { navigator.push(OnboardingScreen()) },
|
||||
),
|
||||
),
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
add(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_manage_notifications),
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
addAll(
|
||||
listOf(
|
||||
getBackgroundActivityGroup(),
|
||||
getDataGroup(),
|
||||
getNetworkGroup(networkPreferences = networkPreferences),
|
||||
getLibraryGroup(),
|
||||
getExtensionsGroup(basePreferences = basePreferences),
|
||||
// SY -->
|
||||
// getDownloaderGroup(),
|
||||
getDataSaverGroup(),
|
||||
getDeveloperToolsGroup(),
|
||||
// SY <--
|
||||
),
|
||||
)
|
||||
}
|
||||
return listOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_dump_crash_logs),
|
||||
subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
CrashLogUtil(context).dumpLogs()
|
||||
}
|
||||
},
|
||||
),
|
||||
/* SY --> Preference.PreferenceItem.SwitchPreference(
|
||||
pref = networkPreferences.verboseLogging(),
|
||||
title = stringResource(MR.strings.pref_verbose_logging),
|
||||
subtitle = stringResource(MR.strings.pref_verbose_logging_summary),
|
||||
onValueChanged = {
|
||||
context.toast(MR.strings.requires_app_restart)
|
||||
true
|
||||
},
|
||||
), SY <-- */
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_debug_info),
|
||||
onClick = { navigator.push(DebugInfoScreen()) },
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_onboarding_guide),
|
||||
onClick = { navigator.push(OnboardingScreen()) },
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_manage_notifications),
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
),
|
||||
getBackgroundActivityGroup(),
|
||||
getDataGroup(),
|
||||
getNetworkGroup(networkPreferences = networkPreferences),
|
||||
getLibraryGroup(),
|
||||
getReaderGroup(basePreferences = basePreferences),
|
||||
getExtensionsGroup(basePreferences = basePreferences),
|
||||
// SY -->
|
||||
// getDownloaderGroup(),
|
||||
getDataSaverGroup(),
|
||||
getDeveloperToolsGroup(),
|
||||
// SY <--
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -367,6 +352,34 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getReaderGroup(
|
||||
basePreferences: BasePreferences,
|
||||
): Preference.PreferenceGroup {
|
||||
val context = LocalContext.current
|
||||
val chooseColorProfile = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
basePreferences.displayProfile().set(uri.toString())
|
||||
}
|
||||
}
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_category_reader),
|
||||
preferenceItems = persistentListOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_display_profile),
|
||||
subtitle = basePreferences.displayProfile().get(),
|
||||
onClick = {
|
||||
chooseColorProfile.launch(arrayOf("*/*"))
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getExtensionsGroup(
|
||||
basePreferences: BasePreferences,
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -17,6 +18,7 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
||||
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen
|
||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.domain.UnsortedPreferences
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -39,7 +41,9 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
||||
val reposCount by sourcePreferences.extensionRepos().collectAsState()
|
||||
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
|
||||
|
||||
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
|
||||
|
||||
// SY -->
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -104,7 +108,7 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.label_extension_repos),
|
||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
|
||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
|
||||
onClick = {
|
||||
navigator.push(ExtensionReposScreen())
|
||||
},
|
||||
|
||||
+27
-27
@@ -349,15 +349,15 @@ object SettingsDataScreen : SearchableSettings {
|
||||
private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
|
||||
return listOf(
|
||||
Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_sync_service_category),
|
||||
title = stringResource(SYMR.strings.pref_sync_service_category),
|
||||
preferenceItems = persistentListOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
pref = syncPreferences.syncService(),
|
||||
title = stringResource(MR.strings.pref_sync_service),
|
||||
title = stringResource(SYMR.strings.pref_sync_service),
|
||||
entries = persistentMapOf(
|
||||
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
|
||||
SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi),
|
||||
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive),
|
||||
SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
|
||||
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive),
|
||||
),
|
||||
onValueChanged = { true },
|
||||
),
|
||||
@@ -402,7 +402,7 @@ object SettingsDataScreen : SearchableSettings {
|
||||
val googleDriveSync = Injekt.get<GoogleDriveService>()
|
||||
return listOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_google_drive_sign_in),
|
||||
title = stringResource(SYMR.strings.pref_google_drive_sign_in),
|
||||
onClick = {
|
||||
val intent = googleDriveSync.getSignInIntent()
|
||||
context.startActivity(intent)
|
||||
@@ -427,19 +427,19 @@ object SettingsDataScreen : SearchableSettings {
|
||||
val result = googleDriveSync.deleteSyncDataFromGoogleDrive()
|
||||
when (result) {
|
||||
GoogleDriveSyncService.DeleteSyncDataStatus.NOT_INITIALIZED -> context.toast(
|
||||
MR.strings.google_drive_not_signed_in,
|
||||
SYMR.strings.google_drive_not_signed_in,
|
||||
duration = 5000,
|
||||
)
|
||||
GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast(
|
||||
MR.strings.google_drive_sync_data_not_found,
|
||||
SYMR.strings.google_drive_sync_data_not_found,
|
||||
duration = 5000,
|
||||
)
|
||||
GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast(
|
||||
MR.strings.google_drive_sync_data_purged,
|
||||
SYMR.strings.google_drive_sync_data_purged,
|
||||
duration = 5000,
|
||||
)
|
||||
GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast(
|
||||
MR.strings.google_drive_sync_data_purge_error,
|
||||
SYMR.strings.google_drive_sync_data_purge_error,
|
||||
duration = 10000,
|
||||
)
|
||||
}
|
||||
@@ -450,7 +450,7 @@ object SettingsDataScreen : SearchableSettings {
|
||||
}
|
||||
|
||||
return Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_google_drive_purge_sync_data),
|
||||
title = stringResource(SYMR.strings.pref_google_drive_purge_sync_data),
|
||||
onClick = { showPurgeDialog = true },
|
||||
)
|
||||
}
|
||||
@@ -462,8 +462,8 @@ object SettingsDataScreen : SearchableSettings {
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = stringResource(MR.strings.pref_purge_confirmation_title)) },
|
||||
text = { Text(text = stringResource(MR.strings.pref_purge_confirmation_message)) },
|
||||
title = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_title)) },
|
||||
text = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_message)) },
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
@@ -482,8 +482,8 @@ object SettingsDataScreen : SearchableSettings {
|
||||
val scope = rememberCoroutineScope()
|
||||
return listOf(
|
||||
Preference.PreferenceItem.EditTextPreference(
|
||||
title = stringResource(MR.strings.pref_sync_host),
|
||||
subtitle = stringResource(MR.strings.pref_sync_host_summ),
|
||||
title = stringResource(SYMR.strings.pref_sync_host),
|
||||
subtitle = stringResource(SYMR.strings.pref_sync_host_summ),
|
||||
pref = syncPreferences.clientHost(),
|
||||
onValueChanged = { newValue ->
|
||||
scope.launch {
|
||||
@@ -496,8 +496,8 @@ object SettingsDataScreen : SearchableSettings {
|
||||
},
|
||||
),
|
||||
Preference.PreferenceItem.EditTextPreference(
|
||||
title = stringResource(MR.strings.pref_sync_api_key),
|
||||
subtitle = stringResource(MR.strings.pref_sync_api_key_summ),
|
||||
title = stringResource(SYMR.strings.pref_sync_api_key),
|
||||
subtitle = stringResource(SYMR.strings.pref_sync_api_key_summ),
|
||||
pref = syncPreferences.clientAPIKey(),
|
||||
),
|
||||
)
|
||||
@@ -507,12 +507,12 @@ object SettingsDataScreen : SearchableSettings {
|
||||
private fun getSyncNowPref(): Preference.PreferenceGroup {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_sync_now_group_title),
|
||||
title = stringResource(SYMR.strings.pref_sync_now_group_title),
|
||||
preferenceItems = persistentListOf(
|
||||
getSyncOptionsPref(),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_sync_now),
|
||||
subtitle = stringResource(MR.strings.pref_sync_now_subtitle),
|
||||
title = stringResource(SYMR.strings.pref_sync_now),
|
||||
subtitle = stringResource(SYMR.strings.pref_sync_now_subtitle),
|
||||
onClick = {
|
||||
navigator.push(SyncSettingsSelector())
|
||||
},
|
||||
@@ -525,8 +525,8 @@ object SettingsDataScreen : SearchableSettings {
|
||||
private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
return Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_sync_options),
|
||||
subtitle = stringResource(MR.strings.pref_sync_options_summ),
|
||||
title = stringResource(SYMR.strings.pref_sync_options),
|
||||
subtitle = stringResource(SYMR.strings.pref_sync_options_summ),
|
||||
onClick = { navigator.push(SyncTriggerOptionsScreen()) },
|
||||
)
|
||||
}
|
||||
@@ -538,16 +538,16 @@ object SettingsDataScreen : SearchableSettings {
|
||||
val lastSync by syncPreferences.lastSyncTimestamp().collectAsState()
|
||||
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_sync_automatic_category),
|
||||
title = stringResource(SYMR.strings.pref_sync_automatic_category),
|
||||
preferenceItems = persistentListOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
pref = syncIntervalPref,
|
||||
title = stringResource(MR.strings.pref_sync_interval),
|
||||
title = stringResource(SYMR.strings.pref_sync_interval),
|
||||
entries = persistentMapOf(
|
||||
0 to stringResource(MR.strings.off),
|
||||
30 to stringResource(MR.strings.update_30min),
|
||||
60 to stringResource(MR.strings.update_1hour),
|
||||
180 to stringResource(MR.strings.update_3hour),
|
||||
30 to stringResource(SYMR.strings.update_30min),
|
||||
60 to stringResource(SYMR.strings.update_1hour),
|
||||
180 to stringResource(SYMR.strings.update_3hour),
|
||||
360 to stringResource(MR.strings.update_6hour),
|
||||
720 to stringResource(MR.strings.update_12hour),
|
||||
1440 to stringResource(MR.strings.update_24hour),
|
||||
@@ -560,7 +560,7 @@ object SettingsDataScreen : SearchableSettings {
|
||||
},
|
||||
),
|
||||
Preference.PreferenceItem.InfoPreference(
|
||||
stringResource(MR.strings.last_synchronization, relativeTimeSpanString(lastSync)),
|
||||
stringResource(SYMR.strings.last_synchronization, relativeTimeSpanString(lastSync)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+1
-6
@@ -35,6 +35,7 @@ object SettingsReaderScreen : SearchableSettings {
|
||||
// SY -->
|
||||
val forceHorizontalSeekbar by readerPref.forceHorizontalSeekbar().collectAsState()
|
||||
// SY <--
|
||||
|
||||
return listOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
pref = readerPref.defaultReadingMode(),
|
||||
@@ -81,12 +82,6 @@ object SettingsReaderScreen : SearchableSettings {
|
||||
enabled = !forceHorizontalSeekbar,
|
||||
),
|
||||
// SY <--
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
pref = readerPref.trueColor(),
|
||||
title = stringResource(MR.strings.pref_true_color),
|
||||
subtitle = stringResource(MR.strings.pref_true_color_summary),
|
||||
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O,
|
||||
),
|
||||
/* SY -->
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
pref = readerPref.pageTransitions(),
|
||||
|
||||
@@ -56,10 +56,9 @@ import tachiyomi.presentation.core.icons.Reddit
|
||||
import tachiyomi.presentation.core.icons.X
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
object AboutScreen : Screen() {
|
||||
|
||||
@@ -293,11 +292,15 @@ object AboutScreen : Screen() {
|
||||
|
||||
internal fun getFormattedBuildTime(): String {
|
||||
return try {
|
||||
val df = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'", Locale.US)
|
||||
.withZone(ZoneId.of("UTC"))
|
||||
val buildTime = LocalDateTime.from(df.parse(BuildConfig.BUILD_TIME))
|
||||
|
||||
buildTime!!.toDateTimestampString(UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()))
|
||||
LocalDateTime.ofInstant(
|
||||
Instant.parse(BuildConfig.BUILD_TIME),
|
||||
ZoneId.systemDefault(),
|
||||
)
|
||||
.toDateTimestampString(
|
||||
UiPreferences.dateFormat(
|
||||
Injekt.get<UiPreferences>().dateFormat().get(),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
BuildConfig.BUILD_TIME
|
||||
}
|
||||
|
||||
+6
-5
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+16
-2
@@ -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) {
|
||||
|
||||
+48
-14
@@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionRepos
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class ExtensionReposScreenModel(
|
||||
private val getExtensionRepos: GetExtensionRepos = Injekt.get(),
|
||||
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
|
||||
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
|
||||
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
|
||||
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
|
||||
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
|
||||
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
|
||||
|
||||
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
|
||||
@@ -29,7 +34,7 @@ class ExtensionReposScreenModel(
|
||||
|
||||
init {
|
||||
screenModelScope.launchIO {
|
||||
getExtensionRepos.subscribe()
|
||||
getExtensionRepo.subscribeAll()
|
||||
.collectLatest { repos ->
|
||||
mutableState.update {
|
||||
RepoScreenState.Success(
|
||||
@@ -43,25 +48,51 @@ class ExtensionReposScreenModel(
|
||||
/**
|
||||
* Creates and adds a new repo to the database.
|
||||
*
|
||||
* @param name The name of the repo to create.
|
||||
* @param baseUrl The baseUrl of the repo to create.
|
||||
*/
|
||||
fun createRepo(name: String) {
|
||||
fun createRepo(baseUrl: String) {
|
||||
screenModelScope.launchIO {
|
||||
when (createExtensionRepo.await(name)) {
|
||||
is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
|
||||
when (val result = createExtensionRepo.await(baseUrl)) {
|
||||
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
|
||||
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
|
||||
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
|
||||
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given repo from the database.
|
||||
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
|
||||
*
|
||||
* @param repo The repo to delete.
|
||||
* @param newRepo The repo to insert
|
||||
*/
|
||||
fun deleteRepo(repo: String) {
|
||||
fun replaceRepo(newRepo: ExtensionRepo) {
|
||||
screenModelScope.launchIO {
|
||||
deleteExtensionRepo.await(repo)
|
||||
replaceExtensionRepo.await(newRepo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes information for each repository.
|
||||
*/
|
||||
fun refreshRepos() {
|
||||
val status = state.value
|
||||
|
||||
if (status is RepoScreenState.Success) {
|
||||
screenModelScope.launchIO {
|
||||
updateExtensionRepo.awaitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given repo from the database
|
||||
*/
|
||||
fun deleteRepo(baseUrl: String) {
|
||||
screenModelScope.launchIO {
|
||||
deleteExtensionRepo.await(baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +118,13 @@ class ExtensionReposScreenModel(
|
||||
sealed class RepoEvent {
|
||||
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
|
||||
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
|
||||
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
|
||||
}
|
||||
|
||||
sealed class RepoDialog {
|
||||
data object Create : RepoDialog()
|
||||
data class Delete(val repo: String) : RepoDialog()
|
||||
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
|
||||
}
|
||||
|
||||
sealed class RepoScreenState {
|
||||
@@ -101,7 +134,8 @@ sealed class RepoScreenState {
|
||||
|
||||
@Immutable
|
||||
data class Success(
|
||||
val repos: ImmutableSet<String>,
|
||||
val repos: ImmutableSet<ExtensionRepo>,
|
||||
val oldRepos: ImmutableSet<String>? = null,
|
||||
val dialog: RepoDialog? = null,
|
||||
) : RepoScreenState() {
|
||||
|
||||
|
||||
+21
-5
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Label
|
||||
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun ExtensionReposContent(
|
||||
repos: ImmutableSet<String>,
|
||||
repos: ImmutableSet<ExtensionRepo>,
|
||||
lazyListState: LazyListState,
|
||||
paddingValues: PaddingValues,
|
||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||
onClickDelete: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -45,7 +48,8 @@ fun ExtensionReposContent(
|
||||
ExtensionRepoListItem(
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
repo = it,
|
||||
onDelete = { onClickDelete(it) },
|
||||
onOpenWebsite = { onOpenWebsite(it) },
|
||||
onDelete = { onClickDelete(it.baseUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +58,8 @@ fun ExtensionReposContent(
|
||||
|
||||
@Composable
|
||||
private fun ExtensionRepoListItem(
|
||||
repo: String,
|
||||
repo: ExtensionRepo,
|
||||
onOpenWebsite: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -74,16 +79,27 @@ private fun ExtensionRepoListItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
|
||||
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
|
||||
Text(
|
||||
text = repo.name,
|
||||
modifier = Modifier.padding(start = MaterialTheme.padding.medium),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = onOpenWebsite) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
|
||||
contentDescription = stringResource(MR.strings.action_open_in_browser),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val url = "$repo/index.min.json"
|
||||
val url = "${repo.baseUrl}/index.min.json"
|
||||
context.copyToClipboard(url, url)
|
||||
},
|
||||
) {
|
||||
|
||||
+39
-2
@@ -1,6 +1,7 @@
|
||||
package eu.kanade.presentation.more.settings.screen.browse.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -14,8 +15,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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 +27,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||
fun ExtensionRepoCreateDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
repos: ImmutableSet<String>,
|
||||
repoUrls: ImmutableSet<String>,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val nameAlreadyExists = remember(name) { repos.contains(name) }
|
||||
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
@@ -73,6 +76,7 @@ fun ExtensionRepoCreateDialog(
|
||||
Text(text = stringResource(msgRes))
|
||||
},
|
||||
isError = name.isNotEmpty() && nameAlreadyExists,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
@@ -115,3 +119,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))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+16
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+15
-4
@@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||
import tachiyomi.presentation.core.components.SectionCard
|
||||
@@ -39,7 +40,7 @@ class SyncSettingsSelector : Screen() {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBar(
|
||||
title = stringResource(MR.strings.pref_choose_what_to_sync),
|
||||
title = stringResource(SYMR.strings.pref_choose_what_to_sync),
|
||||
navigateUp = navigator::pop,
|
||||
scrollBehavior = it,
|
||||
)
|
||||
@@ -47,14 +48,14 @@ class SyncSettingsSelector : Screen() {
|
||||
) { contentPadding ->
|
||||
LazyColumnWithAction(
|
||||
contentPadding = contentPadding,
|
||||
actionLabel = stringResource(MR.strings.label_sync),
|
||||
actionLabel = stringResource(SYMR.strings.label_sync),
|
||||
actionEnabled = state.options.anyEnabled(),
|
||||
onClickAction = {
|
||||
if (!SyncDataJob.isAnyJobRunning(context)) {
|
||||
if (!SyncDataJob.isRunning(context)) {
|
||||
model.syncNow(context)
|
||||
navigator.pop()
|
||||
} else {
|
||||
context.toast(MR.strings.sync_in_progress)
|
||||
context.toast(SYMR.strings.sync_in_progress)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -123,6 +124,11 @@ private class SyncSettingsSelectorModel(
|
||||
appSettings = syncSettings.appSettings,
|
||||
sourceSettings = syncSettings.sourceSettings,
|
||||
privateSettings = syncSettings.privateSettings,
|
||||
|
||||
// SY -->
|
||||
customInfo = syncSettings.customInfo,
|
||||
readEntries = syncSettings.readEntries,
|
||||
// SY <--
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +142,11 @@ private class SyncSettingsSelectorModel(
|
||||
appSettings = backupOptions.appSettings,
|
||||
sourceSettings = backupOptions.sourceSettings,
|
||||
privateSettings = backupOptions.privateSettings,
|
||||
|
||||
// SY -->
|
||||
customInfo = backupOptions.customInfo,
|
||||
readEntries = backupOptions.readEntries,
|
||||
// SY <--
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||
import tachiyomi.presentation.core.components.SectionCard
|
||||
@@ -34,7 +35,7 @@ class SyncTriggerOptionsScreen : Screen() {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBar(
|
||||
title = stringResource(MR.strings.pref_sync_options),
|
||||
title = stringResource(SYMR.strings.pref_sync_options),
|
||||
navigateUp = navigator::pop,
|
||||
scrollBehavior = it,
|
||||
)
|
||||
@@ -43,13 +44,13 @@ class SyncTriggerOptionsScreen : Screen() {
|
||||
LazyColumnWithAction(
|
||||
contentPadding = contentPadding,
|
||||
actionLabel = stringResource(MR.strings.action_save),
|
||||
actionEnabled = state.options.anyEnabled(),
|
||||
actionEnabled = true,
|
||||
onClickAction = {
|
||||
navigator.pop()
|
||||
},
|
||||
) {
|
||||
item {
|
||||
SectionCard(MR.strings.label_triggers) {
|
||||
SectionCard(SYMR.strings.label_triggers) {
|
||||
Options(SyncTriggerOptions.mainOptions, state, model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.FlipToBack
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material.icons.outlined.SelectAll
|
||||
@@ -50,6 +51,7 @@ fun UpdateScreen(
|
||||
onClickCover: (UpdatesItem) -> Unit,
|
||||
onSelectAll: (Boolean) -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
onCalendarClicked: () -> Unit,
|
||||
onUpdateLibrary: () -> Boolean,
|
||||
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
|
||||
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
|
||||
@@ -63,6 +65,7 @@ fun UpdateScreen(
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
UpdatesAppBar(
|
||||
onCalendarClicked = { onCalendarClicked() },
|
||||
onUpdateLibrary = { onUpdateLibrary() },
|
||||
actionModeCounter = state.selected.size,
|
||||
onSelectAll = { onSelectAll(true) },
|
||||
@@ -132,6 +135,7 @@ fun UpdateScreen(
|
||||
|
||||
@Composable
|
||||
private fun UpdatesAppBar(
|
||||
onCalendarClicked: () -> Unit,
|
||||
onUpdateLibrary: () -> Unit,
|
||||
// For action mode
|
||||
actionModeCounter: Int,
|
||||
@@ -147,6 +151,11 @@ private fun UpdatesAppBar(
|
||||
actions = {
|
||||
AppBarActions(
|
||||
persistentListOf(
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.action_view_upcoming),
|
||||
icon = Icons.Outlined.CalendarMonth,
|
||||
onClick = onCalendarClicked,
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.action_update_library),
|
||||
icon = Icons.Outlined.Refresh,
|
||||
|
||||
@@ -30,12 +30,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.google.accompanist.web.AccompanistWebViewClient
|
||||
import com.google.accompanist.web.LoadingState
|
||||
import com.google.accompanist.web.WebContent
|
||||
import com.google.accompanist.web.WebView
|
||||
import com.google.accompanist.web.rememberWebViewNavigator
|
||||
import com.google.accompanist.web.rememberWebViewState
|
||||
import com.kevinnzou.web.AccompanistWebViewClient
|
||||
import com.kevinnzou.web.LoadingState
|
||||
import com.kevinnzou.web.WebContent
|
||||
import com.kevinnzou.web.WebView
|
||||
import com.kevinnzou.web.rememberWebViewNavigator
|
||||
import com.kevinnzou.web.rememberWebViewState
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.util.system.setDefaultSettings
|
||||
|
||||
@@ -28,11 +28,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.web.AccompanistWebViewClient
|
||||
import com.google.accompanist.web.LoadingState
|
||||
import com.google.accompanist.web.WebView
|
||||
import com.google.accompanist.web.rememberWebViewNavigator
|
||||
import com.google.accompanist.web.rememberWebViewState
|
||||
import com.kevinnzou.web.AccompanistWebViewClient
|
||||
import com.kevinnzou.web.LoadingState
|
||||
import com.kevinnzou.web.WebView
|
||||
import com.kevinnzou.web.rememberWebViewNavigator
|
||||
import com.kevinnzou.web.rememberWebViewState
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.WarningBanner
|
||||
|
||||
@@ -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
|
||||
@@ -40,6 +38,7 @@ import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
|
||||
import eu.kanade.tachiyomi.crash.CrashActivity
|
||||
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
|
||||
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher
|
||||
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
|
||||
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
|
||||
import eu.kanade.tachiyomi.data.coil.MangaKeyer
|
||||
@@ -72,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
|
||||
@@ -175,25 +178,43 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
||||
) {
|
||||
SyncDataJob.startNow(this@App)
|
||||
}
|
||||
|
||||
initializeMigrator()
|
||||
}
|
||||
|
||||
private fun initializeMigrator() {
|
||||
val preferenceStore = Injekt.get<PreferenceStore>()
|
||||
// SY -->
|
||||
val preference = preferenceStore.getInt(Preference.appStateKey("eh_last_version_code"), 0)
|
||||
// SY <--
|
||||
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
|
||||
Migrator.initialize(
|
||||
old = preference.get(),
|
||||
new = BuildConfig.VERSION_CODE,
|
||||
migrations = migrations,
|
||||
onMigrationComplete = {
|
||||
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
|
||||
preference.set(BuildConfig.VERSION_CODE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context): ImageLoader {
|
||||
return ImageLoader.Builder(this).apply {
|
||||
val callFactoryLazy = lazy { Injekt.get<NetworkHelper>().client }
|
||||
val diskCacheLazy = lazy { CoilDiskCache.get(this@App) }
|
||||
components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
|
||||
add(TachiyomiImageDecoder.Factory())
|
||||
add(MangaCoverFetcher.MangaFactory(callFactoryLazy, diskCacheLazy))
|
||||
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy, diskCacheLazy))
|
||||
add(MangaCoverFetcher.MangaFactory(callFactoryLazy))
|
||||
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
|
||||
add(MangaKeyer())
|
||||
add(MangaCoverKeyer())
|
||||
add(BufferedSourceFetcher.Factory())
|
||||
// SY -->
|
||||
add(PagePreviewKeyer())
|
||||
add(PagePreviewFetcher.Factory(callFactoryLazy, diskCacheLazy))
|
||||
add(PagePreviewFetcher.Factory(callFactoryLazy))
|
||||
// SY <--
|
||||
}
|
||||
diskCache(diskCacheLazy::value)
|
||||
crossfade((300 * this@App.animatorDurationScale).toInt())
|
||||
allowRgb565(DeviceUtil.isLowRamDevice(this@App))
|
||||
if (networkPreferences.verboseLogging().get()) logger(DebugLogger())
|
||||
@@ -349,24 +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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.preference.TriState
|
||||
import tachiyomi.core.common.preference.getAndSet
|
||||
import tachiyomi.core.common.preference.getEnum
|
||||
import tachiyomi.core.common.preference.minusAssign
|
||||
import tachiyomi.core.common.preference.plusAssign
|
||||
import tachiyomi.domain.backup.service.BackupPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.File
|
||||
|
||||
object Migrations {
|
||||
|
||||
// TODO NATIVE TACHIYOMI MIGRATIONS ARE FUCKED UP DUE TO DIFFERING VERSION NUMBERS
|
||||
|
||||
/**
|
||||
* Performs a migration when the application is updated.
|
||||
*
|
||||
* @return true if a migration is performed, false otherwise.
|
||||
*/
|
||||
fun upgrade(
|
||||
context: Context,
|
||||
preferenceStore: PreferenceStore,
|
||||
basePreferences: BasePreferences,
|
||||
uiPreferences: UiPreferences,
|
||||
networkPreferences: NetworkPreferences,
|
||||
sourcePreferences: SourcePreferences,
|
||||
securityPreferences: SecurityPreferences,
|
||||
libraryPreferences: LibraryPreferences,
|
||||
readerPreferences: ReaderPreferences,
|
||||
backupPreferences: BackupPreferences,
|
||||
trackerManager: TrackerManager,
|
||||
): Boolean {
|
||||
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
|
||||
val oldVersion = lastVersionCode.get()
|
||||
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||
lastVersionCode.set(BuildConfig.VERSION_CODE)
|
||||
|
||||
// Always set up background tasks to ensure they're running
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
BackupCreateJob.setupTask(context)
|
||||
|
||||
// Fresh install
|
||||
if (oldVersion == 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
if (oldVersion < 15) {
|
||||
// Delete internal chapter cache dir.
|
||||
File(context.cacheDir, "chapter_disk_cache").deleteRecursively()
|
||||
}
|
||||
if (oldVersion < 19) {
|
||||
// Move covers to external files dir.
|
||||
val oldDir = File(context.externalCacheDir, "cover_disk_cache")
|
||||
if (oldDir.exists()) {
|
||||
val destDir = context.getExternalFilesDir("covers")
|
||||
if (destDir != null) {
|
||||
oldDir.listFiles()?.forEach {
|
||||
it.renameTo(File(destDir, it.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 26) {
|
||||
// Delete external chapter cache dir.
|
||||
val extCache = context.externalCacheDir
|
||||
if (extCache != null) {
|
||||
val chapterCache = File(extCache, "chapter_disk_cache")
|
||||
if (chapterCache.exists()) {
|
||||
chapterCache.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 44) {
|
||||
// Reset sorting preference if using removed sort by source
|
||||
val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0)
|
||||
|
||||
if (oldSortingMode == 5) { // SOURCE = 5
|
||||
prefs.edit {
|
||||
putInt(libraryPreferences.sortingMode().key(), 0) // ALPHABETICAL = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 52) {
|
||||
// Migrate library filters to tri-state versions
|
||||
fun convertBooleanPrefToTriState(key: String): Int {
|
||||
val oldPrefValue = prefs.getBoolean(key, false)
|
||||
return if (oldPrefValue) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
prefs.edit {
|
||||
putInt(
|
||||
libraryPreferences.filterDownloaded().key(),
|
||||
convertBooleanPrefToTriState("pref_filter_downloaded_key"),
|
||||
)
|
||||
remove("pref_filter_downloaded_key")
|
||||
|
||||
putInt(
|
||||
libraryPreferences.filterUnread().key(),
|
||||
convertBooleanPrefToTriState("pref_filter_unread_key"),
|
||||
)
|
||||
remove("pref_filter_unread_key")
|
||||
|
||||
putInt(
|
||||
libraryPreferences.filterCompleted().key(),
|
||||
convertBooleanPrefToTriState("pref_filter_completed_key"),
|
||||
)
|
||||
remove("pref_filter_completed_key")
|
||||
}
|
||||
}
|
||||
if (oldVersion < 54) {
|
||||
// Force MAL log out due to login flow change
|
||||
// v52: switched from scraping to WebView
|
||||
// v53: switched from WebView to OAuth
|
||||
if (trackerManager.myAnimeList.isLoggedIn) {
|
||||
trackerManager.myAnimeList.logout()
|
||||
context.toast(MR.strings.myanimelist_relogin)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 57) {
|
||||
// Migrate DNS over HTTPS setting
|
||||
val wasDohEnabled = prefs.getBoolean("enable_doh", false)
|
||||
if (wasDohEnabled) {
|
||||
prefs.edit {
|
||||
putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE)
|
||||
remove("enable_doh")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 59) {
|
||||
// Reset rotation to Free after replacing Lock
|
||||
if (prefs.contains("pref_rotation_type_key")) {
|
||||
prefs.edit {
|
||||
putInt("pref_rotation_type_key", 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 60) {
|
||||
// Migrate Rotation and Viewer values to default values for viewer_flags
|
||||
val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) {
|
||||
1 -> ReaderOrientation.FREE.flagValue
|
||||
2 -> ReaderOrientation.PORTRAIT.flagValue
|
||||
3 -> ReaderOrientation.LANDSCAPE.flagValue
|
||||
4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue
|
||||
5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue
|
||||
else -> ReaderOrientation.FREE.flagValue
|
||||
}
|
||||
|
||||
// Reading mode flag and prefValue is the same value
|
||||
val newReadingMode = prefs.getInt("pref_default_viewer_key", 1)
|
||||
|
||||
prefs.edit {
|
||||
putInt("pref_default_orientation_type_key", newOrientation)
|
||||
remove("pref_rotation_type_key")
|
||||
putInt("pref_default_reading_mode_key", newReadingMode)
|
||||
remove("pref_default_viewer_key")
|
||||
}
|
||||
}
|
||||
if (oldVersion < 61) {
|
||||
// Handle removed every 1 or 2 hour library updates
|
||||
val updateInterval = libraryPreferences.autoUpdateInterval().get()
|
||||
if (updateInterval == 1 || updateInterval == 2) {
|
||||
libraryPreferences.autoUpdateInterval().set(3)
|
||||
LibraryUpdateJob.setupTask(context, 3)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 64) {
|
||||
val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0)
|
||||
val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val newSortingMode = when (oldSortingMode) {
|
||||
0 -> "ALPHABETICAL"
|
||||
1 -> "LAST_READ"
|
||||
2 -> "LAST_CHECKED"
|
||||
3 -> "UNREAD"
|
||||
4 -> "TOTAL_CHAPTERS"
|
||||
6 -> "LATEST_CHAPTER"
|
||||
8 -> "DATE_FETCHED"
|
||||
7 -> "DATE_ADDED"
|
||||
else -> "ALPHABETICAL"
|
||||
}
|
||||
|
||||
val newSortingDirection = when (oldSortingDirection) {
|
||||
true -> "ASCENDING"
|
||||
else -> "DESCENDING"
|
||||
}
|
||||
|
||||
prefs.edit(commit = true) {
|
||||
remove(libraryPreferences.sortingMode().key())
|
||||
remove("library_sorting_ascending")
|
||||
}
|
||||
|
||||
prefs.edit {
|
||||
putString(libraryPreferences.sortingMode().key(), newSortingMode)
|
||||
putString("library_sorting_ascending", newSortingDirection)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 70) {
|
||||
if (sourcePreferences.enabledLanguages().isSet()) {
|
||||
sourcePreferences.enabledLanguages() += "all"
|
||||
}
|
||||
}
|
||||
if (oldVersion < 71) {
|
||||
// Handle removed every 3, 4, 6, and 8 hour library updates
|
||||
val updateInterval = libraryPreferences.autoUpdateInterval().get()
|
||||
if (updateInterval in listOf(3, 4, 6, 8)) {
|
||||
libraryPreferences.autoUpdateInterval().set(12)
|
||||
LibraryUpdateJob.setupTask(context, 12)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 72) {
|
||||
val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true)
|
||||
if (!oldUpdateOngoingOnly) {
|
||||
libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED
|
||||
}
|
||||
}
|
||||
if (oldVersion < 75) {
|
||||
val oldSecureScreen = prefs.getBoolean("secure_screen", false)
|
||||
if (oldSecureScreen) {
|
||||
securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS)
|
||||
}
|
||||
if (
|
||||
DeviceUtil.isMiui &&
|
||||
basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller.PACKAGEINSTALLER
|
||||
) {
|
||||
basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 77) {
|
||||
val oldReaderTap = prefs.getBoolean("reader_tap", false)
|
||||
if (!oldReaderTap) {
|
||||
readerPreferences.navigationModePager().set(5)
|
||||
readerPreferences.navigationModeWebtoon().set(5)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 81) {
|
||||
// Handle renamed enum values
|
||||
prefs.edit {
|
||||
val newSortingMode = when (
|
||||
val oldSortingMode = prefs.getString(
|
||||
libraryPreferences.sortingMode().key(),
|
||||
"ALPHABETICAL",
|
||||
)
|
||||
) {
|
||||
"LAST_CHECKED" -> "LAST_MANGA_UPDATE"
|
||||
"UNREAD" -> "UNREAD_COUNT"
|
||||
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
|
||||
else -> oldSortingMode
|
||||
}
|
||||
putString(libraryPreferences.sortingMode().key(), newSortingMode)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 82) {
|
||||
prefs.edit {
|
||||
val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit
|
||||
val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!!
|
||||
putString(libraryPreferences.sortingMode().key(), "$sort,$direction")
|
||||
remove("library_sorting_ascending")
|
||||
}
|
||||
}
|
||||
if (oldVersion < 84) {
|
||||
if (backupPreferences.backupInterval().get() == 0) {
|
||||
backupPreferences.backupInterval().set(12)
|
||||
BackupCreateJob.setupTask(context)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 85) {
|
||||
val preferences = listOf(
|
||||
libraryPreferences.filterChapterByRead(),
|
||||
libraryPreferences.filterChapterByDownloaded(),
|
||||
libraryPreferences.filterChapterByBookmarked(),
|
||||
libraryPreferences.sortChapterBySourceOrNumber(),
|
||||
libraryPreferences.displayChapterByNameOrNumber(),
|
||||
libraryPreferences.sortChapterByAscendingOrDescending(),
|
||||
)
|
||||
|
||||
prefs.edit {
|
||||
preferences.forEach { preference ->
|
||||
val key = preference.key()
|
||||
val value = prefs.getInt(key, Int.MIN_VALUE)
|
||||
if (value == Int.MIN_VALUE) return@forEach
|
||||
remove(key)
|
||||
putLong(key, value.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 86) {
|
||||
if (uiPreferences.themeMode().isSet()) {
|
||||
prefs.edit {
|
||||
val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit
|
||||
putString(uiPreferences.themeMode().key(), themeMode.uppercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 92) {
|
||||
val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE)
|
||||
trackingQueuePref.all.forEach {
|
||||
val (_, lastChapterRead) = it.value.toString().split(":")
|
||||
trackingQueuePref.edit {
|
||||
remove(it.key)
|
||||
putFloat(it.key, lastChapterRead.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 96) {
|
||||
LibraryUpdateJob.cancelAllWorks(context)
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
}
|
||||
if (oldVersion < 97) {
|
||||
// Removed background jobs
|
||||
context.workManager.cancelAllWorkByTag("UpdateChecker")
|
||||
context.workManager.cancelAllWorkByTag("ExtensionUpdate")
|
||||
prefs.edit {
|
||||
remove("automatic_ext_updates")
|
||||
}
|
||||
}
|
||||
if (oldVersion < 99) {
|
||||
val prefKeys = listOf(
|
||||
"pref_filter_library_downloaded",
|
||||
"pref_filter_library_unread",
|
||||
"pref_filter_library_started",
|
||||
"pref_filter_library_bookmarked",
|
||||
"pref_filter_library_completed",
|
||||
) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" }
|
||||
|
||||
prefKeys.forEach { key ->
|
||||
val pref = preferenceStore.getInt(key, 0)
|
||||
prefs.edit {
|
||||
remove(key)
|
||||
|
||||
val newValue = when (pref.get()) {
|
||||
1 -> TriState.ENABLED_IS
|
||||
2 -> TriState.ENABLED_NOT
|
||||
else -> TriState.DISABLED
|
||||
}
|
||||
|
||||
preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 105) {
|
||||
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
|
||||
if (pref.isSet() && "battery_not_low" in pref.get()) {
|
||||
pref.getAndSet { it - "battery_not_low" }
|
||||
}
|
||||
}
|
||||
if (oldVersion < 106) {
|
||||
val pref = preferenceStore.getInt("relative_time", 7)
|
||||
if (pref.get() == 0) {
|
||||
uiPreferences.relativeTime().set(false)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 113) {
|
||||
val prefsToReplace = listOf(
|
||||
"pref_download_only",
|
||||
"incognito_mode",
|
||||
"last_catalogue_source",
|
||||
"trusted_signatures",
|
||||
"last_app_closed",
|
||||
"library_update_last_timestamp",
|
||||
"library_unseen_updates_count",
|
||||
"last_used_category",
|
||||
"last_app_check",
|
||||
"last_ext_check",
|
||||
"last_version_code",
|
||||
"storage_dir",
|
||||
)
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key in prefsToReplace },
|
||||
newKey = { Preference.appStateKey(it) },
|
||||
)
|
||||
|
||||
// Deleting old download cache index files, but might as well clear it all out
|
||||
context.cacheDir.deleteRecursively()
|
||||
}
|
||||
if (oldVersion < 114) {
|
||||
sourcePreferences.extensionRepos().getAndSet {
|
||||
it.map { repo -> "https://raw.githubusercontent.com/$repo/repo" }.toSet()
|
||||
}
|
||||
}
|
||||
if (oldVersion < 116) {
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") },
|
||||
newKey = { Preference.privateKey(it) },
|
||||
)
|
||||
}
|
||||
if (oldVersion < 117) {
|
||||
prefs.edit {
|
||||
remove(Preference.appStateKey("trusted_signatures"))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun replacePreferences(
|
||||
preferenceStore: PreferenceStore,
|
||||
filterPredicate: (Map.Entry<String, Any?>) -> Boolean,
|
||||
newKey: (String) -> String,
|
||||
) {
|
||||
preferenceStore.getAll()
|
||||
.filter(filterPredicate)
|
||||
.forEach { (key, value) ->
|
||||
when (value) {
|
||||
is Int -> {
|
||||
preferenceStore.getInt(newKey(key)).set(value)
|
||||
preferenceStore.getInt(key).delete()
|
||||
}
|
||||
is Long -> {
|
||||
preferenceStore.getLong(newKey(key)).set(value)
|
||||
preferenceStore.getLong(key).delete()
|
||||
}
|
||||
is Float -> {
|
||||
preferenceStore.getFloat(newKey(key)).set(value)
|
||||
preferenceStore.getFloat(key).delete()
|
||||
}
|
||||
is String -> {
|
||||
preferenceStore.getString(newKey(key)).set(value)
|
||||
preferenceStore.getString(key).delete()
|
||||
}
|
||||
is Boolean -> {
|
||||
preferenceStore.getBoolean(newKey(key)).set(value)
|
||||
preferenceStore.getBoolean(key).delete()
|
||||
}
|
||||
is Set<*> -> (value as? Set<String>)?.let {
|
||||
preferenceStore.getStringSet(newKey(key)).set(value)
|
||||
preferenceStore.getStringSet(key).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -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)
|
||||
|
||||
+5
-4
@@ -313,7 +313,7 @@ class MangaRestorer(
|
||||
restoreCategories(manga, categories, backupCategories)
|
||||
restoreChapters(manga, chapters)
|
||||
restoreTracking(manga, tracks)
|
||||
restoreHistory(history)
|
||||
restoreHistory(manga, history)
|
||||
restoreExcludedScanlators(manga, excludedScanlators)
|
||||
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
|
||||
// SY -->
|
||||
@@ -359,13 +359,14 @@ class MangaRestorer(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreHistory(backupHistory: List<BackupHistory>) {
|
||||
private suspend fun restoreHistory(manga: Manga, backupHistory: List<BackupHistory>) {
|
||||
val toUpdate = backupHistory.mapNotNull { history ->
|
||||
val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(history.url) }
|
||||
val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(manga.id, history.url) }
|
||||
val item = history.getHistoryImpl()
|
||||
|
||||
if (dbHistory == null) {
|
||||
val chapter = handler.awaitOneOrNull { chaptersQueries.getChapterByUrl(history.url) }
|
||||
val chapter = handler.awaitList { chaptersQueries.getChapterByUrl(history.url) }
|
||||
.find { it.manga_id == manga.id }
|
||||
return@mapNotNull if (chapter == null) {
|
||||
// Chapter doesn't exist; skip
|
||||
null
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import coil3.ImageLoader
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import okio.BufferedSource
|
||||
|
||||
class BufferedSourceFetcher(
|
||||
private val data: BufferedSource,
|
||||
private val options: Options,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(
|
||||
source = data,
|
||||
fileSystem = options.fileSystem,
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}
|
||||
|
||||
class Factory : Fetcher.Factory<BufferedSource> {
|
||||
|
||||
override fun create(
|
||||
data: BufferedSource,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher {
|
||||
return BufferedSourceFetcher(data, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import java.io.IOException
|
||||
* Available request parameter:
|
||||
* - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class MangaCoverFetcher(
|
||||
private val url: String?,
|
||||
private val isLibraryManga: Boolean,
|
||||
@@ -55,7 +56,7 @@ class MangaCoverFetcher(
|
||||
private val diskCacheKeyLazy: Lazy<String>,
|
||||
private val sourceLazy: Lazy<HttpSource?>,
|
||||
private val callFactoryLazy: Lazy<Call.Factory>,
|
||||
private val diskCacheLazy: Lazy<DiskCache>,
|
||||
private val imageLoader: ImageLoader,
|
||||
) : Fetcher {
|
||||
|
||||
private val diskCacheKey: String
|
||||
@@ -207,7 +208,7 @@ class MangaCoverFetcher(
|
||||
private fun moveSnapshotToCoverCache(snapshot: DiskCache.Snapshot, cacheFile: File?): File? {
|
||||
if (cacheFile == null) return null
|
||||
return try {
|
||||
diskCacheLazy.value.run {
|
||||
imageLoader.diskCache?.run {
|
||||
fileSystem.source(snapshot.data).use { input ->
|
||||
writeSourceToCoverCache(input, cacheFile)
|
||||
}
|
||||
@@ -248,7 +249,7 @@ class MangaCoverFetcher(
|
||||
|
||||
private fun readFromDiskCache(): DiskCache.Snapshot? {
|
||||
return if (options.diskCachePolicy.readEnabled) {
|
||||
diskCacheLazy.value.openSnapshot(diskCacheKey)
|
||||
imageLoader.diskCache?.openSnapshot(diskCacheKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -257,9 +258,10 @@ class MangaCoverFetcher(
|
||||
private fun writeToDiskCache(
|
||||
response: Response,
|
||||
): DiskCache.Snapshot? {
|
||||
val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null
|
||||
val diskCache = imageLoader.diskCache
|
||||
val editor = diskCache?.openEditor(diskCacheKey) ?: return null
|
||||
try {
|
||||
diskCacheLazy.value.fileSystem.write(editor.data) {
|
||||
diskCache.fileSystem.write(editor.data) {
|
||||
response.body.source().readAll(this)
|
||||
}
|
||||
return editor.commitAndOpenSnapshot()
|
||||
@@ -299,7 +301,6 @@ class MangaCoverFetcher(
|
||||
|
||||
class MangaFactory(
|
||||
private val callFactoryLazy: Lazy<Call.Factory>,
|
||||
private val diskCacheLazy: Lazy<DiskCache>,
|
||||
) : Fetcher.Factory<Manga> {
|
||||
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
@@ -312,17 +313,16 @@ class MangaCoverFetcher(
|
||||
options = options,
|
||||
coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) },
|
||||
customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) },
|
||||
diskCacheKeyLazy = lazy { MangaKeyer().key(data, options) },
|
||||
diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
|
||||
sourceLazy = lazy { sourceManager.get(data.source) as? HttpSource },
|
||||
callFactoryLazy = callFactoryLazy,
|
||||
diskCacheLazy = diskCacheLazy,
|
||||
imageLoader = imageLoader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MangaCoverFactory(
|
||||
private val callFactoryLazy: Lazy<Call.Factory>,
|
||||
private val diskCacheLazy: Lazy<DiskCache>,
|
||||
) : Fetcher.Factory<MangaCover> {
|
||||
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
@@ -335,10 +335,10 @@ class MangaCoverFetcher(
|
||||
options = options,
|
||||
coverFileLazy = lazy { coverCache.getCoverFile(data.url) },
|
||||
customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.mangaId) },
|
||||
diskCacheKeyLazy = lazy { MangaCoverKeyer().key(data, options) },
|
||||
diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
|
||||
sourceLazy = lazy { sourceManager.get(data.sourceId) as? HttpSource },
|
||||
callFactoryLazy = callFactoryLazy,
|
||||
diskCacheLazy = diskCacheLazy,
|
||||
imageLoader = imageLoader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import java.io.IOException
|
||||
* Disk caching is handled by [PagePreviewCache], otherwise
|
||||
* handled by Coil's [DiskCache].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class PagePreviewFetcher(
|
||||
private val page: PagePreview,
|
||||
private val options: Options,
|
||||
@@ -43,7 +44,7 @@ class PagePreviewFetcher(
|
||||
private val diskCacheKeyLazy: Lazy<String>,
|
||||
private val sourceLazy: Lazy<PagePreviewSource?>,
|
||||
private val callFactoryLazy: Lazy<Call.Factory>,
|
||||
private val diskCacheLazy: Lazy<DiskCache>,
|
||||
private val imageLoader: ImageLoader,
|
||||
) : Fetcher {
|
||||
|
||||
private val diskCacheKey: String
|
||||
@@ -164,7 +165,7 @@ class PagePreviewFetcher(
|
||||
|
||||
private fun moveSnapshotToPagePreviewCache(snapshot: DiskCache.Snapshot): File? {
|
||||
return try {
|
||||
diskCacheLazy.value.run {
|
||||
imageLoader.diskCache?.run {
|
||||
fileSystem.source(snapshot.data).use { input ->
|
||||
writeSourceToPagePreviewCache(input)
|
||||
}
|
||||
@@ -203,15 +204,16 @@ class PagePreviewFetcher(
|
||||
}
|
||||
|
||||
private fun readFromDiskCache(): DiskCache.Snapshot? {
|
||||
return if (options.diskCachePolicy.readEnabled) diskCacheLazy.value.openSnapshot(diskCacheKey) else null
|
||||
return if (options.diskCachePolicy.readEnabled) imageLoader.diskCache?.openSnapshot(diskCacheKey) else null
|
||||
}
|
||||
|
||||
private fun writeToDiskCache(
|
||||
response: Response,
|
||||
): DiskCache.Snapshot? {
|
||||
val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null
|
||||
val diskCache = imageLoader.diskCache
|
||||
val editor = diskCache?.openEditor(diskCacheKey) ?: return null
|
||||
try {
|
||||
diskCacheLazy.value.fileSystem.write(editor.data) {
|
||||
diskCache.fileSystem.write(editor.data) {
|
||||
response.body.source().readAll(this)
|
||||
}
|
||||
return editor.commitAndOpenSnapshot()
|
||||
@@ -235,7 +237,6 @@ class PagePreviewFetcher(
|
||||
|
||||
class Factory(
|
||||
private val callFactoryLazy: Lazy<Call.Factory>,
|
||||
private val diskCacheLazy: Lazy<DiskCache>,
|
||||
) : Fetcher.Factory<PagePreview> {
|
||||
|
||||
private val pagePreviewCache: PagePreviewCache by injectLazy()
|
||||
@@ -248,10 +249,10 @@ class PagePreviewFetcher(
|
||||
pagePreviewFile = { pagePreviewCache.getImageFile(data.imageUrl) },
|
||||
isInCache = { pagePreviewCache.isImageInCache(data.imageUrl) },
|
||||
writeToCache = { pagePreviewCache.putImageToCache(data.imageUrl, it) },
|
||||
diskCacheKeyLazy = lazy { PagePreviewKeyer().key(data, options) },
|
||||
diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
|
||||
sourceLazy = lazy { sourceManager.get(data.source) as? PagePreviewSource },
|
||||
callFactoryLazy = callFactoryLazy,
|
||||
diskCacheLazy = diskCacheLazy,
|
||||
imageLoader = imageLoader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil3.ImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.bitmapConfig
|
||||
import eu.kanade.tachiyomi.util.storage.CbzCrypto
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import net.lingala.zip4j.ZipFile
|
||||
import net.lingala.zip4j.model.FileHeader
|
||||
import okio.BufferedSource
|
||||
@@ -39,29 +41,58 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
|
||||
}
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
zip4j.use { zipFile ->
|
||||
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream())
|
||||
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream(), options.cropBorders, displayProfile)
|
||||
}
|
||||
}
|
||||
// SY <--
|
||||
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
val bitmap = decoder.decode(rgb565 = options.allowRgb565)
|
||||
val srcWidth = decoder.width
|
||||
val srcHeight = decoder.height
|
||||
|
||||
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
|
||||
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = srcWidth,
|
||||
srcHeight = srcHeight,
|
||||
dstWidth = dstWidth,
|
||||
dstHeight = dstHeight,
|
||||
scale = options.scale,
|
||||
)
|
||||
|
||||
var bitmap = decoder.decode(sampleSize = sampleSize)
|
||||
decoder.recycle()
|
||||
|
||||
check(bitmap != null) { "Failed to decode image" }
|
||||
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
options.bitmapConfig == Bitmap.Config.HARDWARE &&
|
||||
maxOf(bitmap.width, bitmap.height) <= GLUtil.maxTextureSize
|
||||
) {
|
||||
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
|
||||
if (hwBitmap != null) {
|
||||
bitmap.recycle()
|
||||
bitmap = hwBitmap
|
||||
}
|
||||
}
|
||||
|
||||
return DecodeResult(
|
||||
image = bitmap.asCoilImage(),
|
||||
isSampled = false,
|
||||
isSampled = sampleSize > 1,
|
||||
)
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
|
||||
if (!isApplicable(result.source.source())) return null
|
||||
return TachiyomiImageDecoder(result.source, options)
|
||||
return if (options.customDecoder || isApplicable(result.source.source())) {
|
||||
TachiyomiImageDecoder(result.source, options)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isApplicable(source: BufferedSource): Boolean {
|
||||
@@ -84,4 +115,8 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
|
||||
|
||||
override fun hashCode() = javaClass.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var displayProfile: ByteArray? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import coil3.Extras
|
||||
import coil3.getExtra
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.Options
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.isOriginal
|
||||
import coil3.size.pxOrElse
|
||||
|
||||
internal inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
|
||||
return if (isOriginal) original() else width.toPx(scale)
|
||||
}
|
||||
|
||||
internal inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
|
||||
return if (isOriginal) original() else height.toPx(scale)
|
||||
}
|
||||
|
||||
internal fun Dimension.toPx(scale: Scale): Int = pxOrElse {
|
||||
when (scale) {
|
||||
Scale.FILL -> Int.MIN_VALUE
|
||||
Scale.FIT -> Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.cropBorders(enable: Boolean) = apply {
|
||||
extras[cropBordersKey] = enable
|
||||
}
|
||||
|
||||
val Options.cropBorders: Boolean
|
||||
get() = getExtra(cropBordersKey)
|
||||
|
||||
private val cropBordersKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.customDecoder(enable: Boolean) = apply {
|
||||
extras[customDecoderKey] = enable
|
||||
}
|
||||
|
||||
val Options.customDecoder: Boolean
|
||||
get() = getExtra(customDecoderKey)
|
||||
|
||||
private val customDecoderKey = Extras.Key(default = false)
|
||||
@@ -475,7 +475,7 @@ class DownloadManager(
|
||||
|
||||
fun renameMangaDir(oldTitle: String, newTitle: String, source: Long) {
|
||||
val sourceDir = provider.findSourceDir(sourceManager.getOrStub(source)) ?: return
|
||||
val mangaDir = sourceDir.findFile(DiskUtil.buildValidFilename(oldTitle), true) ?: return
|
||||
val mangaDir = sourceDir.findFile(DiskUtil.buildValidFilename(oldTitle)) ?: return
|
||||
mangaDir.renameTo(DiskUtil.buildValidFilename(newTitle))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class DownloadProvider(
|
||||
* @param source the source to query.
|
||||
*/
|
||||
fun findSourceDir(source: Source): UniFile? {
|
||||
return downloadsDir?.findFile(getSourceDirName(source), true)
|
||||
return downloadsDir?.findFile(getSourceDirName(source))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ class DownloadProvider(
|
||||
*/
|
||||
fun findMangaDir(mangaTitle: String, source: Source): UniFile? {
|
||||
val sourceDir = findSourceDir(source)
|
||||
return sourceDir?.findFile(getMangaDirName(mangaTitle), true)
|
||||
return sourceDir?.findFile(getMangaDirName(mangaTitle))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +82,7 @@ class DownloadProvider(
|
||||
fun findChapterDir(chapterName: String, chapterScanlator: String?, mangaTitle: String, source: Source): UniFile? {
|
||||
val mangaDir = findMangaDir(mangaTitle, source)
|
||||
return getValidChapterDirNames(chapterName, chapterScanlator).asSequence()
|
||||
.mapNotNull { mangaDir?.findFile(it, true) }
|
||||
.mapNotNull { mangaDir?.findFile(it) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class DownloadProvider(
|
||||
val mangaDir = findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source) ?: return null to emptyList()
|
||||
return mangaDir to chapters.mapNotNull { chapter ->
|
||||
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence()
|
||||
.mapNotNull { mangaDir.findFile(it, true) }
|
||||
.mapNotNull { mangaDir.findFile(it) }
|
||||
.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.util.storage.CbzCrypto
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
|
||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||
import exh.source.isEhBasedSource
|
||||
import exh.util.DataSaver
|
||||
import exh.util.DataSaver.Companion.getImage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -511,6 +512,9 @@ class Downloader(
|
||||
.retryWhen { _, attempt ->
|
||||
if (attempt < 3) {
|
||||
delay((2L shl attempt.toInt()) * 1000)
|
||||
if (source.isEhBasedSource()) {
|
||||
page.imageUrl = source.getImageUrl(page)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -709,7 +713,7 @@ class Downloader(
|
||||
)
|
||||
|
||||
// Remove the old file
|
||||
dir.findFile(COMIC_INFO_FILE, true)?.delete()
|
||||
dir.findFile(COMIC_INFO_FILE)?.delete()
|
||||
dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use {
|
||||
val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo)
|
||||
it.write(comicInfoString.toByteArray())
|
||||
|
||||
@@ -823,7 +823,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
// Always sync the data before library update if syncing is enabled.
|
||||
if (syncPreferences.isSyncEnabled()) {
|
||||
// Check if SyncDataJob is already running
|
||||
if (wm.isRunning(SyncDataJob.TAG_MANUAL)) {
|
||||
if (SyncDataJob.isRunning(context)) {
|
||||
// SyncDataJob is already running
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class ImageSaver(
|
||||
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
|
||||
MediaStore.Images.Media.DISPLAY_NAME to image.name,
|
||||
MediaStore.Images.Media.MIME_TYPE to type.mime,
|
||||
MediaStore.Images.Media.DATE_MODIFIED to Instant.now().toEpochMilli(),
|
||||
MediaStore.Images.Media.DATE_MODIFIED to Instant.now().epochSecond,
|
||||
)
|
||||
|
||||
val picture = findUriOrDefault(relativePath, filename) {
|
||||
|
||||
@@ -9,11 +9,14 @@ import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import eu.kanade.domain.sync.SyncPreferences
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.util.system.cancelNotification
|
||||
import eu.kanade.tachiyomi.util.system.isRunning
|
||||
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
||||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -27,12 +30,15 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
|
||||
private val notifier = SyncNotifier(context)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
setForeground(getForegroundInfo())
|
||||
} catch (e: IllegalStateException) {
|
||||
logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" }
|
||||
if (tags.contains(TAG_AUTO)) {
|
||||
// Find a running manual worker. If exists, try again later
|
||||
if (context.workManager.isRunning(TAG_MANUAL)) {
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
setForegroundSafely()
|
||||
|
||||
return try {
|
||||
SyncManager(context).syncData()
|
||||
Result.success()
|
||||
@@ -62,10 +68,8 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
|
||||
private const val TAG_AUTO = "$TAG_JOB:auto"
|
||||
const val TAG_MANUAL = "$TAG_JOB:manual"
|
||||
|
||||
private val jobTagList = listOf(TAG_AUTO, TAG_MANUAL)
|
||||
|
||||
fun isAnyJobRunning(context: Context): Boolean {
|
||||
return jobTagList.any { context.workManager.isRunning(it) }
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return context.workManager.isRunning(TAG_JOB)
|
||||
}
|
||||
|
||||
fun setupTask(context: Context, prefInterval: Int? = null) {
|
||||
@@ -79,6 +83,7 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
|
||||
10,
|
||||
TimeUnit.MINUTES,
|
||||
)
|
||||
.addTag(TAG_JOB)
|
||||
.addTag(TAG_AUTO)
|
||||
.build()
|
||||
|
||||
@@ -89,14 +94,33 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
|
||||
}
|
||||
|
||||
fun startNow(context: Context) {
|
||||
val wm = context.workManager
|
||||
if (wm.isRunning(TAG_JOB)) {
|
||||
// Already running either as a scheduled or manual job
|
||||
return
|
||||
}
|
||||
val request = OneTimeWorkRequestBuilder<SyncDataJob>()
|
||||
.addTag(TAG_JOB)
|
||||
.addTag(TAG_MANUAL)
|
||||
.build()
|
||||
context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.workManager.cancelUniqueWork(TAG_MANUAL)
|
||||
val wm = context.workManager
|
||||
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG_JOB, TAG_AUTO, TAG_MANUAL))
|
||||
.addStates(listOf(WorkInfo.State.RUNNING))
|
||||
.build()
|
||||
wm.getWorkInfos(workQuery).get()
|
||||
// Should only return one work but just in case
|
||||
.forEach {
|
||||
wm.cancelWorkById(it.id)
|
||||
|
||||
// Re-enqueue cancelled scheduled work
|
||||
if (it.tags.contains(TAG_AUTO)) {
|
||||
setupTask(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,14 @@ class SyncManager(
|
||||
appSettings = syncOptions.appSettings,
|
||||
sourceSettings = syncOptions.sourceSettings,
|
||||
privateSettings = syncOptions.privateSettings,
|
||||
|
||||
// SY -->
|
||||
customInfo = syncOptions.customInfo,
|
||||
readEntries = syncOptions.readEntries,
|
||||
// SY <--
|
||||
)
|
||||
|
||||
logcat(LogPriority.DEBUG) { "Begin create backup" }
|
||||
val backup = Backup(
|
||||
backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
|
||||
backupCategories = backupCreator.backupCategories(backupOptions),
|
||||
@@ -100,9 +107,11 @@ class SyncManager(
|
||||
backupSavedSearches = backupCreator.backupSavedSearches(),
|
||||
// SY <--
|
||||
)
|
||||
logcat(LogPriority.DEBUG) { "End create backup" }
|
||||
|
||||
// Create the SyncData object
|
||||
val syncData = SyncData(
|
||||
deviceId = syncPreferences.uniqueDeviceID(),
|
||||
backup = backup,
|
||||
)
|
||||
|
||||
@@ -129,8 +138,22 @@ class SyncManager(
|
||||
|
||||
val remoteBackup = syncService?.doSync(syncData)
|
||||
|
||||
if (remoteBackup == null) {
|
||||
logcat(LogPriority.DEBUG) { "Skip restore due to network issues" }
|
||||
// should we call showSyncError?
|
||||
return
|
||||
}
|
||||
|
||||
if (remoteBackup === syncData.backup){
|
||||
// nothing changed
|
||||
logcat(LogPriority.DEBUG) { "Skip restore due to remote was overwrite from local" }
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
notifier.showSyncSuccess("Sync completed successfully")
|
||||
return
|
||||
}
|
||||
|
||||
// Stop the sync early if the remote backup is null or empty
|
||||
if (remoteBackup?.backupManga?.size == 0) {
|
||||
if (remoteBackup.backupManga?.size == 0) {
|
||||
notifier.showSyncError("No data found on remote server.")
|
||||
return
|
||||
}
|
||||
@@ -143,49 +166,47 @@ class SyncManager(
|
||||
return
|
||||
}
|
||||
|
||||
if (remoteBackup != null) {
|
||||
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
|
||||
updateNonFavorites(nonFavorites)
|
||||
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
|
||||
updateNonFavorites(nonFavorites)
|
||||
|
||||
val newSyncData = backup.copy(
|
||||
backupManga = filteredFavorites,
|
||||
backupCategories = remoteBackup.backupCategories,
|
||||
backupSources = remoteBackup.backupSources,
|
||||
backupPreferences = remoteBackup.backupPreferences,
|
||||
backupSourcePreferences = remoteBackup.backupSourcePreferences,
|
||||
val newSyncData = backup.copy(
|
||||
backupManga = filteredFavorites,
|
||||
backupCategories = remoteBackup.backupCategories,
|
||||
backupSources = remoteBackup.backupSources,
|
||||
backupPreferences = remoteBackup.backupPreferences,
|
||||
backupSourcePreferences = remoteBackup.backupSourcePreferences,
|
||||
|
||||
// SY -->
|
||||
backupSavedSearches = remoteBackup.backupSavedSearches,
|
||||
// SY <--
|
||||
// SY -->
|
||||
backupSavedSearches = remoteBackup.backupSavedSearches,
|
||||
// SY <--
|
||||
)
|
||||
|
||||
// It's local sync no need to restore data. (just update remote data)
|
||||
if (filteredFavorites.isEmpty()) {
|
||||
// update the sync timestamp
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
notifier.showSyncSuccess("Sync completed successfully")
|
||||
return
|
||||
}
|
||||
|
||||
val backupUri = writeSyncDataToCache(context, newSyncData)
|
||||
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
|
||||
if (backupUri != null) {
|
||||
BackupRestoreJob.start(
|
||||
context,
|
||||
backupUri,
|
||||
sync = true,
|
||||
options = RestoreOptions(
|
||||
appSettings = true,
|
||||
sourceSettings = true,
|
||||
library = true,
|
||||
),
|
||||
)
|
||||
|
||||
// It's local sync no need to restore data. (just update remote data)
|
||||
if (filteredFavorites.isEmpty()) {
|
||||
// update the sync timestamp
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
notifier.showSyncSuccess("Sync completed successfully")
|
||||
return
|
||||
}
|
||||
|
||||
val backupUri = writeSyncDataToCache(context, newSyncData)
|
||||
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
|
||||
if (backupUri != null) {
|
||||
BackupRestoreJob.start(
|
||||
context,
|
||||
backupUri,
|
||||
sync = true,
|
||||
options = RestoreOptions(
|
||||
appSettings = true,
|
||||
sourceSettings = true,
|
||||
library = true,
|
||||
),
|
||||
)
|
||||
|
||||
// update the sync timestamp
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
} else {
|
||||
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
|
||||
}
|
||||
// update the sync timestamp
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
} else {
|
||||
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.sync.models
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
|
||||
data class SyncTriggerOptions(
|
||||
val syncOnChapterRead: Boolean = false,
|
||||
@@ -25,22 +25,22 @@ data class SyncTriggerOptions(
|
||||
companion object {
|
||||
val mainOptions = persistentListOf(
|
||||
Entry(
|
||||
label = MR.strings.sync_on_chapter_read,
|
||||
label = SYMR.strings.sync_on_chapter_read,
|
||||
getter = SyncTriggerOptions::syncOnChapterRead,
|
||||
setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_chapter_open,
|
||||
label = SYMR.strings.sync_on_chapter_open,
|
||||
getter = SyncTriggerOptions::syncOnChapterOpen,
|
||||
setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_app_start,
|
||||
label = SYMR.strings.sync_on_app_start,
|
||||
getter = SyncTriggerOptions::syncOnAppStart,
|
||||
setter = { options, enabled -> options.copy(syncOnAppStart = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_app_resume,
|
||||
label = SYMR.strings.sync_on_app_resume,
|
||||
getter = SyncTriggerOptions::syncOnAppResume,
|
||||
setter = { options, enabled -> options.copy(syncOnAppResume = enabled) },
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse
|
||||
import com.google.api.client.http.ByteArrayContent
|
||||
import com.google.api.client.http.InputStreamContent
|
||||
import com.google.api.client.http.javanet.NetHttpTransport
|
||||
import com.google.api.client.json.JsonFactory
|
||||
import com.google.api.client.json.jackson2.JacksonFactory
|
||||
@@ -18,19 +19,24 @@ import com.google.api.services.drive.Drive
|
||||
import com.google.api.services.drive.DriveScopes
|
||||
import com.google.api.services.drive.model.File
|
||||
import eu.kanade.domain.sync.SyncPreferences
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import logcat.LogPriority
|
||||
import logcat.logcat
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.time.Instant
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
@@ -64,11 +70,47 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
|
||||
|
||||
private val googleDriveService = GoogleDriveService(context)
|
||||
|
||||
override suspend fun beforeSync() {
|
||||
override suspend fun doSync(syncData: SyncData): Backup? {
|
||||
beforeSync()
|
||||
|
||||
try {
|
||||
val remoteSData = pullSyncData()
|
||||
|
||||
if (remoteSData != null ){
|
||||
// Get local unique device ID
|
||||
val localDeviceId = syncPreferences.uniqueDeviceID()
|
||||
val lastSyncDeviceId = remoteSData.deviceId
|
||||
|
||||
// Log the device IDs
|
||||
logcat(LogPriority.DEBUG, "SyncService") {
|
||||
"Local device ID: $localDeviceId, Last sync device ID: $lastSyncDeviceId"
|
||||
}
|
||||
|
||||
// check if the last sync was done by the same device if so overwrite the remote data with the local data
|
||||
return if (lastSyncDeviceId == localDeviceId) {
|
||||
pushSyncData(syncData)
|
||||
syncData.backup
|
||||
}else{
|
||||
// Merge the local and remote sync data
|
||||
val mergedSyncData = mergeSyncData(syncData, remoteSData)
|
||||
pushSyncData(mergedSyncData)
|
||||
mergedSyncData.backup
|
||||
}
|
||||
}
|
||||
|
||||
pushSyncData(syncData)
|
||||
return syncData.backup
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, "SyncService") { "Error syncing: ${e.message}" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun beforeSync() {
|
||||
try {
|
||||
googleDriveService.refreshToken()
|
||||
val drive = googleDriveService.driveService
|
||||
?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
|
||||
?: throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
|
||||
|
||||
var backoff = 1000L
|
||||
var retries = 0 // Retry counter
|
||||
@@ -112,21 +154,17 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
|
||||
|
||||
if (retries >= maxRetries) {
|
||||
logcat(LogPriority.ERROR) { "Max retries reached, exiting sync process" }
|
||||
throw Exception(context.stringResource(MR.strings.error_before_sync_gdrive) + ": Max retries reached.")
|
||||
throw Exception(context.stringResource(SYMR.strings.error_before_sync_gdrive) + ": Max retries reached.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, throwable = e) { "Error in GoogleDrive beforeSync" }
|
||||
throw Exception(context.stringResource(MR.strings.error_before_sync_gdrive) + ": ${e.message}", e)
|
||||
throw Exception(context.stringResource(SYMR.strings.error_before_sync_gdrive) + ": ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pullSyncData(): SyncData? {
|
||||
val drive = googleDriveService.driveService
|
||||
|
||||
if (drive == null) {
|
||||
logcat(LogPriority.DEBUG) { "Google Drive service not initialized" }
|
||||
throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
|
||||
}
|
||||
private fun pullSyncData(): SyncData? {
|
||||
val drive = googleDriveService.driveService ?:
|
||||
throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
|
||||
|
||||
val fileList = getAppDataFileList(drive)
|
||||
if (fileList.isEmpty()) {
|
||||
@@ -137,75 +175,53 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
|
||||
val gdriveFileId = fileList[0].id
|
||||
logcat(LogPriority.DEBUG) { "Google Drive File ID: $gdriveFileId" }
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
try {
|
||||
drive.files().get(gdriveFileId).executeMediaAndDownloadTo(outputStream)
|
||||
logcat(LogPriority.DEBUG) { "File downloaded successfully" }
|
||||
drive.files().get(gdriveFileId).executeMediaAsInputStream().use { inputStream ->
|
||||
GZIPInputStream(inputStream).use { gzipInputStream ->
|
||||
return Json.decodeFromStream(SyncData.serializer(), gzipInputStream)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, throwable = e) { "Error downloading file" }
|
||||
return null
|
||||
}
|
||||
|
||||
return withIOContext {
|
||||
try {
|
||||
val gzipInputStream = GZIPInputStream(outputStream.toByteArray().inputStream())
|
||||
val jsonString = gzipInputStream.bufferedReader().use { it.readText() }
|
||||
val syncData = json.decodeFromString(SyncData.serializer(), jsonString)
|
||||
this@GoogleDriveSyncService.logcat(LogPriority.DEBUG) { "JSON deserialized successfully" }
|
||||
syncData
|
||||
} catch (e: Exception) {
|
||||
this@GoogleDriveSyncService.logcat(
|
||||
LogPriority.ERROR,
|
||||
throwable = e,
|
||||
) { "Failed to convert json to sync data with kotlinx.serialization" }
|
||||
throw Exception(e.message, e)
|
||||
}
|
||||
throw Exception("Failed to download sync data: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pushSyncData(syncData: SyncData) {
|
||||
val jsonData = json.encodeToString(syncData)
|
||||
private suspend fun pushSyncData(syncData: SyncData) {
|
||||
val drive = googleDriveService.driveService
|
||||
?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
|
||||
?: throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
|
||||
|
||||
val fileList = getAppDataFileList(drive)
|
||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||
withIOContext {
|
||||
GZIPOutputStream(byteArrayOutputStream).use { gzipOutputStream ->
|
||||
gzipOutputStream.write(jsonData.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
this@GoogleDriveSyncService.logcat(LogPriority.DEBUG) { "JSON serialized successfully" }
|
||||
}
|
||||
|
||||
val byteArrayContent = ByteArrayContent("application/octet-stream", byteArrayOutputStream.toByteArray())
|
||||
PipedOutputStream().use { pos ->
|
||||
PipedInputStream(pos).use { pis ->
|
||||
withIOContext {
|
||||
// Start a coroutine or a background thread to write JSON to the PipedOutputStream
|
||||
launch {
|
||||
GZIPOutputStream(pos).use { gzipOutputStream ->
|
||||
Json.encodeToStream(SyncData.serializer(), syncData, gzipOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (fileList.isNotEmpty()) {
|
||||
// File exists, so update it
|
||||
val fileId = fileList[0].id
|
||||
drive.files().update(fileId, null, byteArrayContent).execute()
|
||||
logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" }
|
||||
} else {
|
||||
// File doesn't exist, so create it
|
||||
val fileMetadata = File().apply {
|
||||
name = remoteFileName
|
||||
mimeType = "application/gzip"
|
||||
parents = listOf("appDataFolder")
|
||||
if (fileList.isNotEmpty()) {
|
||||
val fileId = fileList[0].id
|
||||
val mediaContent = InputStreamContent("application/gzip", pis)
|
||||
drive.files().update(fileId, null, mediaContent).execute()
|
||||
logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" }
|
||||
} else {
|
||||
val fileMetadata = File().apply {
|
||||
name = remoteFileName
|
||||
mimeType = "application/gzip"
|
||||
parents = listOf("appDataFolder")
|
||||
}
|
||||
val mediaContent = InputStreamContent("application/gzip", pis)
|
||||
val uploadedFile = drive.files().create(fileMetadata, mediaContent)
|
||||
.setFields("id")
|
||||
.execute()
|
||||
logcat(LogPriority.DEBUG) { "Created new sync data file in Google Drive with file ID: ${uploadedFile.id}" }
|
||||
}
|
||||
}
|
||||
val uploadedFile = drive.files().create(fileMetadata, byteArrayContent)
|
||||
.setFields("id")
|
||||
.execute()
|
||||
|
||||
logcat(
|
||||
LogPriority.DEBUG,
|
||||
) { "Created new sync data file in Google Drive with file ID: ${uploadedFile.id}" }
|
||||
}
|
||||
|
||||
// Data has been successfully pushed or updated, delete the lock file
|
||||
deleteLockFile(drive)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, throwable = e) { "Failed to push or update sync data" }
|
||||
throw Exception(context.stringResource(MR.strings.error_uploading_sync_data) + ": ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +298,7 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, throwable = e) { "Error deleting lock file" }
|
||||
throw Exception(context.stringResource(MR.strings.error_deleting_google_drive_lock_file), e)
|
||||
throw Exception(context.stringResource(SYMR.strings.error_deleting_google_drive_lock_file), e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +408,6 @@ class GoogleDriveService(private val context: Context) {
|
||||
}
|
||||
internal suspend fun refreshToken() = withIOContext {
|
||||
val refreshToken = syncPreferences.googleDriveRefreshToken().get()
|
||||
val accessToken = syncPreferences.googleDriveAccessToken().get()
|
||||
|
||||
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
|
||||
val secrets = GoogleClientSecrets.load(
|
||||
@@ -407,21 +422,17 @@ class GoogleDriveService(private val context: Context) {
|
||||
.build()
|
||||
|
||||
if (refreshToken == "") {
|
||||
throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
|
||||
throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
|
||||
}
|
||||
|
||||
credential.refreshToken = refreshToken
|
||||
|
||||
this@GoogleDriveService.logcat(LogPriority.DEBUG) { "Refreshing access token with: $refreshToken" }
|
||||
|
||||
try {
|
||||
credential.refreshToken()
|
||||
val newAccessToken = credential.accessToken
|
||||
// Save the new access token
|
||||
syncPreferences.googleDriveAccessToken().set(newAccessToken)
|
||||
setupGoogleDriveService(newAccessToken, credential.refreshToken)
|
||||
this@GoogleDriveService
|
||||
.logcat(LogPriority.DEBUG) { "Google Access token refreshed old: $accessToken new: $newAccessToken" }
|
||||
} catch (e: TokenResponseException) {
|
||||
if (e.details.error == "invalid_grant") {
|
||||
// The refresh token is invalid, prompt the user to sign in again
|
||||
|
||||
@@ -17,6 +17,7 @@ import logcat.logcat
|
||||
|
||||
@Serializable
|
||||
data class SyncData(
|
||||
val deviceId: String = "",
|
||||
val backup: Backup? = null,
|
||||
)
|
||||
|
||||
@@ -25,38 +26,7 @@ abstract class SyncService(
|
||||
val json: Json,
|
||||
val syncPreferences: SyncPreferences,
|
||||
) {
|
||||
open suspend fun doSync(syncData: SyncData): Backup? {
|
||||
beforeSync()
|
||||
|
||||
val remoteSData = pullSyncData()
|
||||
|
||||
val finalSyncData =
|
||||
if (remoteSData == null) {
|
||||
pushSyncData(syncData)
|
||||
syncData
|
||||
} else {
|
||||
val mergedSyncData = mergeSyncData(syncData, remoteSData)
|
||||
pushSyncData(mergedSyncData)
|
||||
mergedSyncData
|
||||
}
|
||||
|
||||
return finalSyncData.backup
|
||||
}
|
||||
|
||||
/**
|
||||
* For refreshing tokens and other possible operations before connecting to the remote storage
|
||||
*/
|
||||
open suspend fun beforeSync() {}
|
||||
|
||||
/**
|
||||
* Download sync data from the remote storage
|
||||
*/
|
||||
abstract suspend fun pullSyncData(): SyncData?
|
||||
|
||||
/**
|
||||
* Upload sync data to the remote storage
|
||||
*/
|
||||
abstract suspend fun pushSyncData(syncData: SyncData)
|
||||
abstract suspend fun doSync(syncData: SyncData): Backup?;
|
||||
|
||||
/**
|
||||
* Merges the local and remote sync data into a single JSON string.
|
||||
@@ -65,11 +35,17 @@ abstract class SyncService(
|
||||
* @param remoteSyncData The SData containing the remote sync data.
|
||||
* @return The JSON string containing the merged sync data.
|
||||
*/
|
||||
private fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData {
|
||||
val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga)
|
||||
protected fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData {
|
||||
val mergedCategoriesList =
|
||||
mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
|
||||
|
||||
val mergedMangaList = mergeMangaLists(
|
||||
localSyncData.backup?.backupManga,
|
||||
remoteSyncData.backup?.backupManga,
|
||||
localSyncData.backup?.backupCategories ?: emptyList(),
|
||||
remoteSyncData.backup?.backupCategories ?: emptyList(),
|
||||
mergedCategoriesList)
|
||||
|
||||
val mergedSourcesList =
|
||||
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
|
||||
val mergedPreferencesList =
|
||||
@@ -101,6 +77,7 @@ abstract class SyncService(
|
||||
|
||||
// Create the merged SData object
|
||||
return SyncData(
|
||||
deviceId = syncPreferences.uniqueDeviceID(),
|
||||
backup = mergedBackup,
|
||||
)
|
||||
}
|
||||
@@ -117,6 +94,9 @@ abstract class SyncService(
|
||||
private fun mergeMangaLists(
|
||||
localMangaList: List<BackupManga>?,
|
||||
remoteMangaList: List<BackupManga>?,
|
||||
localCategories: List<BackupCategory>,
|
||||
remoteCategories: List<BackupCategory>,
|
||||
mergedCategories: List<BackupCategory>,
|
||||
): List<BackupManga> {
|
||||
val logTag = "MergeMangaLists"
|
||||
|
||||
@@ -135,6 +115,18 @@ abstract class SyncService(
|
||||
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||
val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||
|
||||
val localCategoriesMapByOrder = localCategories.associateBy { it.order }
|
||||
val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order }
|
||||
val mergedCategoriesMapByName = mergedCategories.associateBy { it.name }
|
||||
|
||||
fun updateCategories(theManga: BackupManga, theMap: Map<Long, BackupCategory>): BackupManga {
|
||||
return theManga.copy(categories = theManga.categories.mapNotNull {
|
||||
theMap[it]?.let { category ->
|
||||
mergedCategoriesMapByName[category.name]?.order
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
|
||||
}
|
||||
@@ -145,20 +137,26 @@ abstract class SyncService(
|
||||
|
||||
// New version comparison logic
|
||||
when {
|
||||
local != null && remote == null -> local
|
||||
local == null && remote != null -> remote
|
||||
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder)
|
||||
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder)
|
||||
local != null && remote != null -> {
|
||||
// Compare versions to decide which manga to keep
|
||||
if (local.version >= remote.version) {
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Keeping local version of ${local.title} with merged chapters."
|
||||
}
|
||||
local.copy(chapters = mergeChapters(local.chapters, remote.chapters))
|
||||
updateCategories(
|
||||
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
|
||||
localCategoriesMapByOrder
|
||||
)
|
||||
} else {
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Keeping remote version of ${remote.title} with merged chapters."
|
||||
}
|
||||
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters))
|
||||
updateCategories(
|
||||
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
|
||||
remoteCategoriesMapByOrder
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null // No manga found for key
|
||||
|
||||
@@ -2,23 +2,26 @@ package eu.kanade.tachiyomi.data.sync.service
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.domain.sync.SyncPreferences
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
|
||||
import eu.kanade.tachiyomi.data.sync.SyncNotifier
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.PATCH
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.PUT
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import logcat.LogPriority
|
||||
import logcat.logcat
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.gzip
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import org.apache.http.HttpStatus
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SyncYomiSyncService(
|
||||
@@ -26,140 +29,117 @@ class SyncYomiSyncService(
|
||||
json: Json,
|
||||
syncPreferences: SyncPreferences,
|
||||
private val notifier: SyncNotifier,
|
||||
|
||||
private val protoBuf: ProtoBuf = Injekt.get(),
|
||||
) : SyncService(context, json, syncPreferences) {
|
||||
|
||||
@Serializable
|
||||
enum class SyncStatus {
|
||||
@SerialName("pending")
|
||||
Pending,
|
||||
private class SyncYomiException(message: String?) : Exception(message)
|
||||
|
||||
@SerialName("syncing")
|
||||
Syncing,
|
||||
override suspend fun doSync(syncData: SyncData): Backup? {
|
||||
try {
|
||||
val (remoteData, etag) = pullSyncData()
|
||||
|
||||
@SerialName("success")
|
||||
Success,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LockFile(
|
||||
@SerialName("id")
|
||||
val id: Int?,
|
||||
@SerialName("user_api_key")
|
||||
val userApiKey: String?,
|
||||
@SerialName("acquired_by")
|
||||
val acquiredBy: String?,
|
||||
@SerialName("last_synced")
|
||||
val lastSynced: String?,
|
||||
@SerialName("status")
|
||||
val status: SyncStatus,
|
||||
@SerialName("acquired_at")
|
||||
val acquiredAt: String?,
|
||||
@SerialName("expires_at")
|
||||
val expiresAt: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LockfileCreateRequest(
|
||||
@SerialName("acquired_by")
|
||||
val acquiredBy: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LockfilePatchRequest(
|
||||
@SerialName("user_api_key")
|
||||
val userApiKey: String,
|
||||
@SerialName("acquired_by")
|
||||
val acquiredBy: String,
|
||||
)
|
||||
|
||||
override suspend fun beforeSync() {
|
||||
val host = syncPreferences.clientHost().get()
|
||||
val apiKey = syncPreferences.clientAPIKey().get()
|
||||
val lockFileApi = "$host/api/sync/lock"
|
||||
val deviceId = syncPreferences.uniqueDeviceID()
|
||||
val client = OkHttpClient()
|
||||
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
val createLockfileRequest = LockfileCreateRequest(deviceId)
|
||||
val createLockfileJson = json.encodeToString(createLockfileRequest)
|
||||
|
||||
val patchRequest = LockfilePatchRequest(apiKey, deviceId)
|
||||
val patchJson = json.encodeToString(patchRequest)
|
||||
|
||||
val lockFileRequest = GET(
|
||||
url = lockFileApi,
|
||||
headers = headers,
|
||||
)
|
||||
|
||||
val lockFileCreate = POST(
|
||||
url = lockFileApi,
|
||||
headers = headers,
|
||||
body = createLockfileJson.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
|
||||
)
|
||||
|
||||
val lockFileUpdate = PATCH(
|
||||
url = lockFileApi,
|
||||
headers = headers,
|
||||
body = patchJson.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
|
||||
)
|
||||
|
||||
// create lock file first
|
||||
client.newCall(lockFileCreate).await()
|
||||
// update lock file acquired_by
|
||||
client.newCall(lockFileUpdate).await()
|
||||
|
||||
var backoff = 2000L // Start with 2 seconds
|
||||
val maxBackoff = 32000L // Maximum backoff time e.g., 32 seconds
|
||||
var lockFile: LockFile
|
||||
do {
|
||||
val response = client.newCall(lockFileRequest).await()
|
||||
val responseBody = response.body.string()
|
||||
lockFile = json.decodeFromString<LockFile>(responseBody)
|
||||
logcat(LogPriority.DEBUG) { "SyncYomi lock file status: ${lockFile.status}" }
|
||||
|
||||
if (lockFile.status != SyncStatus.Success) {
|
||||
logcat(LogPriority.DEBUG) { "Lock file not ready, retrying in $backoff ms..." }
|
||||
delay(backoff)
|
||||
backoff = (backoff * 2).coerceAtMost(maxBackoff)
|
||||
val finalSyncData = if (remoteData != null){
|
||||
assert(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
|
||||
logcat(LogPriority.DEBUG, "SyncService") {
|
||||
"Try update remote data with ETag($etag)"
|
||||
}
|
||||
mergeSyncData(syncData, remoteData)
|
||||
} else {
|
||||
// init or overwrite remote data
|
||||
logcat(LogPriority.DEBUG) {
|
||||
"Try overwrite remote data with ETag($etag)"
|
||||
}
|
||||
syncData
|
||||
}
|
||||
} while (lockFile.status != SyncStatus.Success)
|
||||
|
||||
// update lock file acquired_by
|
||||
client.newCall(lockFileUpdate).await()
|
||||
pushSyncData(finalSyncData, etag)
|
||||
return finalSyncData.backup
|
||||
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
|
||||
notifier.showSyncError(e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pullSyncData(): SyncData? {
|
||||
private suspend fun pullSyncData(): Pair<SyncData?, String> {
|
||||
val host = syncPreferences.clientHost().get()
|
||||
val apiKey = syncPreferences.clientAPIKey().get()
|
||||
val downloadUrl = "$host/api/sync/download"
|
||||
val downloadUrl = "$host/api/sync/content"
|
||||
|
||||
val client = OkHttpClient()
|
||||
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
|
||||
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||
val lastETag = syncPreferences.lastSyncEtag().get()
|
||||
if (lastETag != "") {
|
||||
headersBuilder.add("If-None-Match", lastETag)
|
||||
}
|
||||
val headers = headersBuilder.build()
|
||||
|
||||
val downloadRequest = GET(
|
||||
url = downloadUrl,
|
||||
headers = headers,
|
||||
)
|
||||
|
||||
val client = OkHttpClient()
|
||||
val response = client.newCall(downloadRequest).await()
|
||||
val responseBody = response.body.string()
|
||||
|
||||
return if (response.isSuccessful) {
|
||||
json.decodeFromString<SyncData>(responseBody)
|
||||
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
|
||||
// not modified
|
||||
assert(lastETag.isNotEmpty())
|
||||
logcat(LogPriority.INFO) {
|
||||
"Remote server not modified"
|
||||
}
|
||||
return Pair(null, lastETag)
|
||||
} else if (response.code == HttpStatus.SC_NOT_FOUND) {
|
||||
// maybe got deleted from remote
|
||||
return Pair(null, "")
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val newETag = response.headers["ETag"]
|
||||
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
|
||||
|
||||
val byteArray = response.body.byteStream().use {
|
||||
return@use it.readBytes()
|
||||
}
|
||||
|
||||
return try {
|
||||
val backup = protoBuf.decodeFromByteArray(BackupSerializer, byteArray)
|
||||
return Pair(SyncData(backup = backup), newETag)
|
||||
} catch (_: SerializationException) {
|
||||
logcat(LogPriority.INFO) {
|
||||
"Bad content responsed from server"
|
||||
}
|
||||
// the body is invalid
|
||||
// return default value so we can overwrite it
|
||||
Pair(null, "")
|
||||
}
|
||||
|
||||
} else {
|
||||
val responseBody = response.body.string()
|
||||
notifier.showSyncError("Failed to download sync data: $responseBody")
|
||||
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
|
||||
null
|
||||
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
|
||||
throw SyncYomiException("Failed to download sync data: $responseBody")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pushSyncData(syncData: SyncData) {
|
||||
/**
|
||||
* Return true if update success
|
||||
*/
|
||||
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
|
||||
val backup = syncData.backup ?: return
|
||||
|
||||
val host = syncPreferences.clientHost().get()
|
||||
val apiKey = syncPreferences.clientAPIKey().get()
|
||||
val uploadUrl = "$host/api/sync/upload"
|
||||
val uploadUrl = "$host/api/sync/content"
|
||||
val timeout = 30L
|
||||
|
||||
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||
if (eTag.isNotEmpty()) {
|
||||
headersBuilder.add("If-Match", eTag)
|
||||
}
|
||||
val headers = headersBuilder.build()
|
||||
|
||||
// Set timeout to 30 seconds
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(timeout, TimeUnit.SECONDS)
|
||||
@@ -167,32 +147,34 @@ class SyncYomiSyncService(
|
||||
.writeTimeout(timeout, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val headers = Headers.Builder().add(
|
||||
"Content-Type",
|
||||
"application/gzip",
|
||||
).add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build()
|
||||
val byteArray = protoBuf.encodeToByteArray(BackupSerializer, backup)
|
||||
if (byteArray.isEmpty()) {
|
||||
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
||||
}
|
||||
val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
|
||||
|
||||
val mediaType = "application/gzip".toMediaTypeOrNull()
|
||||
|
||||
val jsonData = json.encodeToString(syncData)
|
||||
val body = jsonData.toRequestBody(mediaType).gzip()
|
||||
|
||||
val uploadRequest = POST(
|
||||
val uploadRequest = PUT(
|
||||
url = uploadUrl,
|
||||
headers = headers,
|
||||
body = body,
|
||||
)
|
||||
|
||||
client.newCall(uploadRequest).await().use {
|
||||
if (it.isSuccessful) {
|
||||
logcat(
|
||||
LogPriority.DEBUG,
|
||||
) { "SyncYomi sync completed!" }
|
||||
} else {
|
||||
val responseBody = it.body.string()
|
||||
notifier.showSyncError("Failed to upload sync data: $responseBody")
|
||||
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
|
||||
}
|
||||
val response = client.newCall(uploadRequest).await()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val newETag = response.headers["ETag"]
|
||||
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
|
||||
syncPreferences.lastSyncEtag().set(newETag)
|
||||
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
|
||||
|
||||
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
|
||||
// other clients updated remote data, will try next time
|
||||
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
|
||||
|
||||
} else {
|
||||
val responseBody = response.body.string()
|
||||
notifier.showSyncError("Failed to upload sync data: $responseBody")
|
||||
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ import exh.source.BlacklistedSources
|
||||
import exh.source.EH_SOURCE_ID
|
||||
import exh.source.EXH_SOURCE_ID
|
||||
import exh.source.MERGED_SOURCE_ID
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -32,7 +31,6 @@ import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.lang.launchNow
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.source.model.StubSource
|
||||
@@ -54,10 +52,10 @@ class ExtensionManager(
|
||||
private val trustExtension: TrustExtension = Injekt.get(),
|
||||
) {
|
||||
|
||||
// SY -->
|
||||
val scope = CoroutineScope(SupervisorJob())
|
||||
|
||||
private val _isInitialized = MutableStateFlow(false)
|
||||
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||
// SY <--
|
||||
|
||||
/**
|
||||
* API where all the available extensions can be found.
|
||||
@@ -71,13 +69,31 @@ class ExtensionManager(
|
||||
|
||||
private val iconMap = mutableMapOf<String, Drawable>()
|
||||
|
||||
private val _installedExtensionsFlow = MutableStateFlow(emptyList<Extension.Installed>())
|
||||
val installedExtensionsFlow = _installedExtensionsFlow.asStateFlow()
|
||||
private val _installedExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>())
|
||||
val installedExtensionsFlow = _installedExtensionsMapFlow.mapExtensions(scope)
|
||||
|
||||
private val _availableExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
|
||||
// SY -->
|
||||
val availableExtensionsFlow = _availableExtensionsMapFlow.map { it.filterNotBlacklisted().values.toList() }
|
||||
.stateIn(scope, SharingStarted.Lazily, _availableExtensionsMapFlow.value.values.toList())
|
||||
// SY <--
|
||||
|
||||
private val _untrustedExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>())
|
||||
val untrustedExtensionsFlow = _untrustedExtensionsMapFlow.mapExtensions(scope)
|
||||
|
||||
init {
|
||||
initExtensions()
|
||||
ExtensionInstallReceiver(InstallationListener()).register(context)
|
||||
}
|
||||
|
||||
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet()
|
||||
|
||||
fun getAppIconForSource(sourceId: Long): Drawable? {
|
||||
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
|
||||
val pkgName = _installedExtensionsMapFlow.value.values
|
||||
.find { ext ->
|
||||
ext.sources.any { it.id == sourceId }
|
||||
}
|
||||
?.pkgName
|
||||
if (pkgName != null) {
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
|
||||
ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
@@ -95,15 +111,6 @@ class ExtensionManager(
|
||||
// SY <--
|
||||
}
|
||||
|
||||
private val _availableExtensionsFlow = MutableStateFlow(emptyList<Extension.Available>())
|
||||
|
||||
// SY -->
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
val availableExtensionsFlow = _availableExtensionsFlow
|
||||
.map { it.filterNotBlacklisted() }
|
||||
.stateIn(GlobalScope, SharingStarted.Eagerly, emptyList())
|
||||
// SY <--
|
||||
|
||||
private var availableExtensionsSourcesData: Map<Long, StubSource> = emptyMap()
|
||||
|
||||
private fun setupAvailableExtensionsSourcesDataMap(extensions: List<Extension.Available>) {
|
||||
@@ -115,38 +122,30 @@ class ExtensionManager(
|
||||
|
||||
fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
|
||||
|
||||
private val _untrustedExtensionsFlow = MutableStateFlow(emptyList<Extension.Untrusted>())
|
||||
val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
initExtensions()
|
||||
ExtensionInstallReceiver(InstallationListener()).register(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and registers the installed extensions.
|
||||
*/
|
||||
private fun initExtensions() {
|
||||
val extensions = ExtensionLoader.loadExtensions(context)
|
||||
|
||||
_installedExtensionsFlow.value = extensions
|
||||
_installedExtensionsMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Success>()
|
||||
.map { it.extension }
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
|
||||
_untrustedExtensionsFlow.value = extensions
|
||||
_untrustedExtensionsMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Untrusted>()
|
||||
.map { it.extension }
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
// SY -->
|
||||
.filterNotBlacklisted()
|
||||
// SY <--
|
||||
|
||||
_isInitialized.value = true
|
||||
// SY <--
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
private fun <T : Extension> Iterable<T>.filterNotBlacklisted(): List<T> {
|
||||
private fun <T : Extension> Map<String, T>.filterNotBlacklisted(): Map<String, T> {
|
||||
val blacklistEnabled = preferences.enableSourceBlacklist().get()
|
||||
return filterNot { extension ->
|
||||
return filterNot { (_, extension) ->
|
||||
extension.isBlacklisted(blacklistEnabled)
|
||||
.also {
|
||||
if (it) this@ExtensionManager.xLogD("Removing blacklisted extension: (name: %s, pkgName: %s)!", extension.name, extension.pkgName)
|
||||
@@ -160,7 +159,7 @@ class ExtensionManager(
|
||||
// EXH <--
|
||||
|
||||
/**
|
||||
* Finds the available extensions in the [api] and updates [availableExtensions].
|
||||
* Finds the available extensions in the [api] and updates [_availableExtensionsMapFlow].
|
||||
*/
|
||||
suspend fun findAvailableExtensions() {
|
||||
val extensions: List<Extension.Available> = try {
|
||||
@@ -173,7 +172,7 @@ class ExtensionManager(
|
||||
|
||||
enableAdditionalSubLanguages(extensions)
|
||||
|
||||
_availableExtensionsFlow.value = extensions
|
||||
_availableExtensionsMapFlow.value = extensions.associateBy { it.pkgName }
|
||||
updatedInstalledExtensionsStatuses(extensions)
|
||||
setupAvailableExtensionsSourcesDataMap(extensions)
|
||||
}
|
||||
@@ -219,42 +218,36 @@ class ExtensionManager(
|
||||
return
|
||||
}
|
||||
|
||||
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList()
|
||||
val installedExtensionsMap = _installedExtensionsMapFlow.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((pkgName, extension) in installedExtensionsMap) {
|
||||
val availableExt = availableExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
for ((index, installedExt) in mutInstalledExtensions.withIndex()) {
|
||||
val pkgName = installedExt.pkgName
|
||||
// SY -->
|
||||
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
// SY <--
|
||||
|
||||
if (availableExt == null && !installedExt.isObsolete) {
|
||||
mutInstalledExtensions[index] = installedExt.copy(isObsolete = true)
|
||||
if (availableExt == null && !extension.isObsolete) {
|
||||
installedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
|
||||
changed = true
|
||||
// SY -->
|
||||
} else if (installedExt.isBlacklisted() && !installedExt.isRedundant) {
|
||||
mutInstalledExtensions[index] = installedExt.copy(isRedundant = true)
|
||||
} else if (extension.isBlacklisted() && !extension.isRedundant) {
|
||||
installedExtensionsMap[pkgName] = extension.copy(isRedundant = true)
|
||||
changed = true
|
||||
// SY <--
|
||||
} else if (availableExt != null) {
|
||||
val hasUpdate = installedExt.updateExists(availableExt)
|
||||
|
||||
if (installedExt.hasUpdate != hasUpdate) {
|
||||
mutInstalledExtensions[index] = installedExt.copy(
|
||||
val hasUpdate = extension.updateExists(availableExt)
|
||||
if (extension.hasUpdate != hasUpdate) {
|
||||
installedExtensionsMap[pkgName] = extension.copy(
|
||||
hasUpdate = hasUpdate,
|
||||
repoUrl = availableExt.repoUrl,
|
||||
)
|
||||
changed = true
|
||||
} else {
|
||||
mutInstalledExtensions[index] = installedExt.copy(
|
||||
installedExtensionsMap[pkgName] = extension.copy(
|
||||
repoUrl = availableExt.repoUrl,
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_installedExtensionsFlow.value = mutInstalledExtensions
|
||||
_installedExtensionsMapFlow.value = installedExtensionsMap
|
||||
}
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
@@ -278,8 +271,7 @@ class ExtensionManager(
|
||||
* @param extension The extension to be updated.
|
||||
*/
|
||||
fun updateExtension(extension: Extension.Installed): Flow<InstallStep> {
|
||||
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == extension.pkgName }
|
||||
?: return emptyFlow()
|
||||
val availableExt = _availableExtensionsMapFlow.value[extension.pkgName] ?: return emptyFlow()
|
||||
return installExtension(availableExt)
|
||||
}
|
||||
|
||||
@@ -315,24 +307,16 @@ class ExtensionManager(
|
||||
*
|
||||
* @param extension the extension to trust
|
||||
*/
|
||||
fun trust(extension: Extension.Untrusted) {
|
||||
val untrustedPkgNames = _untrustedExtensionsFlow.value.map { it.pkgName }.toSet()
|
||||
if (extension.pkgName !in untrustedPkgNames) return
|
||||
suspend fun trust(extension: Extension.Untrusted) {
|
||||
_untrustedExtensionsMapFlow.value[extension.pkgName] ?: return
|
||||
|
||||
trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash)
|
||||
|
||||
val nowTrustedExtensions = _untrustedExtensionsFlow.value
|
||||
.filter { it.pkgName == extension.pkgName && it.versionCode == extension.versionCode }
|
||||
_untrustedExtensionsFlow.value -= nowTrustedExtensions
|
||||
_untrustedExtensionsMapFlow.value -= extension.pkgName
|
||||
|
||||
launchNow {
|
||||
nowTrustedExtensions
|
||||
.map { extension ->
|
||||
async { ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName) }.await()
|
||||
}
|
||||
.filterIsInstance<LoadResult.Success>()
|
||||
.forEach { registerNewExtension(it.extension) }
|
||||
}
|
||||
ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)
|
||||
.let { it as? LoadResult.Success }
|
||||
?.let { registerNewExtension(it.extension) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,7 +332,7 @@ class ExtensionManager(
|
||||
}
|
||||
// SY <--
|
||||
|
||||
_installedExtensionsFlow.value += extension
|
||||
_installedExtensionsMapFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,13 +349,7 @@ class ExtensionManager(
|
||||
}
|
||||
// SY <--
|
||||
|
||||
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList()
|
||||
val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName }
|
||||
if (oldExtension != null) {
|
||||
mutInstalledExtensions -= oldExtension
|
||||
}
|
||||
mutInstalledExtensions += extension
|
||||
_installedExtensionsFlow.value = mutInstalledExtensions
|
||||
_installedExtensionsMapFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,14 +359,8 @@ class ExtensionManager(
|
||||
* @param pkgName The package name of the uninstalled application.
|
||||
*/
|
||||
private fun unregisterExtension(pkgName: String) {
|
||||
val installedExtension = _installedExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (installedExtension != null) {
|
||||
_installedExtensionsFlow.value -= installedExtension
|
||||
}
|
||||
val untrustedExtension = _untrustedExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (untrustedExtension != null) {
|
||||
_untrustedExtensionsFlow.value -= untrustedExtension
|
||||
}
|
||||
_installedExtensionsMapFlow.value -= pkgName
|
||||
_untrustedExtensionsMapFlow.value -= pkgName
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,14 +379,9 @@ class ExtensionManager(
|
||||
}
|
||||
|
||||
override fun onExtensionUntrusted(extension: Extension.Untrusted) {
|
||||
val installedExtension = _installedExtensionsFlow.value
|
||||
.find { it.pkgName == extension.pkgName }
|
||||
|
||||
if (installedExtension != null) {
|
||||
_installedExtensionsFlow.value -= installedExtension
|
||||
} else {
|
||||
_untrustedExtensionsFlow.value += extension
|
||||
}
|
||||
_installedExtensionsMapFlow.value -= extension.pkgName
|
||||
_untrustedExtensionsMapFlow.value += extension
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
@@ -436,17 +403,24 @@ class ExtensionManager(
|
||||
}
|
||||
|
||||
private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean {
|
||||
val availableExt = availableExtension ?: _availableExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
val availableExt = availableExtension
|
||||
?: _availableExtensionsMapFlow.value[pkgName]
|
||||
?: return false
|
||||
|
||||
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
|
||||
}
|
||||
|
||||
private fun updatePendingUpdatesCount() {
|
||||
val pendingUpdateCount = _installedExtensionsFlow.value.count { it.hasUpdate }
|
||||
val pendingUpdateCount = _installedExtensionsMapFlow.value.values.count { it.hasUpdate }
|
||||
preferences.extensionUpdatesCount().set(pendingUpdateCount)
|
||||
if (pendingUpdateCount == 0) {
|
||||
ExtensionUpdateNotifier(context).dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private operator fun <T : Extension> Map<String, T>.plus(extension: T) = plus(extension.pkgName to extension)
|
||||
|
||||
private fun <T : Extension> StateFlow<Map<String, T>>.mapExtensions(scope: CoroutineScope): StateFlow<List<T>> {
|
||||
return map { it.values.toList() }.stateIn(scope, SharingStarted.Lazily, value.values.toList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,14 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import exh.source.BlacklistedSources
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
@@ -26,8 +31,12 @@ internal class ExtensionApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferenceStore: PreferenceStore by injectLazy()
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
||||
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
|
||||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
// SY -->
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
// SY <--
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val lastExtCheck: Preference<Long> by lazy {
|
||||
@@ -36,11 +45,15 @@ internal class ExtensionApi {
|
||||
|
||||
suspend fun findExtensions(): List<Extension.Available> {
|
||||
return withIOContext {
|
||||
sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
|
||||
getExtensionRepo.getAll()
|
||||
.map { async { getExtensions(it) } }
|
||||
.awaitAll()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getExtensions(repoBaseUrl: String): List<Extension.Available> {
|
||||
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
|
||||
val repoBaseUrl = extRepo.baseUrl
|
||||
return try {
|
||||
val response = networkService.client
|
||||
.newCall(GET("$repoBaseUrl/index.min.json"))
|
||||
@@ -68,6 +81,9 @@ internal class ExtensionApi {
|
||||
return null
|
||||
}
|
||||
|
||||
// Update extension repo details
|
||||
updateExtensionRepo.awaitAll()
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
extensionManager.availableExtensionsFlow.value
|
||||
} else {
|
||||
|
||||
@@ -9,12 +9,10 @@ import androidx.core.content.ContextCompat
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.lang.launchNow
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
/**
|
||||
@@ -23,29 +21,23 @@ import tachiyomi.core.common.util.system.logcat
|
||||
*
|
||||
* @param listener The listener that should be notified of extension installation events.
|
||||
*/
|
||||
internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
BroadcastReceiver() {
|
||||
internal class ExtensionInstallReceiver(private val listener: Listener) : BroadcastReceiver() {
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob())
|
||||
|
||||
/**
|
||||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intent filter this receiver should subscribe to.
|
||||
*/
|
||||
private val filter
|
||||
get() = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(ACTION_EXTENSION_ADDED)
|
||||
addAction(ACTION_EXTENSION_REPLACED)
|
||||
addAction(ACTION_EXTENSION_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
private val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(ACTION_EXTENSION_ADDED)
|
||||
addAction(ACTION_EXTENSION_REPLACED)
|
||||
addAction(ACTION_EXTENSION_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one of the events of the [filter] is received. When the package is an extension,
|
||||
@@ -58,7 +50,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
scope.launch {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
@@ -67,7 +59,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||
launchNow {
|
||||
scope.launch {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
@@ -107,9 +99,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
logcat(LogPriority.WARN) { "Package name not found" }
|
||||
return LoadResult.Error
|
||||
}
|
||||
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) {
|
||||
ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
|
||||
}.await()
|
||||
return ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,7 +172,7 @@ internal object ExtensionLoader {
|
||||
* Attempts to load an extension from the given package name. It checks if the extension
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||
suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||
val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
|
||||
if (extensionPackage == null) {
|
||||
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
||||
@@ -223,7 +223,8 @@ internal object ExtensionLoader {
|
||||
* @param context The application context.
|
||||
* @param extensionInfo The extension to load.
|
||||
*/
|
||||
private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
|
||||
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
val appInfo = pkgInfo.applicationInfo
|
||||
@@ -252,7 +253,7 @@ internal object ExtensionLoader {
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return LoadResult.Error
|
||||
} else if (!trustExtension.isTrusted(pkgInfo, signatures.last())) {
|
||||
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
|
||||
val extension = Extension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
|
||||
@@ -806,6 +806,11 @@ class EHentai(
|
||||
override fun pageListParse(response: Response) =
|
||||
throw UnsupportedOperationException("Unused method was called somehow!")
|
||||
|
||||
override suspend fun getImageUrl(page: Page): String {
|
||||
val imageUrlResponse = client.newCall(imageUrlRequest(page)).awaitSuccess()
|
||||
return realImageUrlParse(imageUrlResponse, page)
|
||||
}
|
||||
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
|
||||
override fun fetchImageUrl(page: Page): Observable<String> {
|
||||
return client.newCall(imageUrlRequest(page))
|
||||
|
||||
@@ -109,8 +109,6 @@ class MergedSource : HttpSource() {
|
||||
suspend fun fetchChaptersForMergedManga(
|
||||
manga: Manga,
|
||||
downloadChapters: Boolean = true,
|
||||
editScanlators: Boolean = false,
|
||||
dedupe: Boolean = true,
|
||||
) {
|
||||
fetchChaptersAndSync(manga, downloadChapters)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
@@ -195,7 +196,9 @@ class ExtensionsScreenModel(
|
||||
}
|
||||
|
||||
fun trustExtension(extension: Extension.Untrusted) {
|
||||
extensionManager.trust(extension)
|
||||
screenModelScope.launch {
|
||||
extensionManager.trust(extension)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -280,7 +280,7 @@ open class FeedScreenModel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getManga(initialManga: DomainManga, source: CatalogueSource?): State<DomainManga> {
|
||||
fun getManga(initialManga: DomainManga): State<DomainManga> {
|
||||
return produceState(initialValue = initialManga) {
|
||||
getManga.subscribe(initialManga.url, initialManga.source)
|
||||
.collectLatest { manga ->
|
||||
|
||||
@@ -87,7 +87,7 @@ fun Screen.feedTab(): TabContent {
|
||||
navigator.push(MangaScreen(manga.id, true))
|
||||
},
|
||||
onRefresh = screenModel::init,
|
||||
getMangaState = { manga, source -> screenModel.getManga(initialManga = manga, source = source) },
|
||||
getMangaState = { manga -> screenModel.getManga(initialManga = manga) },
|
||||
)
|
||||
|
||||
state.dialog?.let { dialog ->
|
||||
|
||||
+22
-4
@@ -46,9 +46,15 @@ import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import java.io.Serializable
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PreMigrationScreen(val mangaIds: List<Long>) : Screen() {
|
||||
sealed class MigrationType : Serializable {
|
||||
data class MangaList(val mangaIds: List<Long>) : MigrationType()
|
||||
data class MangaSingle(val fromMangaId: Long, val toManga: Long?) : MigrationType()
|
||||
}
|
||||
|
||||
class PreMigrationScreen(val migration: MigrationType) : Screen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val screenModel = rememberScreenModel { PreMigrationScreenModel() }
|
||||
@@ -173,7 +179,7 @@ class PreMigrationScreen(val mangaIds: List<Long>) : Screen() {
|
||||
screenModel.onMigrationSheet(false)
|
||||
screenModel.saveEnabledSources()
|
||||
|
||||
navigator replace MigrationListScreen(MigrationProcedureConfig(mangaIds, extraParam))
|
||||
navigator replace MigrationListScreen(MigrationProcedureConfig(migration, extraParam))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -184,10 +190,22 @@ class PreMigrationScreen(val mangaIds: List<Long>) : Screen() {
|
||||
navigator.push(
|
||||
if (skipPre) {
|
||||
MigrationListScreen(
|
||||
MigrationProcedureConfig(mangaIds, null),
|
||||
MigrationProcedureConfig(MigrationType.MangaList(mangaIds), null),
|
||||
)
|
||||
} else {
|
||||
PreMigrationScreen(mangaIds)
|
||||
PreMigrationScreen(MigrationType.MangaList(mangaIds))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun navigateToMigration(skipPre: Boolean, navigator: Navigator, fromMangaId: Long, toManga: Long?) {
|
||||
navigator.push(
|
||||
if (skipPre) {
|
||||
MigrationListScreen(
|
||||
MigrationProcedureConfig(MigrationType.MangaSingle(fromMangaId, toManga), null),
|
||||
)
|
||||
} else {
|
||||
PreMigrationScreen(MigrationType.MangaSingle(fromMangaId, toManga))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+48
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.advanced.process
|
||||
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType
|
||||
import java.io.Serializable
|
||||
|
||||
data class MigrationProcedureConfig(
|
||||
var mangaIds: List<Long>,
|
||||
var migration: MigrationType,
|
||||
val extraSearchParams: String?,
|
||||
) : Serializable
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ data class SourceSearchScreen(
|
||||
}
|
||||
|
||||
val onDismissRequest = { screenModel.setDialog(null) }
|
||||
when (val dialog = state.dialog) {
|
||||
when (state.dialog) {
|
||||
is BrowseSourceScreenModel.Dialog.Filter -> {
|
||||
SourceFilterDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.kanade.presentation.components.TabContent
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreen
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.domain.UnsortedPreferences
|
||||
@@ -55,6 +56,7 @@ fun Screen.migrateSourceTab(): TabContent {
|
||||
// SY -->
|
||||
onClickAll = { source ->
|
||||
// TODO: Jay wtf, need to clean this up sometime
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
launchIO {
|
||||
val manga = Injekt.get<GetFavorites>().await()
|
||||
val sourceMangas =
|
||||
|
||||
@@ -49,6 +49,7 @@ import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
@@ -62,6 +63,7 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import tachiyomi.core.common.Constants
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.domain.UnsortedPreferences
|
||||
import tachiyomi.domain.source.model.StubSource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
@@ -69,6 +71,8 @@ import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import tachiyomi.source.local.LocalSource
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
data class BrowseSourceScreen(
|
||||
private val sourceId: Long,
|
||||
@@ -319,6 +323,16 @@ data class BrowseSourceScreen(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.addFavorite(dialog.manga) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
// SY -->
|
||||
PreMigrationScreen.navigateToMigration(
|
||||
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
|
||||
navigator,
|
||||
dialog.duplicate.id,
|
||||
dialog.manga.id,
|
||||
)
|
||||
// SY <--
|
||||
},
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
|
||||
|
||||
+1
-1
@@ -455,7 +455,7 @@ open class BrowseSourceScreenModel(
|
||||
val manga: Manga,
|
||||
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
|
||||
) : Dialog
|
||||
data class Migrate(val newManga: Manga) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
|
||||
// SY -->
|
||||
data class DeleteSavedSearch(val idToDelete: Long, val name: String) : Dialog
|
||||
|
||||
@@ -59,6 +59,7 @@ import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.mutate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -233,6 +234,9 @@ class LibraryScreenModel(
|
||||
prefs.filterBookmarked,
|
||||
prefs.filterCompleted,
|
||||
prefs.filterIntervalCustom,
|
||||
// SY -->
|
||||
prefs.filterLewd,
|
||||
// SY <--
|
||||
) + trackFilter.values
|
||||
).any { it != TriState.DISABLED }
|
||||
}
|
||||
@@ -740,6 +744,7 @@ class LibraryScreenModel(
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun syncMangaToDex() {
|
||||
launchIO {
|
||||
MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences, sourceManager)?.let { mdex ->
|
||||
|
||||
@@ -164,10 +164,10 @@ object LibraryTab : Tab {
|
||||
}
|
||||
},
|
||||
onClickSyncNow = {
|
||||
if (!SyncDataJob.isAnyJobRunning(context)) {
|
||||
if (!SyncDataJob.isRunning(context)) {
|
||||
SyncDataJob.startNow(context)
|
||||
} else {
|
||||
context.toast(MR.strings.sync_in_progress)
|
||||
context.toast(SYMR.strings.sync_in_progress)
|
||||
}
|
||||
},
|
||||
// SY -->
|
||||
|
||||
@@ -50,8 +50,6 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.google.firebase.analytics.ktx.analytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.presentation.components.AppStateBanners
|
||||
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
|
||||
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
|
||||
@@ -80,7 +78,6 @@ import eu.kanade.tachiyomi.util.system.dpToPx
|
||||
import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
|
||||
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
|
||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||
import exh.EXHMigrations
|
||||
import exh.debug.DebugToggles
|
||||
import exh.eh.EHentaiUpdateWorker
|
||||
import exh.log.DebugModeOverlay
|
||||
@@ -97,6 +94,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.core.migration.Migrator
|
||||
import tachiyomi.core.common.Constants
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -105,17 +103,13 @@ import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.LinkedList
|
||||
import androidx.compose.ui.graphics.Color.Companion as ComposeColor
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
private val libraryPreferences: LibraryPreferences by injectLazy()
|
||||
private val uiPreferences: UiPreferences by injectLazy()
|
||||
private val preferences: BasePreferences by injectLazy()
|
||||
|
||||
// SY -->
|
||||
@@ -165,20 +159,7 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
val didMigration = if (isLaunch) {
|
||||
addAnalytics()
|
||||
EXHMigrations.upgrade(
|
||||
context = applicationContext,
|
||||
basePreferences = preferences,
|
||||
uiPreferences = uiPreferences,
|
||||
preferenceStore = Injekt.get(),
|
||||
networkPreferences = Injekt.get(),
|
||||
sourcePreferences = sourcePreferences,
|
||||
securityPreferences = Injekt.get(),
|
||||
libraryPreferences = libraryPreferences,
|
||||
readerPreferences = Injekt.get(),
|
||||
backupPreferences = Injekt.get(),
|
||||
trackerManager = Injekt.get(),
|
||||
pagePreviewCache = Injekt.get(),
|
||||
)
|
||||
Migrator.awaitAndRelease()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ fun EditMangaDialog(
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val binding = binding ?: return@TextButton
|
||||
onPositiveClick(
|
||||
binding.title.text.toString(),
|
||||
@@ -120,7 +121,7 @@ fun EditMangaDialog(
|
||||
}
|
||||
|
||||
private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDialogBinding, scope: CoroutineScope) {
|
||||
loadCover(manga, context, binding)
|
||||
loadCover(manga, binding)
|
||||
|
||||
val statusAdapter: ArrayAdapter<String> = ArrayAdapter(
|
||||
context,
|
||||
@@ -188,7 +189,7 @@ private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDial
|
||||
binding.mangaDescription.hint =
|
||||
context.stringResource(
|
||||
SYMR.strings.description_hint,
|
||||
manga.ogDescription?.takeIf { it.isNotBlank() }?.let { it.replace("\n", " ").chop(20) } ?: ""
|
||||
manga.ogDescription?.takeIf { it.isNotBlank() }?.replace("\n", " ")?.chop(20) ?: ""
|
||||
)
|
||||
binding.thumbnailUrl.hint =
|
||||
context.stringResource(
|
||||
@@ -212,7 +213,7 @@ private fun resetTags(manga: Manga, binding: EditMangaDialogBinding, scope: Coro
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCover(manga: Manga, context: Context, binding: EditMangaDialogBinding) {
|
||||
private fun loadCover(manga: Manga, binding: EditMangaDialogBinding) {
|
||||
binding.mangaCover.load(manga) {
|
||||
transformations(RoundedCornersTransformation(4.dpToPx.toFloat()))
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import exh.source.isMdBasedSource
|
||||
import exh.ui.ifSourcesLoaded
|
||||
import exh.ui.metadata.MetadataViewScreen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
@@ -251,11 +252,19 @@ class MangaScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
is MangaScreenModel.Dialog.DuplicateManga -> DuplicateMangaDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
)
|
||||
|
||||
is MangaScreenModel.Dialog.DuplicateManga -> {
|
||||
DuplicateMangaDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
// SY -->
|
||||
migrateManga(navigator, dialog.duplicate, screenModel.manga!!.id)
|
||||
// SY <--
|
||||
},
|
||||
)
|
||||
}
|
||||
MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
manga = successState.manga,
|
||||
@@ -457,12 +466,13 @@ class MangaScreen(
|
||||
/**
|
||||
* Initiates source migration for the specific manga.
|
||||
*/
|
||||
private fun migrateManga(navigator: Navigator, manga: Manga) {
|
||||
private fun migrateManga(navigator: Navigator, manga: Manga, toMangaId: Long? = null) {
|
||||
// SY -->
|
||||
PreMigrationScreen.navigateToMigration(
|
||||
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
|
||||
navigator,
|
||||
listOf(manga.id),
|
||||
manga.id,
|
||||
toMangaId,
|
||||
)
|
||||
// SY <--
|
||||
}
|
||||
@@ -505,6 +515,7 @@ class MangaScreen(
|
||||
navigator.push(SourcesScreen(smartSearchConfig))
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun mergeWithAnother(
|
||||
navigator: Navigator,
|
||||
context: Context,
|
||||
|
||||
@@ -976,6 +976,7 @@ class MangaScreenModel(
|
||||
downloadManager.getQueuedDownloadOrNull(chapter.id)
|
||||
}
|
||||
// SY -->
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val manga = mergedData?.manga?.get(chapter.mangaId) ?: manga
|
||||
val source = mergedData?.sources?.find { manga.source == it.id }?.takeIf { mergedData.sources.size > 2 }
|
||||
// SY <--
|
||||
@@ -1049,7 +1050,7 @@ class MangaScreenModel(
|
||||
downloadNewChapters(newChapters)
|
||||
}
|
||||
} else {
|
||||
state.source.fetchChaptersForMergedManga(state.manga, manualFetch, true, dedupe)
|
||||
state.source.fetchChaptersForMergedManga(state.manga, manualFetch)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -1548,6 +1549,9 @@ class MangaScreenModel(
|
||||
) : Dialog
|
||||
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
|
||||
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||
/* SY -->
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
SY <-- */
|
||||
data class SetFetchInterval(val manga: Manga) : Dialog
|
||||
|
||||
// SY -->
|
||||
@@ -1580,6 +1584,12 @@ class MangaScreenModel(
|
||||
updateSuccessState { it.copy(dialog = Dialog.FullCover) }
|
||||
}
|
||||
|
||||
/* SY -->
|
||||
fun showMigrateDialog(duplicate: Manga) {
|
||||
val manga = successState?.manga ?: return
|
||||
updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) }
|
||||
} SY <-- */
|
||||
|
||||
fun setExcludedScanlators(excludedScanlators: Set<String>) {
|
||||
screenModelScope.launchIO {
|
||||
setExcludedScanlators.await(mangaId, excludedScanlators)
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.elevation.SurfaceColors
|
||||
import com.google.android.material.transition.platform.MaterialContainerTransform
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.chrisbanes.insetter.applyInsetter
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.model.readingMode
|
||||
@@ -61,6 +62,7 @@ import eu.kanade.presentation.reader.appbars.NavBarType
|
||||
import eu.kanade.presentation.reader.appbars.ReaderAppBars
|
||||
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
|
||||
@@ -123,6 +125,7 @@ import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class ReaderActivity : BaseActivity() {
|
||||
@@ -1197,8 +1200,8 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.trueColor().changes()
|
||||
.onEach(::setTrueColor)
|
||||
preferences.displayProfile().changes()
|
||||
.onEach { setDisplayProfile(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.cutoutShort().changes()
|
||||
@@ -1266,13 +1269,21 @@ 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)
|
||||
}
|
||||
}
|
||||
val data = outputStream.toByteArray()
|
||||
SubsamplingScaleImageView.setDisplayProfile(data)
|
||||
TachiyomiImageDecoder.displayProfile = data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1141,7 +1141,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
|
||||
return imageSaver.save(
|
||||
image = Image.Page(
|
||||
inputStream = { ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, 0, bg) },
|
||||
inputStream = { ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, 0, bg).inputStream() },
|
||||
name = filename,
|
||||
location = location,
|
||||
),
|
||||
|
||||
@@ -43,7 +43,9 @@ internal class ZipPageLoader(file: UniFile, context: Context) : PageLoader() {
|
||||
}
|
||||
|
||||
private val apacheZip: ZipFile? = if (!file.isEncryptedZip() && Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
|
||||
ZipFile(channel)
|
||||
ZipFile.Builder()
|
||||
.setSeekableByteChannel(channel)
|
||||
.get()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,23 +18,27 @@ import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.os.postDelayed
|
||||
import androidx.core.view.isVisible
|
||||
import coil3.BitmapImage
|
||||
import coil3.dispose
|
||||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Precision
|
||||
import coil3.size.ViewSizeResolver
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_IN_OUT_QUAD
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_OUT_QUAD
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import eu.kanade.tachiyomi.data.coil.cropBorders
|
||||
import eu.kanade.tachiyomi.data.coil.customDecoder
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import okio.BufferedSource
|
||||
|
||||
/**
|
||||
* A wrapper view for showing page image.
|
||||
@@ -140,14 +144,14 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setImage(inputStream: InputStream, isAnimated: Boolean, config: Config) {
|
||||
fun setImage(source: BufferedSource, isAnimated: Boolean, config: Config) {
|
||||
this.config = config
|
||||
if (isAnimated) {
|
||||
prepareAnimatedImageView()
|
||||
setAnimatedImage(inputStream, config)
|
||||
setAnimatedImage(source, config)
|
||||
} else {
|
||||
prepareNonAnimatedImageView()
|
||||
setNonAnimatedImage(inputStream, config)
|
||||
setNonAnimatedImage(source, config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +260,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun setNonAnimatedImage(
|
||||
image: Any,
|
||||
data: Any,
|
||||
config: Config,
|
||||
) = (pageView as? SubsamplingScaleImageView)?.apply {
|
||||
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
|
||||
@@ -277,12 +281,36 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
},
|
||||
)
|
||||
|
||||
when (image) {
|
||||
is BitmapDrawable -> setImage(ImageSource.bitmap(image.bitmap))
|
||||
is InputStream -> setImage(ImageSource.inputStream(image))
|
||||
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}")
|
||||
if (isWebtoon) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(data)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.target(
|
||||
onSuccess = { result ->
|
||||
val image = result as BitmapImage
|
||||
setImage(ImageSource.bitmap(image.bitmap))
|
||||
isVisible = true
|
||||
},
|
||||
onError = {
|
||||
this@ReaderPageImageView.onImageLoadError()
|
||||
},
|
||||
)
|
||||
.size(ViewSizeResolver(this@ReaderPageImageView))
|
||||
.precision(Precision.INEXACT)
|
||||
.cropBorders(config.cropBorders)
|
||||
.customDecoder(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
context.imageLoader.enqueue(request)
|
||||
} else {
|
||||
when (data) {
|
||||
is BitmapDrawable -> setImage(ImageSource.bitmap(data.bitmap))
|
||||
is BufferedSource -> setImage(ImageSource.inputStream(data.inputStream()))
|
||||
else -> throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
|
||||
}
|
||||
isVisible = true
|
||||
}
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
private fun prepareAnimatedImageView() {
|
||||
@@ -325,26 +353,22 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun setAnimatedImage(
|
||||
image: Any,
|
||||
data: Any,
|
||||
config: Config,
|
||||
) = (pageView as? AppCompatImageView)?.apply {
|
||||
if (this is PhotoView) {
|
||||
setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration())
|
||||
}
|
||||
|
||||
val data = when (image) {
|
||||
is Drawable -> image
|
||||
is InputStream -> ByteBuffer.wrap(image.readBytes())
|
||||
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}")
|
||||
}
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(data)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.target(
|
||||
onSuccess = { result ->
|
||||
setImageDrawable(result.asDrawable(context.resources))
|
||||
(result as? Animatable)?.start()
|
||||
val drawable = result.asDrawable(context.resources)
|
||||
setImageDrawable(drawable)
|
||||
(drawable as? Animatable)?.start()
|
||||
isVisible = true
|
||||
this@ReaderPageImageView.onImageLoaded()
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc
|
||||
var doubleTapAnimDuration = 500
|
||||
var volumeKeysEnabled = false
|
||||
var volumeKeysInverted = false
|
||||
var trueColor = false
|
||||
var alwaysShowChapterTransition = true
|
||||
var navigationMode = 0
|
||||
protected set
|
||||
@@ -58,9 +57,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc
|
||||
readerPreferences.readWithVolumeKeysInverted()
|
||||
.register({ volumeKeysInverted = it })
|
||||
|
||||
readerPreferences.trueColor()
|
||||
.register({ trueColor = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.alwaysShowChapterTransition()
|
||||
.register({ alwaysShowChapterTransition = it })
|
||||
|
||||
|
||||
@@ -19,15 +19,14 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
@@ -159,53 +158,45 @@ class PagerPageHolder(
|
||||
val streamFn2 = extraPage?.stream
|
||||
|
||||
try {
|
||||
val (bais, isAnimated, background) = withIOContext {
|
||||
streamFn().buffered(16).use { stream ->
|
||||
val (source, isAnimated, background) = withIOContext {
|
||||
streamFn().buffered(16).use { source ->
|
||||
// SY -->
|
||||
(
|
||||
if (extraPage != null) {
|
||||
streamFn2?.invoke()
|
||||
?.buffered(16)
|
||||
if (extraPage != null) {
|
||||
streamFn2?.invoke()
|
||||
?.buffered(16)
|
||||
} else {
|
||||
null
|
||||
}.use { source2 ->
|
||||
val itemSource = if (viewer.config.dualPageSplit) {
|
||||
process(item.first, Buffer().readFrom(source))
|
||||
} else {
|
||||
mergePages(Buffer().readFrom(source), source2?.let { Buffer().readFrom(it) })
|
||||
}
|
||||
// SY <--
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(itemSource)
|
||||
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
||||
ImageUtil.chooseBackground(context, itemSource.peek())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
).use { stream2 ->
|
||||
if (viewer.config.dualPageSplit) {
|
||||
process(item.first, stream)
|
||||
} else {
|
||||
mergePages(stream, stream2)
|
||||
}.use { itemStream ->
|
||||
// SY <--
|
||||
val bais = ByteArrayInputStream(itemStream.readBytes())
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(bais)
|
||||
bais.reset()
|
||||
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
||||
ImageUtil.chooseBackground(context, bais)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
bais.reset()
|
||||
Triple(bais, isAnimated, background)
|
||||
}
|
||||
}
|
||||
Triple(itemSource, isAnimated, background)
|
||||
}
|
||||
}
|
||||
}
|
||||
withUIContext {
|
||||
bais.use {
|
||||
setImage(
|
||||
it,
|
||||
isAnimated,
|
||||
Config(
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
minimumScaleType = viewer.config.imageScaleType,
|
||||
cropBorders = viewer.config.imageCropBorders,
|
||||
zoomStartPosition = viewer.config.imageZoomType,
|
||||
landscapeZoom = viewer.config.landscapeZoom,
|
||||
),
|
||||
)
|
||||
if (!isAnimated) {
|
||||
pageBackground = background
|
||||
}
|
||||
setImage(
|
||||
source,
|
||||
isAnimated,
|
||||
Config(
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
minimumScaleType = viewer.config.imageScaleType,
|
||||
cropBorders = viewer.config.imageCropBorders,
|
||||
zoomStartPosition = viewer.config.imageZoomType,
|
||||
landscapeZoom = viewer.config.landscapeZoom,
|
||||
),
|
||||
)
|
||||
if (!isAnimated) {
|
||||
pageBackground = background
|
||||
}
|
||||
removeErrorLayout()
|
||||
}
|
||||
@@ -217,124 +208,119 @@ class PagerPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(page: ReaderPage, imageStream: BufferedInputStream): InputStream {
|
||||
private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource {
|
||||
if (viewer.config.dualPageRotateToFit) {
|
||||
return rotateDualPage(imageStream)
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (!viewer.config.dualPageSplit) {
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
|
||||
if (page is InsertPage) {
|
||||
return splitInHalf(imageStream)
|
||||
return splitInHalf(imageSource)
|
||||
}
|
||||
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (!isDoublePage) {
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
|
||||
onPageSplit(page)
|
||||
|
||||
return splitInHalf(imageStream)
|
||||
return splitInHalf(imageSource)
|
||||
}
|
||||
|
||||
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageStream, rotation)
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageStream
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergePages(imageStream: InputStream, imageStream2: InputStream?): InputStream {
|
||||
private fun mergePages(imageSource: BufferedSource, imageSource2: BufferedSource?): BufferedSource {
|
||||
// Handle adding a center margin to wide images if requested
|
||||
if (imageStream2 == null) {
|
||||
return if (imageStream is BufferedInputStream &&
|
||||
!ImageUtil.isAnimatedAndSupported(imageStream) &&
|
||||
ImageUtil.isWideImage(imageStream) &&
|
||||
if (imageSource2 == null) {
|
||||
return if (
|
||||
!ImageUtil.isAnimatedAndSupported(imageSource) &&
|
||||
ImageUtil.isWideImage(imageSource) &&
|
||||
viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
|
||||
!viewer.config.imageCropBorders
|
||||
) {
|
||||
ImageUtil.addHorizontalCenterMargin(imageStream, height, context)
|
||||
ImageUtil.addHorizontalCenterMargin(imageSource, height, context)
|
||||
} else {
|
||||
imageStream
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
if (page.fullPage) return imageStream
|
||||
if (ImageUtil.isAnimatedAndSupported(imageStream)) {
|
||||
if (page.fullPage) return imageSource
|
||||
if (ImageUtil.isAnimatedAndSupported(imageSource)) {
|
||||
page.fullPage = true
|
||||
splitDoublePages()
|
||||
return imageStream
|
||||
} else if (ImageUtil.isAnimatedAndSupported(imageStream2)) {
|
||||
return imageSource
|
||||
} else if (ImageUtil.isAnimatedAndSupported(imageSource2)) {
|
||||
page.isolatedPage = true
|
||||
extraPage?.fullPage = true
|
||||
splitDoublePages()
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = try {
|
||||
ImageDecoder.newInstance(imageBytes.inputStream())?.decode()
|
||||
ImageDecoder.newInstance(imageSource.inputStream())?.decode()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Cannot combine pages" }
|
||||
null
|
||||
}
|
||||
if (imageBitmap == null) {
|
||||
imageStream2.close()
|
||||
imageStream.close()
|
||||
imageSource2.close()
|
||||
page.fullPage = true
|
||||
splitDoublePages()
|
||||
logcat(LogPriority.ERROR) { "Cannot combine pages" }
|
||||
return imageBytes.inputStream()
|
||||
return imageSource
|
||||
}
|
||||
scope.launch { progressIndicator.setProgress(96) }
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
if (height < width) {
|
||||
imageStream2.close()
|
||||
imageStream.close()
|
||||
imageSource2.close()
|
||||
page.fullPage = true
|
||||
splitDoublePages()
|
||||
return imageBytes.inputStream()
|
||||
return imageSource
|
||||
}
|
||||
|
||||
val imageBytes2 = imageStream2.readBytes()
|
||||
val imageBitmap2 = try {
|
||||
ImageDecoder.newInstance(imageBytes2.inputStream())?.decode()
|
||||
ImageDecoder.newInstance(imageSource2.inputStream())?.decode()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Cannot combine pages" }
|
||||
null
|
||||
}
|
||||
if (imageBitmap2 == null) {
|
||||
imageStream2.close()
|
||||
imageStream.close()
|
||||
imageSource2.close()
|
||||
extraPage?.fullPage = true
|
||||
page.isolatedPage = true
|
||||
splitDoublePages()
|
||||
logcat(LogPriority.ERROR) { "Cannot combine pages" }
|
||||
return imageBytes.inputStream()
|
||||
return imageSource
|
||||
}
|
||||
scope.launch { progressIndicator.setProgress(97) }
|
||||
val height2 = imageBitmap2.height
|
||||
val width2 = imageBitmap2.width
|
||||
|
||||
if (height2 < width2) {
|
||||
imageStream2.close()
|
||||
imageStream.close()
|
||||
imageSource2.close()
|
||||
extraPage?.fullPage = true
|
||||
page.isolatedPage = true
|
||||
splitDoublePages()
|
||||
return imageBytes.inputStream()
|
||||
return imageSource
|
||||
}
|
||||
val isLTR = (viewer !is R2LPagerViewer) xor viewer.config.invertDoublePages
|
||||
|
||||
imageStream.close()
|
||||
imageStream2.close()
|
||||
imageSource.close()
|
||||
imageSource2.close()
|
||||
|
||||
val centerMargin = if (viewer.config.centerMarginType and PagerConfig.CenterMarginType.DOUBLE_PAGE_CENTER_MARGIN > 0 && !viewer.config.imageCropBorders) {
|
||||
96 / (this.height.coerceAtLeast(1) / max(height, height2).coerceAtLeast(1)).coerceAtLeast(1)
|
||||
@@ -363,7 +349,7 @@ class PagerPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitInHalf(imageStream: InputStream): InputStream {
|
||||
private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
|
||||
var side = when {
|
||||
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
|
||||
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT
|
||||
@@ -387,7 +373,7 @@ class PagerPageHolder(
|
||||
0
|
||||
}
|
||||
|
||||
return ImageUtil.splitInHalf(imageStream, side, sideMargin)
|
||||
return ImageUtil.splitInHalf(imageSource, side, sideMargin)
|
||||
}
|
||||
|
||||
private fun onPageSplit(page: ReaderPage) {
|
||||
|
||||
@@ -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 <--
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+17
-23
@@ -22,15 +22,14 @@ import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Holder of the webtoon reader for a single page of a chapter.
|
||||
@@ -118,6 +117,7 @@ class WebtoonPageHolder(
|
||||
removeErrorLayout()
|
||||
frame.recycle()
|
||||
progressIndicator.setProgress(0)
|
||||
progressContainer.isVisible = true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,16 +187,14 @@ class WebtoonPageHolder(
|
||||
val streamFn = page?.stream ?: return
|
||||
|
||||
try {
|
||||
val (openStream, isAnimated) = withIOContext {
|
||||
val stream = streamFn().buffered(16)
|
||||
val openStream = process(stream)
|
||||
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(stream)
|
||||
Pair(openStream, isAnimated)
|
||||
val (source, isAnimated) = withIOContext {
|
||||
val source = streamFn().use { process(Buffer().readFrom(it)) }
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||
Pair(source, isAnimated)
|
||||
}
|
||||
withUIContext {
|
||||
frame.setImage(
|
||||
openStream,
|
||||
source,
|
||||
isAnimated,
|
||||
ReaderPageImageView.Config(
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
@@ -206,10 +204,6 @@ class WebtoonPageHolder(
|
||||
)
|
||||
removeErrorLayout()
|
||||
}
|
||||
// Suspend the coroutine to close the input stream only when the WebtoonPageHolder is recycled
|
||||
suspendCancellableCoroutine<Nothing> { continuation ->
|
||||
continuation.invokeOnCancellation { openStream.close() }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext {
|
||||
@@ -218,29 +212,29 @@ class WebtoonPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(imageStream: BufferedInputStream): InputStream {
|
||||
private fun process(imageSource: BufferedSource): BufferedSource {
|
||||
if (viewer.config.dualPageRotateToFit) {
|
||||
return rotateDualPage(imageStream)
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (viewer.config.dualPageSplit) {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (isDoublePage) {
|
||||
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
|
||||
return ImageUtil.splitAndMerge(imageStream, upperSide)
|
||||
return ImageUtil.splitAndMerge(imageSource, upperSide)
|
||||
}
|
||||
}
|
||||
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
|
||||
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageStream, rotation)
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageStream
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
@@ -23,7 +23,7 @@ class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
|
||||
onSuccess = {
|
||||
Toast.makeText(
|
||||
this@GoogleDriveLoginActivity,
|
||||
stringResource(MR.strings.google_drive_login_success),
|
||||
stringResource(SYMR.strings.google_drive_login_success),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
|
||||
@@ -32,7 +32,7 @@ class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
|
||||
onFailure = { error ->
|
||||
Toast.makeText(
|
||||
this@GoogleDriveLoginActivity,
|
||||
stringResource(MR.strings.google_drive_login_failed, error),
|
||||
stringResource(SYMR.strings.google_drive_login_failed, error),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
returnToSettings()
|
||||
@@ -42,7 +42,7 @@ class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
|
||||
} else if (error != null) {
|
||||
Toast.makeText(
|
||||
this@GoogleDriveLoginActivity,
|
||||
stringResource(MR.strings.google_drive_login_failed, error),
|
||||
stringResource(SYMR.strings.google_drive_login_failed, error),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import mihon.feature.upcoming.UpcomingScreen
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
@@ -91,6 +92,7 @@ object UpdatesTab : Tab {
|
||||
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onCalendarClicked = { navigator.push(UpcomingScreen()) },
|
||||
)
|
||||
|
||||
val onDismissDialog = { screenModel.setDialog(null) }
|
||||
|
||||
@@ -20,12 +20,13 @@ class CrashLogUtil(
|
||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
||||
) {
|
||||
|
||||
suspend fun dumpLogs() = withNonCancellableContext {
|
||||
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
|
||||
try {
|
||||
val file = context.createFileInCacheDir("tachiyomi_crash_logs.txt")
|
||||
|
||||
file.appendText(getDebugInfo() + "\n\n")
|
||||
getExtensionsInfo()?.let { file.appendText("$it\n\n") }
|
||||
exception?.let { file.appendText("$it\n\n") }
|
||||
|
||||
Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}").waitFor()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,699 +1,15 @@
|
||||
package exh
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
||||
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.online.all.NHentai
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import exh.eh.EHentaiUpdateWorker
|
||||
import exh.log.xLogE
|
||||
import exh.source.BlacklistedSources
|
||||
import exh.source.EH_SOURCE_ID
|
||||
import exh.source.HBROWSE_SOURCE_ID
|
||||
import exh.source.MERGED_SOURCE_ID
|
||||
import exh.source.TSUMINO_SOURCE_ID
|
||||
import exh.util.nullIfBlank
|
||||
import exh.util.under
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.preference.TriState
|
||||
import tachiyomi.core.common.preference.getAndSet
|
||||
import tachiyomi.core.common.preference.getEnum
|
||||
import tachiyomi.core.common.preference.minusAssign
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.data.DatabaseHandler
|
||||
import tachiyomi.data.category.CategoryMapper
|
||||
import tachiyomi.data.chapter.ChapterMapper
|
||||
import tachiyomi.domain.backup.service.BackupPreferences
|
||||
import tachiyomi.domain.chapter.interactor.DeleteChapters
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.interactor.GetMangaBySource
|
||||
import tachiyomi.domain.manga.interactor.InsertMergedReference
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
import tachiyomi.domain.manga.model.MergedMangaReference
|
||||
import tachiyomi.domain.source.interactor.InsertFeedSavedSearch
|
||||
import tachiyomi.domain.source.interactor.InsertSavedSearch
|
||||
import tachiyomi.domain.source.model.FeedSavedSearch
|
||||
import tachiyomi.domain.source.model.SavedSearch
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
object EXHMigrations {
|
||||
private val handler: DatabaseHandler by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val getManga: GetManga by injectLazy()
|
||||
private val getMangaBySource: GetMangaBySource by injectLazy()
|
||||
private val updateManga: UpdateManga by injectLazy()
|
||||
private val updateChapter: UpdateChapter by injectLazy()
|
||||
private val deleteChapters: DeleteChapters by injectLazy()
|
||||
private val insertMergedReference: InsertMergedReference by injectLazy()
|
||||
private val insertSavedSearch: InsertSavedSearch by injectLazy()
|
||||
private val insertFeedSavedSearch: InsertFeedSavedSearch by injectLazy()
|
||||
|
||||
/**
|
||||
* Performs a migration when the application is updated.
|
||||
*
|
||||
* @return true if a migration is performed, false otherwise.
|
||||
*/
|
||||
fun upgrade(
|
||||
context: Context,
|
||||
preferenceStore: PreferenceStore,
|
||||
basePreferences: BasePreferences,
|
||||
uiPreferences: UiPreferences,
|
||||
networkPreferences: NetworkPreferences,
|
||||
sourcePreferences: SourcePreferences,
|
||||
securityPreferences: SecurityPreferences,
|
||||
libraryPreferences: LibraryPreferences,
|
||||
readerPreferences: ReaderPreferences,
|
||||
backupPreferences: BackupPreferences,
|
||||
trackerManager: TrackerManager,
|
||||
pagePreviewCache: PagePreviewCache,
|
||||
): Boolean {
|
||||
val lastVersionCode = preferenceStore.getInt("eh_last_version_code", 0)
|
||||
val oldVersion = lastVersionCode.get()
|
||||
try {
|
||||
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||
lastVersionCode.set(BuildConfig.VERSION_CODE)
|
||||
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
BackupCreateJob.setupTask(context)
|
||||
EHentaiUpdateWorker.scheduleBackground(context)
|
||||
|
||||
// Fresh install
|
||||
if (oldVersion == 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
if (oldVersion under 4) {
|
||||
updateSourceId(HBROWSE_SOURCE_ID, 6912)
|
||||
// Migrate BHrowse URLs
|
||||
val hBrowseManga = runBlocking { getMangaBySource.await(HBROWSE_SOURCE_ID) }
|
||||
val mangaUpdates = hBrowseManga.map {
|
||||
MangaUpdate(it.id, url = it.url + "/c00001/")
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
updateManga.awaitAll(mangaUpdates)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 6) {
|
||||
updateSourceId(NHentai.otherId, 6907)
|
||||
}
|
||||
if (oldVersion under 7) {
|
||||
val mergedMangas = runBlocking { getMangaBySource.await(MERGED_SOURCE_ID) }
|
||||
|
||||
if (mergedMangas.isNotEmpty()) {
|
||||
val mangaConfigs = mergedMangas.mapNotNull { mergedManga ->
|
||||
readMangaConfig(mergedManga)?.let { mergedManga to it }
|
||||
}
|
||||
if (mangaConfigs.isNotEmpty()) {
|
||||
val mangaToUpdate = mutableListOf<MangaUpdate>()
|
||||
val mergedMangaReferences = mutableListOf<MergedMangaReference>()
|
||||
mangaConfigs.onEach { mergedManga ->
|
||||
val newFirst = mergedManga.second.children.firstOrNull()?.url?.let {
|
||||
if (runBlocking { getManga.await(it, MERGED_SOURCE_ID) } != null) return@onEach
|
||||
mangaToUpdate += MangaUpdate(id = mergedManga.first.id, url = it)
|
||||
mergedManga.first.copy(url = it)
|
||||
} ?: mergedManga.first
|
||||
mergedMangaReferences += MergedMangaReference(
|
||||
id = -1,
|
||||
isInfoManga = false,
|
||||
getChapterUpdates = false,
|
||||
chapterSortMode = 0,
|
||||
chapterPriority = 0,
|
||||
downloadChapters = false,
|
||||
mergeId = newFirst.id,
|
||||
mergeUrl = newFirst.url,
|
||||
mangaId = newFirst.id,
|
||||
mangaUrl = newFirst.url,
|
||||
mangaSourceId = MERGED_SOURCE_ID,
|
||||
)
|
||||
mergedManga.second.children.distinct().forEachIndexed { index, mangaSource ->
|
||||
val load = mangaSource.load() ?: return@forEachIndexed
|
||||
mergedMangaReferences += MergedMangaReference(
|
||||
id = -1,
|
||||
isInfoManga = index == 0,
|
||||
getChapterUpdates = true,
|
||||
chapterSortMode = 0,
|
||||
chapterPriority = 0,
|
||||
downloadChapters = true,
|
||||
mergeId = newFirst.id,
|
||||
mergeUrl = newFirst.url,
|
||||
mangaId = load.manga.id,
|
||||
mangaUrl = load.manga.url,
|
||||
mangaSourceId = load.source.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
runBlocking {
|
||||
updateManga.awaitAll(mangaToUpdate)
|
||||
insertMergedReference.awaitAll(mergedMangaReferences)
|
||||
}
|
||||
|
||||
val loadedMangaList = mangaConfigs
|
||||
.map { it.second.children }
|
||||
.flatten()
|
||||
.mapNotNull { it.load() }
|
||||
.distinct()
|
||||
val chapters =
|
||||
runBlocking {
|
||||
handler.awaitList {
|
||||
ehQueries.getChaptersByMangaIds(
|
||||
mergedMangas.map { it.id },
|
||||
ChapterMapper::mapChapter,
|
||||
)
|
||||
}
|
||||
}
|
||||
val mergedMangaChapters =
|
||||
runBlocking {
|
||||
handler.awaitList {
|
||||
ehQueries.getChaptersByMangaIds(
|
||||
loadedMangaList.map { it.manga.id },
|
||||
ChapterMapper::mapChapter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val mergedMangaChaptersMatched = mergedMangaChapters.mapNotNull { chapter ->
|
||||
loadedMangaList.firstOrNull {
|
||||
it.manga.id == chapter.id
|
||||
}?.let { it to chapter }
|
||||
}
|
||||
val parsedChapters = chapters.filter {
|
||||
it.read || it.lastPageRead != 0L
|
||||
}.mapNotNull { chapter -> readUrlConfig(chapter.url)?.let { chapter to it } }
|
||||
val chaptersToUpdate = mutableListOf<ChapterUpdate>()
|
||||
parsedChapters.forEach { parsedChapter ->
|
||||
mergedMangaChaptersMatched.firstOrNull {
|
||||
it.second.url == parsedChapter.second.url &&
|
||||
it.first.source.id == parsedChapter.second.source &&
|
||||
it.first.manga.url == parsedChapter.second.mangaUrl
|
||||
}?.let {
|
||||
chaptersToUpdate += ChapterUpdate(
|
||||
it.second.id,
|
||||
read = parsedChapter.first.read,
|
||||
lastPageRead = parsedChapter.first.lastPageRead,
|
||||
)
|
||||
}
|
||||
}
|
||||
runBlocking {
|
||||
deleteChapters.await(mergedMangaChapters.map { it.id })
|
||||
updateChapter.awaitAll(chaptersToUpdate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 12) {
|
||||
// Force MAL log out due to login flow change
|
||||
trackerManager.myAnimeList.logout()
|
||||
}
|
||||
if (oldVersion under 14) {
|
||||
// Migrate DNS over HTTPS setting
|
||||
val wasDohEnabled = prefs.getBoolean("enable_doh", false)
|
||||
if (wasDohEnabled) {
|
||||
prefs.edit {
|
||||
putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE)
|
||||
remove("enable_doh")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 16) {
|
||||
// Reset rotation to Free after replacing Lock
|
||||
if (prefs.contains("pref_rotation_type_key")) {
|
||||
prefs.edit {
|
||||
putInt("pref_rotation_type_key", 1)
|
||||
}
|
||||
}
|
||||
// Disable update check for Android 5.x users
|
||||
// if (BuildConfig.INCLUDE_UPDATER && Build.VERSION.SDK_INT under Build.VERSION_CODES.M) {
|
||||
// UpdaterJob.cancelTask(context)
|
||||
// }
|
||||
}
|
||||
if (oldVersion under 17) {
|
||||
// Migrate Rotation and Viewer values to default values for viewer_flags
|
||||
val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) {
|
||||
1 -> ReaderOrientation.FREE.flagValue
|
||||
2 -> ReaderOrientation.PORTRAIT.flagValue
|
||||
3 -> ReaderOrientation.LANDSCAPE.flagValue
|
||||
4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue
|
||||
5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue
|
||||
else -> ReaderOrientation.FREE.flagValue
|
||||
}
|
||||
|
||||
// Reading mode flag and prefValue is the same value
|
||||
val newReadingMode = prefs.getInt("pref_default_viewer_key", 1)
|
||||
|
||||
prefs.edit {
|
||||
putInt("pref_default_orientation_type_key", newOrientation)
|
||||
remove("pref_rotation_type_key")
|
||||
putInt("pref_default_reading_mode_key", newReadingMode)
|
||||
remove("pref_default_viewer_key")
|
||||
}
|
||||
|
||||
// Delete old mangadex trackers
|
||||
runBlocking {
|
||||
handler.await { ehQueries.deleteBySyncId(6) }
|
||||
}
|
||||
}
|
||||
if (oldVersion under 18) {
|
||||
val readerTheme = readerPreferences.readerTheme().get()
|
||||
if (readerTheme == 4) {
|
||||
readerPreferences.readerTheme().set(3)
|
||||
}
|
||||
val updateInterval = libraryPreferences.autoUpdateInterval().get()
|
||||
if (updateInterval == 1 || updateInterval == 2) {
|
||||
libraryPreferences.autoUpdateInterval().set(3)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 20) {
|
||||
try {
|
||||
val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0 /* ALPHABETICAL */)
|
||||
val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true)
|
||||
|
||||
val newSortingMode = when (oldSortingMode) {
|
||||
0 -> "ALPHABETICAL"
|
||||
1 -> "LAST_READ"
|
||||
2 -> "LAST_MANGA_UPDATE"
|
||||
3 -> "UNREAD_COUNT"
|
||||
4 -> "TOTAL_CHAPTERS"
|
||||
6 -> "LATEST_CHAPTER"
|
||||
7 -> "DRAG_AND_DROP"
|
||||
8 -> "DATE_ADDED"
|
||||
9 -> "TAG_LIST"
|
||||
10 -> "CHAPTER_FETCH_DATE"
|
||||
else -> "ALPHABETICAL"
|
||||
}
|
||||
|
||||
val newSortingDirection = when (oldSortingDirection) {
|
||||
true -> "ASCENDING"
|
||||
else -> "DESCENDING"
|
||||
}
|
||||
|
||||
prefs.edit(commit = true) {
|
||||
remove(libraryPreferences.sortingMode().key())
|
||||
remove("library_sorting_ascending")
|
||||
}
|
||||
|
||||
prefs.edit {
|
||||
putString(libraryPreferences.sortingMode().key(), newSortingMode)
|
||||
putString("library_sorting_ascending", newSortingDirection)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(throwable = e) { "Already done migration" }
|
||||
}
|
||||
}
|
||||
if (oldVersion under 22) {
|
||||
// Handle removed every 3, 4, 6, and 8 hour library updates
|
||||
val updateInterval = libraryPreferences.autoUpdateInterval().get()
|
||||
if (updateInterval in listOf(3, 4, 6, 8)) {
|
||||
libraryPreferences.autoUpdateInterval().set(12)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 23) {
|
||||
val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true)
|
||||
if (!oldUpdateOngoingOnly) {
|
||||
libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED
|
||||
}
|
||||
}
|
||||
if (oldVersion under 24) {
|
||||
try {
|
||||
sequenceOf(
|
||||
"fav-sync",
|
||||
"fav-sync.management",
|
||||
"fav-sync.lock",
|
||||
"fav-sync.note",
|
||||
).map {
|
||||
File(context.filesDir, it)
|
||||
}.filter(File::exists).forEach {
|
||||
if (it.isDirectory) {
|
||||
it.deleteRecursively()
|
||||
} else {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
xLogE("Failed to delete old favorites database", e)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 27) {
|
||||
val oldSecureScreen = prefs.getBoolean("secure_screen", false)
|
||||
if (oldSecureScreen) {
|
||||
securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS)
|
||||
}
|
||||
if (
|
||||
DeviceUtil.isMiui &&
|
||||
basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller
|
||||
.PACKAGEINSTALLER
|
||||
) {
|
||||
basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 28) {
|
||||
if (prefs.getString("pref_display_mode_library", null) == "NO_TITLE_GRID") {
|
||||
prefs.edit(commit = true) {
|
||||
putString("pref_display_mode_library", "COVER_ONLY_GRID")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 29) {
|
||||
if (prefs.getString("pref_display_mode_catalogue", null) == "NO_TITLE_GRID") {
|
||||
prefs.edit(commit = true) {
|
||||
putString("pref_display_mode_catalogue", "COMPACT_GRID")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 31) {
|
||||
runBlocking {
|
||||
val savedSearch = prefs.getStringSet("eh_saved_searches", emptySet())?.mapNotNull {
|
||||
runCatching {
|
||||
val content = Json.decodeFromString<JsonObject>(it.substringAfter(':'))
|
||||
SavedSearch(
|
||||
id = -1,
|
||||
source = it.substringBefore(':').toLongOrNull()
|
||||
?: return@runCatching null,
|
||||
name = content["name"]!!.jsonPrimitive.content,
|
||||
query = content["query"]!!.jsonPrimitive.contentOrNull?.nullIfBlank(),
|
||||
filtersJson = Json.encodeToString(content["filters"]!!.jsonArray),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
if (!savedSearch.isNullOrEmpty()) {
|
||||
insertSavedSearch.awaitAll(savedSearch)
|
||||
}
|
||||
val feedSavedSearch = prefs.getStringSet("latest_tab_sources", emptySet())?.map {
|
||||
FeedSavedSearch(
|
||||
id = -1,
|
||||
source = it.toLong(),
|
||||
savedSearch = null,
|
||||
global = true,
|
||||
)
|
||||
}
|
||||
if (!feedSavedSearch.isNullOrEmpty()) {
|
||||
insertFeedSavedSearch.awaitAll(feedSavedSearch)
|
||||
}
|
||||
}
|
||||
prefs.edit(commit = true) {
|
||||
remove("eh_saved_searches")
|
||||
remove("latest_tab_sources")
|
||||
}
|
||||
}
|
||||
if (oldVersion under 32) {
|
||||
val oldReaderTap = prefs.getBoolean("reader_tap", false)
|
||||
if (!oldReaderTap) {
|
||||
readerPreferences.navigationModePager().set(5)
|
||||
readerPreferences.navigationModeWebtoon().set(5)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 38) {
|
||||
// Handle renamed enum values
|
||||
val newSortingMode = when (
|
||||
val oldSortingMode = prefs.getString(libraryPreferences.sortingMode().key(), "ALPHABETICAL")
|
||||
) {
|
||||
"LAST_CHECKED" -> "LAST_MANGA_UPDATE"
|
||||
"UNREAD" -> "UNREAD_COUNT"
|
||||
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
|
||||
"DRAG_AND_DROP" -> "ALPHABETICAL"
|
||||
else -> oldSortingMode
|
||||
}
|
||||
prefs.edit {
|
||||
putString(libraryPreferences.sortingMode().key(), newSortingMode)
|
||||
}
|
||||
runBlocking {
|
||||
handler.await(true) {
|
||||
categoriesQueries.getCategories(CategoryMapper::mapCategory).executeAsList()
|
||||
.filter { (it.flags and 0b00111100L) == 0b00100000L }
|
||||
.forEach {
|
||||
categoriesQueries.update(
|
||||
categoryId = it.id,
|
||||
flags = it.flags and 0b00111100L.inv(),
|
||||
name = null,
|
||||
order = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 39) {
|
||||
prefs.edit {
|
||||
val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit
|
||||
val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!!
|
||||
putString(libraryPreferences.sortingMode().key(), "$sort,$direction")
|
||||
remove("library_sorting_ascending")
|
||||
}
|
||||
}
|
||||
if (oldVersion under 40) {
|
||||
if (backupPreferences.backupInterval().get() == 0) {
|
||||
backupPreferences.backupInterval().set(12)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 41) {
|
||||
val preferences = listOf(
|
||||
libraryPreferences.filterChapterByRead(),
|
||||
libraryPreferences.filterChapterByDownloaded(),
|
||||
libraryPreferences.filterChapterByBookmarked(),
|
||||
libraryPreferences.sortChapterBySourceOrNumber(),
|
||||
libraryPreferences.displayChapterByNameOrNumber(),
|
||||
libraryPreferences.sortChapterByAscendingOrDescending(),
|
||||
)
|
||||
|
||||
prefs.edit {
|
||||
preferences.forEach { preference ->
|
||||
val key = preference.key()
|
||||
val value = prefs.getInt(key, Int.MIN_VALUE)
|
||||
if (value == Int.MIN_VALUE) return@forEach
|
||||
remove(key)
|
||||
putLong(key, value.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 42) {
|
||||
if (uiPreferences.themeMode().isSet()) {
|
||||
prefs.edit {
|
||||
val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit
|
||||
putString(uiPreferences.themeMode().key(), themeMode.uppercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 43) {
|
||||
if (preferenceStore.getBoolean("start_reading_button").get()) {
|
||||
libraryPreferences.showContinueReadingButton().set(true)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 44) {
|
||||
val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE)
|
||||
trackingQueuePref.all.forEach {
|
||||
val (_, lastChapterRead) = it.value.toString().split(":")
|
||||
trackingQueuePref.edit {
|
||||
remove(it.key)
|
||||
putFloat(it.key, lastChapterRead.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 45) {
|
||||
// Force MangaDex log out due to login flow change
|
||||
trackerManager.mdList.logout()
|
||||
}
|
||||
if (oldVersion under 52) {
|
||||
// Removed background jobs
|
||||
context.workManager.cancelAllWorkByTag("UpdateChecker")
|
||||
context.workManager.cancelAllWorkByTag("ExtensionUpdate")
|
||||
prefs.edit {
|
||||
remove("automatic_ext_updates")
|
||||
}
|
||||
val prefKeys = listOf(
|
||||
"pref_filter_library_downloaded",
|
||||
"pref_filter_library_unread",
|
||||
"pref_filter_library_started",
|
||||
"pref_filter_library_bookmarked",
|
||||
"pref_filter_library_completed",
|
||||
"pref_filter_library_lewd",
|
||||
) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" }
|
||||
|
||||
prefKeys.forEach { key ->
|
||||
val pref = preferenceStore.getInt(key, 0)
|
||||
prefs.edit {
|
||||
remove(key)
|
||||
|
||||
val newValue = when (pref.get()) {
|
||||
1 -> TriState.ENABLED_IS
|
||||
2 -> TriState.ENABLED_NOT
|
||||
else -> TriState.DISABLED
|
||||
}
|
||||
|
||||
preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (oldVersion under 53) {
|
||||
// // This was accidentally visible from the reader settings sheet, but should always
|
||||
// // be disabled in release builds.
|
||||
// if (isReleaseBuildType) {
|
||||
// readerPreferences.longStripSplitWebtoon().set(false)
|
||||
// }
|
||||
// }
|
||||
if (oldVersion under 56) {
|
||||
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
|
||||
if (pref.isSet() && "battery_not_low" in pref.get()) {
|
||||
pref.getAndSet { it - "battery_not_low" }
|
||||
}
|
||||
}
|
||||
if (oldVersion under 57) {
|
||||
val pref = preferenceStore.getInt("relative_time", 7)
|
||||
if (pref.get() == 0) {
|
||||
uiPreferences.relativeTime().set(false)
|
||||
}
|
||||
}
|
||||
if (oldVersion under 58) {
|
||||
pagePreviewCache.clear()
|
||||
File(context.cacheDir, PagePreviewCache.PARAMETER_CACHE_DIRECTORY).listFiles()?.forEach {
|
||||
if (it.name == "journal" || it.name.startsWith("journal.")) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
try {
|
||||
it.delete()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion under 59) {
|
||||
val prefsToReplace = listOf(
|
||||
"pref_download_only",
|
||||
"incognito_mode",
|
||||
"last_catalogue_source",
|
||||
"trusted_signatures",
|
||||
"last_app_closed",
|
||||
"library_update_last_timestamp",
|
||||
"library_unseen_updates_count",
|
||||
"last_used_category",
|
||||
"last_app_check",
|
||||
"last_ext_check",
|
||||
"last_version_code",
|
||||
"skip_pre_migration",
|
||||
"eh_auto_update_stats",
|
||||
"storage_dir",
|
||||
)
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key in prefsToReplace },
|
||||
newKey = { Preference.appStateKey(it) },
|
||||
)
|
||||
|
||||
val privatePrefsToReplace = listOf(
|
||||
"sql_password",
|
||||
"encrypt_database",
|
||||
"cbz_password",
|
||||
"password_protect_downloads",
|
||||
"eh_ipb_member_id",
|
||||
"enable_exhentai",
|
||||
"eh_ipb_member_id",
|
||||
"eh_ipb_pass_hash",
|
||||
"eh_igneous",
|
||||
"eh_ehSettingsProfile",
|
||||
"eh_exhSettingsProfile",
|
||||
"eh_settingsKey",
|
||||
"eh_sessionCookie",
|
||||
"eh_hathPerksCookie",
|
||||
)
|
||||
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key in privatePrefsToReplace },
|
||||
newKey = { Preference.privateKey(it) },
|
||||
)
|
||||
|
||||
// Deleting old download cache index files, but might as well clear it all out
|
||||
context.cacheDir.deleteRecursively()
|
||||
}
|
||||
if (oldVersion under 60) {
|
||||
sourcePreferences.extensionRepos().getAndSet {
|
||||
it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet()
|
||||
}
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") },
|
||||
newKey = { Preference.privateKey(it) },
|
||||
)
|
||||
prefs.edit {
|
||||
remove(Preference.appStateKey("trusted_signatures"))
|
||||
}
|
||||
}
|
||||
if (oldVersion under 66) {
|
||||
val cacheImagesToDisk = prefs.getBoolean("cache_archive_manga_on_disk", false)
|
||||
if (cacheImagesToDisk) {
|
||||
readerPreferences.archiveReaderMode().set(ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK)
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion under 66) {
|
||||
if (prefs.getBoolean(Preference.privateKey("encrypt_database"), false)) {
|
||||
context.toast(
|
||||
"Restart the app to load your encrypted library",
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
}
|
||||
|
||||
val appStatePrefsToReplace = listOf(
|
||||
"__PRIVATE_sql_password",
|
||||
"__PRIVATE_encrypt_database",
|
||||
"__PRIVATE_cbz_password",
|
||||
)
|
||||
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key in appStatePrefsToReplace },
|
||||
newKey = { Preference.appStateKey(it.replace("__PRIVATE_", "").trim()) },
|
||||
)
|
||||
}
|
||||
|
||||
// if (oldVersion under 1) { } (1 is current release version)
|
||||
// do stuff here when releasing changed crap
|
||||
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
xLogE("Failed to migrate app from $oldVersion -> ${BuildConfig.VERSION_CODE}!", e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun migrateBackupEntry(manga: Manga): Manga {
|
||||
var newManga = manga
|
||||
@@ -745,102 +61,4 @@ object EXHMigrations {
|
||||
orig
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class UrlConfig(
|
||||
@SerialName("s")
|
||||
val source: Long,
|
||||
@SerialName("u")
|
||||
val url: String,
|
||||
@SerialName("m")
|
||||
val mangaUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class MangaConfig(
|
||||
@SerialName("c")
|
||||
val children: List<MangaSource>,
|
||||
) {
|
||||
companion object {
|
||||
fun readFromUrl(url: String): MangaConfig? {
|
||||
return try {
|
||||
Json.decodeFromString(url)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readMangaConfig(manga: Manga): MangaConfig? {
|
||||
return MangaConfig.readFromUrl(manga.url)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class MangaSource(
|
||||
@SerialName("s")
|
||||
val source: Long,
|
||||
@SerialName("u")
|
||||
val url: String,
|
||||
) {
|
||||
fun load(): LoadedMangaSource? {
|
||||
val manga = runBlocking { getManga.await(url, source) } ?: return null
|
||||
val source = sourceManager.getOrStub(source)
|
||||
return LoadedMangaSource(source, manga)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUrlConfig(url: String): UrlConfig? {
|
||||
return try {
|
||||
Json.decodeFromString(url)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private data class LoadedMangaSource(val source: Source, val manga: Manga)
|
||||
|
||||
private fun updateSourceId(newId: Long, oldId: Long) {
|
||||
runBlocking {
|
||||
handler.await { ehQueries.migrateSource(newId, oldId) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun replacePreferences(
|
||||
preferenceStore: PreferenceStore,
|
||||
filterPredicate: (Map.Entry<String, Any?>) -> Boolean,
|
||||
newKey: (String) -> String,
|
||||
) {
|
||||
preferenceStore.getAll()
|
||||
.filter(filterPredicate)
|
||||
.forEach { (key, value) ->
|
||||
when (value) {
|
||||
is Int -> {
|
||||
preferenceStore.getInt(newKey(key)).set(value)
|
||||
preferenceStore.getInt(key).delete()
|
||||
}
|
||||
is Long -> {
|
||||
preferenceStore.getLong(newKey(key)).set(value)
|
||||
preferenceStore.getLong(key).delete()
|
||||
}
|
||||
is Float -> {
|
||||
preferenceStore.getFloat(newKey(key)).set(value)
|
||||
preferenceStore.getFloat(key).delete()
|
||||
}
|
||||
is String -> {
|
||||
preferenceStore.getString(newKey(key)).set(value)
|
||||
preferenceStore.getString(key).delete()
|
||||
}
|
||||
is Boolean -> {
|
||||
preferenceStore.getBoolean(newKey(key)).set(value)
|
||||
preferenceStore.getBoolean(key).delete()
|
||||
}
|
||||
is Set<*> -> (value as? Set<String>)?.let {
|
||||
preferenceStore.getStringSet(newKey(key)).set(value)
|
||||
preferenceStore.getStringSet(key).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
package exh.debug
|
||||
|
||||
import android.app.Application
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.sync.SyncDataJob
|
||||
import eu.kanade.tachiyomi.source.AndroidSourceManager
|
||||
import eu.kanade.tachiyomi.source.online.all.NHentai
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import exh.EXHMigrations
|
||||
import exh.eh.EHentaiThrottleManager
|
||||
import exh.eh.EHentaiUpdateWorker
|
||||
import exh.metadata.metadata.EHentaiSearchMetadata
|
||||
@@ -25,10 +19,12 @@ import exh.source.nHentaiSourceIds
|
||||
import exh.util.jobScheduler
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import mihon.core.migration.MigrationContext
|
||||
import mihon.core.migration.MigrationJobFactory
|
||||
import mihon.core.migration.MigrationStrategyFactory
|
||||
import mihon.core.migration.Migrator
|
||||
import mihon.core.migration.migrations.migrations
|
||||
import tachiyomi.data.DatabaseHandler
|
||||
import tachiyomi.domain.backup.service.BackupPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetAllManga
|
||||
import tachiyomi.domain.manga.interactor.GetExhFavoriteMangaWithMetadata
|
||||
import tachiyomi.domain.manga.interactor.GetFavorites
|
||||
@@ -36,6 +32,8 @@ import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
||||
import tachiyomi.domain.manga.interactor.GetSearchMetadata
|
||||
import tachiyomi.domain.manga.interactor.InsertFlatMetadata
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.UUID
|
||||
|
||||
@@ -43,16 +41,6 @@ import java.util.UUID
|
||||
object DebugFunctions {
|
||||
private val app: Application by injectLazy()
|
||||
private val handler: DatabaseHandler by injectLazy()
|
||||
private val prefsStore: PreferenceStore by injectLazy()
|
||||
private val basePrefs: BasePreferences by injectLazy()
|
||||
private val uiPrefs: UiPreferences by injectLazy()
|
||||
private val networkPrefs: NetworkPreferences by injectLazy()
|
||||
private val sourcePrefs: SourcePreferences by injectLazy()
|
||||
private val securityPrefs: SecurityPreferences by injectLazy()
|
||||
private val libraryPrefs: LibraryPreferences by injectLazy()
|
||||
private val readerPrefs: ReaderPreferences by injectLazy()
|
||||
private val backupPrefs: BackupPreferences by injectLazy()
|
||||
private val trackerManager: TrackerManager by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val updateManga: UpdateManga by injectLazy()
|
||||
private val getFavorites: GetFavorites by injectLazy()
|
||||
@@ -61,44 +49,21 @@ object DebugFunctions {
|
||||
private val getExhFavoriteMangaWithMetadata: GetExhFavoriteMangaWithMetadata by injectLazy()
|
||||
private val getSearchMetadata: GetSearchMetadata by injectLazy()
|
||||
private val getAllManga: GetAllManga by injectLazy()
|
||||
private val pagePreviewCache: PagePreviewCache by injectLazy()
|
||||
|
||||
fun forceUpgradeMigration() {
|
||||
val lastVersionCode = prefsStore.getInt("eh_last_version_code", 0)
|
||||
lastVersionCode.set(1)
|
||||
EXHMigrations.upgrade(
|
||||
context = app,
|
||||
preferenceStore = prefsStore,
|
||||
basePreferences = basePrefs,
|
||||
uiPreferences = uiPrefs,
|
||||
networkPreferences = networkPrefs,
|
||||
sourcePreferences = sourcePrefs,
|
||||
securityPreferences = securityPrefs,
|
||||
libraryPreferences = libraryPrefs,
|
||||
readerPreferences = readerPrefs,
|
||||
backupPreferences = backupPrefs,
|
||||
trackerManager = trackerManager,
|
||||
pagePreviewCache = pagePreviewCache,
|
||||
)
|
||||
fun forceUpgradeMigration(): Boolean {
|
||||
val migrationContext = MigrationContext(dryrun = false)
|
||||
val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope)
|
||||
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {})
|
||||
val strategy = migrationStrategyFactory.create(1, BuildConfig.VERSION_CODE)
|
||||
return runBlocking { strategy(migrations).await() }
|
||||
}
|
||||
|
||||
fun forceSetupJobs() {
|
||||
val lastVersionCode = prefsStore.getInt("eh_last_version_code", 0)
|
||||
lastVersionCode.set(0)
|
||||
EXHMigrations.upgrade(
|
||||
context = app,
|
||||
preferenceStore = prefsStore,
|
||||
basePreferences = basePrefs,
|
||||
uiPreferences = uiPrefs,
|
||||
networkPreferences = networkPrefs,
|
||||
sourcePreferences = sourcePrefs,
|
||||
securityPreferences = securityPrefs,
|
||||
libraryPreferences = libraryPrefs,
|
||||
readerPreferences = readerPrefs,
|
||||
backupPreferences = backupPrefs,
|
||||
trackerManager = trackerManager,
|
||||
pagePreviewCache = pagePreviewCache,
|
||||
)
|
||||
fun forceSetupJobs(): Boolean {
|
||||
val migrationContext = MigrationContext(dryrun = false)
|
||||
val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope)
|
||||
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {})
|
||||
val strategy = migrationStrategyFactory.create(0, BuildConfig.VERSION_CODE)
|
||||
return runBlocking { strategy(migrations).await() }
|
||||
}
|
||||
|
||||
fun resetAgedFlagInEXHManga() {
|
||||
@@ -340,4 +305,14 @@ object DebugFunctions {
|
||||
}
|
||||
|
||||
fun exportProtobufScheme() = ProtoBufSchemaGenerator.generateSchemaText(Backup.serializer().descriptor)
|
||||
|
||||
fun killSyncJobs() {
|
||||
val context = Injekt.get<Application>()
|
||||
SyncDataJob.stop(context)
|
||||
}
|
||||
|
||||
fun killLibraryJobs() {
|
||||
val context = Injekt.get<Application>()
|
||||
LibraryUpdateJob.stop(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,13 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.BufferedSource
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
@@ -67,7 +70,7 @@ class MemAutoFlushingLookupTable<T>(
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook)
|
||||
}
|
||||
|
||||
private fun InputStream.requireBytes(targetArray: ByteArray, byteCount: Int): Boolean {
|
||||
private fun BufferedSource.requireBytes(targetArray: ByteArray, byteCount: Int): Boolean {
|
||||
var readIter = 0
|
||||
while (true) {
|
||||
val readThisIter = read(targetArray, readIter, byteCount - readIter)
|
||||
@@ -80,7 +83,7 @@ class MemAutoFlushingLookupTable<T>(
|
||||
private fun initialLoad() {
|
||||
launch {
|
||||
try {
|
||||
atomicFile.openRead().buffered().use { input ->
|
||||
atomicFile.openRead().source().buffer().use { input ->
|
||||
val bb = ByteBuffer.allocate(8)
|
||||
|
||||
while (true) {
|
||||
@@ -126,7 +129,7 @@ class MemAutoFlushingLookupTable<T>(
|
||||
|
||||
val fos = atomicFile.startWrite()
|
||||
try {
|
||||
val out = fos.buffered()
|
||||
val out = fos.sink().buffer()
|
||||
table.forEach { key, value ->
|
||||
val v = serializer.write(value).toByteArray(Charsets.UTF_8)
|
||||
bb.putInt(0, key)
|
||||
|
||||
@@ -2,6 +2,7 @@ package exh.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@@ -136,10 +137,18 @@ class FavoritesSyncHelper(val context: Context) {
|
||||
}
|
||||
ignore { wifiLock?.release() }
|
||||
wifiLock = ignore {
|
||||
context.wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
"teh:ExhFavoritesSyncWifi",
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY,
|
||||
"teh:ExhFavoritesSyncWifi",
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
"teh:ExhFavoritesSyncWifi",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Do not update galleries while syncing favorites
|
||||
|
||||
@@ -20,15 +20,19 @@ import eu.kanade.presentation.browse.components.RemoveMangaDialog
|
||||
import eu.kanade.presentation.category.components.ChangeCategoryDialog
|
||||
import eu.kanade.presentation.manga.DuplicateMangaDialog
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import exh.ui.ifSourcesLoaded
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.domain.UnsortedPreferences
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class MangaDexFollowsScreen(private val sourceId: Long) : Screen() {
|
||||
|
||||
@@ -104,6 +108,14 @@ class MangaDexFollowsScreen(private val sourceId: Long) : Screen() {
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.addFavorite(dialog.manga) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
PreMigrationScreen.navigateToMigration(
|
||||
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
|
||||
navigator,
|
||||
dialog.duplicate.id,
|
||||
dialog.manga.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
|
||||
|
||||
@@ -32,7 +32,6 @@ class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() {
|
||||
}
|
||||
|
||||
val screenModel = rememberScreenModel { MangaDexSimilarScreenModel(mangaId, sourceId) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val onMangaClick: (Manga) -> Unit = {
|
||||
|
||||
@@ -31,7 +31,6 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
|
||||
}
|
||||
|
||||
val screenModel = rememberScreenModel { RecommendsScreenModel(mangaId, sourceId) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val onMangaClick: (Manga) -> Unit = { manga ->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user