Compare commits

...

32 Commits

Author SHA1 Message Date
KaiserBh e96895345e feat(sync): prevent deleted "ghost chapters" from reappearing during sync. (#1575)
* feat(sync): prevent deleted "ghost chapters" from reappearing during sync.

- Pass lastSyncTime down to mergeChapters in SyncService.kt.
- Apply timestamp-based tombstoning logic to chapter merging. When a chapter is missing from either the local or remote backup, its `lastModifiedAt` timestamp is checked against the device's last sync time.
- Ensure that chapters deleted on one device (or removed by a source) are recognized as deletions and dropped from the merged backup, rather than being erroneously restored as "new" chapters on subsequent syncs.

* chore: change timestamp to use duration-based calculations

* chore: spotless
2026-04-06 13:08:30 -04:00
MediocreLegion eec1236b8b fix(delegate): migrate NH to the v2 api (#1581)
* fix(delegate): migrate NH to the v2 api

* remove extra comment

* remove redundant data

* linting

* Code cleanup

---------

Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>
2026-04-03 12:59:13 -04:00
Weblate (bot) ee1e783126 Translations update from Hosted Weblate (#1577)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ru/
Translation: Mihon/TachiyomiSY

Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: ScratchBuild <foobarbuzz@gmail.com>
Co-authored-by: ZenVinny <atdenada@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2026-04-03 12:50:27 -04:00
renovate[bot] f3ab39cb1f Update dependency net.zetetic:sqlcipher-android to v4.14.1 (#1583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 12:50:00 -04:00
KaiserBh ba75395648 refactor: improve sync merging categories (#1559)
* feat: Add versioning to categories

* feat: use random UID for categories.

For legacy and migration we should assign uid on insert, and modify existing one as well in the migration.

* feat: sync category metadata

Add version, uid and lastModifiedAt fields to Category model to allow syncing.

* chore: fix category merging logic

Improve the category merging logic by matching using UIDs first, with a fallback to matching by name for legacy remote categories.

Previously, categories were only matched by name, which could lead to incorrect merges if names were changed. This change ensures more accurate synchronization by prioritizing the unique identifier. Conflict resolution is now based on the `version` field, and logging has been added for better visibility into the merging process.

* refactor: prioritize UID when restoring categories

If a category with the same UID exists, update it instead of creating a new one. Fallback to matching by name if no UID match is found.

* chore: add isSyncing flag like before.

This make sure the version is consistent, and it's not accidentally appended by the trigger, if it does then one device will always be ahead, than previous, and they need to make multiple changes to increase the version.

* Apply suggestion from @jobobby04

Use SY specific numbers(601, 602 for now)

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* chore: commit review, re-order.

* chore: surround changes in // SY --> // SY <--

* refactor: fallback to existing category UID if backup UID is 0 during restore.

when dealing with old backups (backups created before we added UIDs). In those old backups, backupCategory.uid defaults to 0.
If a user restored an old backup, it would match by name, and then overwrite the newly generated local UID with 0. This would break the synchronization.

* refactor: change to 6xx

* feat: improve sync reliability for categories and settings

- Refactor `mergeCategoriesLists` to correctly match categories by name when UID matching fails, ensuring better reconciliation across devices.
- Fix a bug in category merging where multiple categories with UID 0 (common for non-synced items) caused data loss.
- Update `SyncManager` to detect changes in categories, sources, preferences, saved searches, and extension repos, ensuring they synchronize even when the library favorites haven't changed.
- Convert `BackupCategory` and `BackupExtensionRepos` to data classes to support robust content-aware comparison during the sync process.
- Fix data loss in `mergeSourcesLists`, `mergePreferencesLists`, and `mergeSavedSearchesLists` by retaining local versions when conflicting with remote data.

* fix(sync): properly sync category deletions across devices

Previously, the sync system could not distinguish between a category that was deleted locally and a new category created on another device, causing deleted categories to be restored from the remote backup.

- Update `SyncService` to use `lastSyncTimestamp` to deduce if a missing local category was deleted (if modified before last sync) or newly created remotely (if modified after).
- Update `SyncManager` to explicitly delete local categories that are absent from the merged remote backup, propagating deletions to other devices.
- Fix `RestoreOptions` in `SyncManager` to respect the user's sync preferences instead of hardcoding `categories = true`.

* chore: change it to 6xx and not 600.

* chore: don't need to change this.

* chore: use kotlin time duration units

---------

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
2026-04-03 12:49:37 -04:00
Weblate (bot) fe0b14ab97 Translations update from Hosted Weblate (#1561)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/be/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ta/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/be/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ta/
Translation: Mihon/TachiyomiSY
Translation: Mihon/TachiyomiSY Plurals

Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Lucas Correia <anicetoclucas@gmail.com>
Co-authored-by: lilp <felipegabriel.avila6@gmail.com>
Co-authored-by: nadevko <ormak@protonmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2026-03-18 19:55:58 -04:00
renovate[bot] 91d2140288 Update koin to v4.2.0 (#1569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 19:47:20 -04:00
renovate[bot] 0417969dd6 Update dependency net.zetetic:sqlcipher-android to v4.14.0 (#1567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 19:47:08 -04:00
AntsyLich 5d8d2ce48a Switch to AndroidX bundled sqlite driver (#3082)
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt
2026-03-18 19:45:20 -04:00
Mend Renovate b15277f134 Update paging.version to v3.4.2 (#3063) 2026-03-18 19:21:01 -04:00
Mend Renovate 76ca27f681 Update kotlin monorepo to v2.3.20 (#3074) 2026-03-18 19:20:57 -04:00
Mend Renovate 56923c76d4 Update sqldelight to v2.3.2 (#3077) 2026-03-18 19:20:53 -04:00
MajorTanya 32e19736b9 Address bundleOf deprecation (#3073) 2026-03-18 19:20:48 -04:00
Mend Renovate 11b01b2771 Update sqldelight to v2.3.1 (#3071)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-03-18 19:20:45 -04:00
Mend Renovate 460ff13e54 Update dependency io.kotest:kotest-assertions-core to v6.1.7 (#3062) 2026-03-18 19:20:42 -04:00
Mend Renovate 57f77c8105 Update moko to v0.26.1 (#3068) 2026-03-18 19:20:39 -04:00
Mend Renovate a2eb22964a Update dependency com.squareup.okio:okio to v3.17.0 (#3070) 2026-03-18 19:20:36 -04:00
Mend Renovate 7158bae26a Update dependency androidx.activity:activity-compose to v1.13.0 (#3065) 2026-03-18 19:20:32 -04:00
Mend Renovate 807ce846d5 Update dependency androidx.core:core-ktx to v1.18.0 (#3067) 2026-03-18 19:20:29 -04:00
Mend Renovate 0b68f2c62a Update dependency androidx.compose:compose-bom to v2026.03.00 (#3066) 2026-03-18 19:20:26 -04:00
AntsyLich b7d6cc8dd0 Add installation id for feature flags (#3052)
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt
#	app/src/main/java/mihon/core/migration/migrations/Migrations.kt
2026-03-18 19:20:17 -04:00
Mend Renovate 8b1fd30902 Update dependency androidx.compose:compose-bom to v2026.02.01 (#3009) 2026-03-18 19:09:37 -04:00
Mend Renovate aff43f3aeb Update dependency com.google.firebase:firebase-bom to v34.10.0 (#3006) 2026-03-18 19:09:29 -04:00
Mend Renovate 0047d2e5d8 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.3.0 (#3029) 2026-03-18 19:09:22 -04:00
Mend Renovate d87385f5b3 Update dependency com.materialkolor:material-kolor to v5.0.0-alpha07 (#3024) 2026-03-18 19:09:15 -04:00
AntsyLich c17e9573b7 Reapply "Fix cache invalidation isn't done at startup (#2970)"
This reverts commit d219c5e3bbcfb24c40fa69e40bff11b6fd81fd7f.

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt
2026-03-18 19:08:43 -04:00
AntsyLich 9c01119d24 Reapply "Fix thread starvation caused by not yielding or using an inappropriate thread pool (#2955)"
This reverts commit 1d7c838ae64e624d9dd0884722f0c6ae5d18e386.

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt
#	app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt
2026-03-18 19:06:16 -04:00
Jobobby04 bbc839e234 Lint 2026-02-27 22:44:01 -05:00
Jobobby04 917f20894b Bump version code 2026-02-27 22:08:28 -05:00
Jobobby04 3a3b719b8b Copy last page read in migrate 2026-02-27 22:07:59 -05:00
Jobobby04 1903437ecf Cleanup 2026-02-27 22:07:42 -05:00
Jobobby04 5c26bb3a52 Add recommended proguard rules 2026-02-27 22:07:31 -05:00
61 changed files with 1263 additions and 342 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy" applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 75 versionCode = 77
versionName = "1.12.0" versionName = "1.12.0"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@@ -192,7 +192,7 @@ dependencies {
implementation(androidx.paging.runtime) implementation(androidx.paging.runtime)
implementation(androidx.paging.compose) implementation(androidx.paging.compose)
implementation(libs.bundles.sqlite) implementation(androidx.sqlite.bundled)
// SY --> // SY -->
implementation(sylibs.sqlcipher) implementation(sylibs.sqlcipher)
// SY <-- // SY <--
+2
View File
@@ -299,3 +299,5 @@
-dontwarn org.ietf.jgss.GSSManager -dontwarn org.ietf.jgss.GSSManager
-dontwarn org.ietf.jgss.GSSName -dontwarn org.ietf.jgss.GSSName
-dontwarn org.ietf.jgss.Oid -dontwarn org.ietf.jgss.Oid
-dontwarn com.google.re2j.Matcher
-dontwarn com.google.re2j.Pattern
@@ -35,4 +35,6 @@ class BasePreferences(
fun hardwareBitmapThreshold() = preferenceStore.getInt("pref_hardware_bitmap_threshold", GLUtil.SAFE_TEXTURE_LIMIT) fun hardwareBitmapThreshold() = preferenceStore.getInt("pref_hardware_bitmap_threshold", GLUtil.SAFE_TEXTURE_LIMIT)
fun alwaysDecodeLongStripWithSSIV() = preferenceStore.getBoolean("pref_always_decode_long_strip_with_ssiv", false) fun alwaysDecodeLongStripWithSSIV() = preferenceStore.getBoolean("pref_always_decode_long_strip_with_ssiv", false)
fun installationId() = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
} }
@@ -30,6 +30,7 @@ sealed class Preference {
override val title: String, override val title: String,
override val subtitle: CharSequence? = null, override val subtitle: CharSequence? = null,
override val enabled: Boolean = true, override val enabled: Boolean = true,
val widget: @Composable (() -> Unit)? = null,
val onClick: (() -> Unit)? = null, val onClick: (() -> Unit)? = null,
) : PreferenceItem<String, Unit>() { ) : PreferenceItem<String, Unit>() {
override val icon: ImageVector? = null override val icon: ImageVector? = null
@@ -147,6 +147,7 @@ internal fun PreferenceItem(
title = item.title, title = item.title,
subtitle = item.subtitle, subtitle = item.subtitle,
icon = item.icon, icon = item.icon,
widget = item.widget,
onPreferenceClick = item.onClick, onPreferenceClick = item.onClick,
) )
} }
@@ -223,6 +223,7 @@ object SettingsAdvancedScreen : SearchableSettings {
private fun getDataGroup(): Preference.PreferenceGroup { private fun getDataGroup(): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_data), title = stringResource(MR.strings.label_data),
@@ -231,8 +232,10 @@ object SettingsAdvancedScreen : SearchableSettings {
title = stringResource(MR.strings.pref_invalidate_download_cache), title = stringResource(MR.strings.pref_invalidate_download_cache),
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary), subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
onClick = { onClick = {
Injekt.get<DownloadCache>().invalidateCache() scope.launch {
context.toast(MR.strings.download_cache_invalidated) Injekt.get<DownloadCache>().invalidateCache()
context.toast(MR.strings.download_cache_invalidated)
}
}, },
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
@@ -303,7 +303,10 @@ object SettingsDataScreen : SearchableSettings {
val chapterCache = remember { Injekt.get<ChapterCache>() } val chapterCache = remember { Injekt.get<ChapterCache>() }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) } var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize } var cacheReadableSize by remember { mutableStateOf(context.stringResource(MR.strings.calculating)) }
LaunchedEffect(cacheReadableSizeSema) {
cacheReadableSize = chapterCache.getReadableSize()
}
// SY --> // SY -->
val pagePreviewCache = remember { Injekt.get<PagePreviewCache>() } val pagePreviewCache = remember { Injekt.get<PagePreviewCache>() }
@@ -9,12 +9,18 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@@ -45,10 +51,24 @@ private fun StorageInfo(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) } var available by remember(file) { mutableStateOf(-1L) }
val availableText = remember(available) { Formatter.formatFileSize(context, available) } var total by remember(file) { mutableStateOf(-1L) }
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
val totalText = remember(total) { Formatter.formatFileSize(context, total) } LaunchedEffect(file) {
available = withContext(Dispatchers.IO) { DiskUtil.getAvailableStorageSpace(file) }
total = withContext(Dispatchers.IO) { DiskUtil.getTotalStorageSpace(file) }
}
val availableText = if (available == -1L) {
stringResource(MR.strings.calculating)
} else {
Formatter.formatFileSize(context, available)
}
val totalText = if (total == -1L) {
stringResource(MR.strings.calculating)
} else {
Formatter.formatFileSize(context, total)
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
@@ -58,13 +78,15 @@ private fun StorageInfo(
style = MaterialTheme.typography.header, style = MaterialTheme.typography.header,
) )
LinearProgressIndicator( if (total > 0) {
modifier = Modifier LinearProgressIndicator(
.clip(MaterialTheme.shapes.small) modifier = Modifier
.fillMaxWidth() .clip(MaterialTheme.shapes.small)
.height(12.dp), .fillMaxWidth()
progress = { (1 - (available / total.toFloat())) }, .height(12.dp),
) progress = { (1 - (available / total.toFloat())) },
)
}
Text( Text(
text = stringResource(MR.strings.available_disk_space_info, availableText, totalText), text = stringResource(MR.strings.available_disk_space_info, availableText, totalText),
@@ -1,24 +1,38 @@
package eu.kanade.presentation.more.settings.screen.debug package eu.kanade.presentation.more.settings.screen.debug
import android.os.Build import android.os.Build
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.profileinstaller.ProfileVerifier import androidx.profileinstaller.ProfileVerifier
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.PreferenceScaffold import eu.kanade.presentation.more.settings.PreferenceScaffold
import eu.kanade.presentation.more.settings.screen.about.AboutScreen import eu.kanade.presentation.more.settings.screen.about.AboutScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import mihon.core.common.FeatureFlags
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class DebugInfoScreen : Screen() { class DebugInfoScreen : Screen() {
@@ -47,6 +61,12 @@ class DebugInfoScreen : Screen() {
@Composable @Composable
private fun getAppInfoGroup(): Preference.PreferenceGroup { private fun getAppInfoGroup(): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val installationIdPref = remember { Injekt.get<BasePreferences>().installationId() }
val installationId by installationIdPref.collectAsState()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = "App info", title = "App info",
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
@@ -58,6 +78,28 @@ class DebugInfoScreen : Screen() {
title = "Build time", title = "Build time",
subtitle = AboutScreen.getFormattedBuildTime(), subtitle = AboutScreen.getFormattedBuildTime(),
), ),
Preference.PreferenceItem.TextPreference(
title = "Installation ID",
subtitle = installationId,
widget = {
IconButton(
onClick = {
scope.launch {
installationIdPref.set(FeatureFlags.newInstallationId())
}
},
) {
Icon(
imageVector = Icons.Outlined.Autorenew,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
)
}
},
onClick = {
context.copyToClipboard(installationId, installationId)
},
),
getProfileVerifierPreference(), getProfileVerifierPreference(),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "WebView version", title = "WebView version",
@@ -13,12 +13,18 @@ class BackupCategory(
@ProtoNumber(100) var flags: Long = 0, @ProtoNumber(100) var flags: Long = 0,
// SY specific values // SY specific values
/*@ProtoNumber(600) var mangaOrder: List<Long> = emptyList(),*/ /*@ProtoNumber(600) var mangaOrder: List<Long> = emptyList(),*/
@ProtoNumber(601) var version: Long = 0,
@ProtoNumber(602) var uid: Long = 0,
@ProtoNumber(603) var lastModifiedAt: Long = 0,
) { ) {
fun toCategory(id: Long) = Category( fun toCategory(id: Long) = Category(
id = id, id = id,
name = this@BackupCategory.name, name = this@BackupCategory.name,
flags = this@BackupCategory.flags, flags = this@BackupCategory.flags,
order = this@BackupCategory.order, order = this@BackupCategory.order,
version = this@BackupCategory.version,
uid = this@BackupCategory.uid,
lastModifiedAt = this@BackupCategory.lastModifiedAt,
/*mangaOrder = this@BackupCategory.mangaOrder*/ /*mangaOrder = this@BackupCategory.mangaOrder*/
) )
} }
@@ -29,5 +35,8 @@ val backupCategoryMapper = { category: Category ->
name = category.name, name = category.name,
order = category.order, order = category.order,
flags = category.flags, flags = category.flags,
version = category.version,
uid = category.uid,
lastModifiedAt = category.lastModifiedAt,
) )
} }
@@ -17,20 +17,63 @@ class CategoriesRestorer(
if (backupCategories.isNotEmpty()) { if (backupCategories.isNotEmpty()) {
val dbCategories = getCategories.await() val dbCategories = getCategories.await()
val dbCategoriesByName = dbCategories.associateBy { it.name } val dbCategoriesByName = dbCategories.associateBy { it.name }
// SY -->
val dbCategoriesByUid = dbCategories.associateBy { it.uid } // Map by UID
// SY <--
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0 var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categories = backupCategories val categories = backupCategories
.sortedBy { it.order } .sortedBy { it.order }
.map { // SY -->
val dbCategory = dbCategoriesByName[it.name] .map { backupCategory ->
if (dbCategory != null) return@map dbCategory var dbCategory = if (backupCategory.uid != 0L) {
dbCategoriesByUid[backupCategory.uid]
} else {
null
}
if (dbCategory == null) {
dbCategory = dbCategoriesByName[backupCategory.name]
}
if (dbCategory != null) {
handler.await {
categoriesQueries.update(
name = backupCategory.name,
order = backupCategory.order,
flags = backupCategory.flags,
version = backupCategory.version,
uid = if (backupCategory.uid != 0L) backupCategory.uid else dbCategory.uid,
last_modified_at = backupCategory.lastModifiedAt,
isSyncing = 1,
categoryId = dbCategory.id,
)
}
return@map dbCategory
}
val order = nextOrder++ val order = nextOrder++
handler.awaitOneExecutable { handler.awaitOneExecutable {
categoriesQueries.insert(it.name, order, it.flags) categoriesQueries.insert(
backupCategory.name,
order,
backupCategory.flags,
backupCategory.version,
backupCategory.uid,
backupCategory.lastModifiedAt,
)
categoriesQueries.selectLastInsertedRowId() categoriesQueries.selectLastInsertedRowId()
} }
.let { id -> it.toCategory(id).copy(order = order) } .let { id -> backupCategory.toCategory(id).copy(order = order) }
} }
// SY <--
// SY -->
handler.await {
categoriesQueries.resetIsSyncing()
}
// SY <--
libraryPreferences.categorizedDisplaySettings().set( libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories) (dbCategories + categories)
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import okhttp3.Response import okhttp3.Response
@@ -63,17 +64,13 @@ class ChapterCache(
*/ */
private val cacheDir: File = diskCache.directory private val cacheDir: File = diskCache.directory
/**
* Returns real size of directory.
*/
private val realSize: Long
get() = DiskUtil.getDirectorySize(cacheDir)
/** /**
* Returns real size of directory in human readable format. * Returns real size of directory in human readable format.
*/ */
val readableSize: String suspend fun getReadableSize(): String = withContext(Dispatchers.IO) {
get() = Formatter.formatFileSize(context, realSize) val size = DiskUtil.getDirectorySize(cacheDir)
Formatter.formatFileSize(context, size)
}
// --> EH // --> EH
// Cache size is in MB // Cache size is in MB
@@ -12,14 +12,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -109,13 +112,19 @@ class DownloadCache(
ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes()) ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes())
} }
rootDownloadsDir = diskCache rootDownloadsDir = diskCache
lastRenew = System.currentTimeMillis()
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to initialize from disk cache" } logcat(LogPriority.ERROR, e) { "Failed to initialize from disk cache" }
diskCacheFile.delete() diskCacheFile.delete()
} }
} }
sourceManager.catalogueSources
.map { sources -> sources.map { it.id }.toSet() }
.distinctUntilChanged()
.collect {
restartRenewal()
}
} }
storageManager.changes storageManager.changes
@@ -353,19 +362,34 @@ class DownloadCache(
notifyChanges() notifyChanges()
} }
fun invalidateCache() { suspend fun invalidateCache() {
lastRenew = 0L renewalJob?.cancelAndJoin()
renewalJob?.cancel()
diskCacheFile.delete() diskCacheFile.delete()
renewCache() lastRenew = 0L
renewCache(forceRenew = true)
}
/**
* Safely cancels any in-progress renewal job, resets the last-renew timestamp, and
* immediately starts a new renewal, bypassing the time-based throttle.
*/
private fun restartRenewal() {
renewalJob?.cancel()
lastRenew = 0L
renewCache(forceRenew = true)
} }
/** /**
* Renews the downloads cache. * Renews the downloads cache.
*
* @param forceRenew when `true`, the time-based throttle is bypassed. Use this after
* explicitly cancelling the previous job to avoid a race where the cancelled job's
* [invokeOnCompletion] handler sets [lastRenew] after the reset but before the new
* job's guard check.
*/ */
private fun renewCache() { private fun renewCache(forceRenew: Boolean = false) {
// Avoid renewing cache if in the process nor too often // Avoid renewing cache if in the process nor too often
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) { if ((!forceRenew && lastRenew + renewInterval >= System.currentTimeMillis()) || renewalJob?.isActive == true) {
return return
} }
@@ -376,15 +400,14 @@ class DownloadCache(
// Try to wait until extensions and sources have loaded // Try to wait until extensions and sources have loaded
// SY --> // SY -->
var sources = emptyList<Source>()
withTimeoutOrNull(30.seconds) { withTimeoutOrNull(30.seconds) {
extensionManager.isInitialized.first { it } // SY <--
sourceManager.isInitialized.first { it } sourceManager.catalogueSources.first { it.isNotEmpty() }
// SY -->
sources = getSources()
} }
// SY <-- // SY <--
val sources = getSources()
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
@@ -459,8 +482,9 @@ class DownloadCache(
private var updateDiskCacheJob: Job? = null private var updateDiskCacheJob: Job? = null
private fun updateDiskCache() { private fun updateDiskCache() {
updateDiskCacheJob?.cancel() val previousJob = updateDiskCacheJob
updateDiskCacheJob = scope.launchIO { updateDiskCacheJob = scope.launchIO {
previousJob?.cancelAndJoin()
delay(1000) delay(1000)
ensureActive() ensureActive()
val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir) val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir)
@@ -109,10 +109,10 @@ class DownloadManager(
return queueState.value.find { it.chapter.id == chapterId } return queueState.value.find { it.chapter.id == chapterId }
} }
fun startDownloadNow(chapterId: Long) { suspend fun startDownloadNow(chapterId: Long) {
val existingDownload = getQueuedDownloadOrNull(chapterId) val existingDownload = getQueuedDownloadOrNull(chapterId)
// If not in queue try to start a new download // If not in queue try to start a new download
val toAdd = existingDownload ?: runBlocking { Download.fromChapterId(chapterId) } ?: return val toAdd = existingDownload ?: Download.fromChapterId(chapterId) ?: return
queueState.value.toMutableList().apply { queueState.value.toMutableList().apply {
existingDownload?.let { remove(it) } existingDownload?.let { remove(it) }
add(0, toAdd) add(0, toAdd)
@@ -89,7 +89,7 @@ class DownloadStore(
/** /**
* Returns the list of downloads to restore. It should be called in a background thread. * Returns the list of downloads to restore. It should be called in a background thread.
*/ */
fun restore(): List<Download> { suspend fun restore(): List<Download> {
val objs = preferences.all val objs = preferences.all
.mapNotNull { it.value as? String } .mapNotNull { it.value as? String }
.mapNotNull { deserialize(it) } .mapNotNull { deserialize(it) }
@@ -100,10 +100,10 @@ class DownloadStore(
val cachedManga = mutableMapOf<Long, Manga?>() val cachedManga = mutableMapOf<Long, Manga?>()
for ((mangaId, chapterId) in objs) { for ((mangaId, chapterId) in objs) {
val manga = cachedManga.getOrPut(mangaId) { val manga = cachedManga.getOrPut(mangaId) {
runBlocking { getManga.await(mangaId) } getManga.await(mangaId)
} ?: continue } ?: continue
val source = sourceManager.get(manga.source) as? HttpSource ?: continue val source = sourceManager.get(manga.source) as? HttpSource ?: continue
val chapter = runBlocking { getChapter.await(chapterId) } ?: continue val chapter = getChapter.await(chapterId) ?: continue
downloads.add(Download(source, manga, chapter)) downloads.add(Download(source, manga, chapter))
} }
} }
@@ -121,9 +121,9 @@ class Downloader(
var isPaused: Boolean = false var isPaused: Boolean = false
init { init {
launchNow { scope.launch {
val chapters = async { store.restore() } val chapters = store.restore()
addAllToQueue(chapters.await()) addAllToQueue(chapters)
} }
} }
@@ -23,6 +23,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
@@ -84,11 +85,18 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context) ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
// Open reader activity // Open reader activity
ACTION_OPEN_CHAPTER -> { ACTION_OPEN_CHAPTER -> {
openChapter( val pendingResult = goAsync()
context, launchIO {
intent.getLongExtra(EXTRA_MANGA_ID, -1), try {
intent.getLongExtra(EXTRA_CHAPTER_ID, -1), openChapter(
) context,
intent.getLongExtra(EXTRA_MANGA_ID, -1),
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
)
} finally {
pendingResult.finish()
}
}
} }
// Mark updated manga chapters as read // Mark updated manga chapters as read
ACTION_MARK_AS_READ -> { ACTION_MARK_AS_READ -> {
@@ -153,16 +161,18 @@ class NotificationReceiver : BroadcastReceiver() {
* @param mangaId id of manga * @param mangaId id of manga
* @param chapterId id of chapter * @param chapterId id of chapter
*/ */
private fun openChapter(context: Context, mangaId: Long, chapterId: Long) { private suspend fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
val manga = runBlocking { getManga.await(mangaId) } val manga = getManga.await(mangaId)
val chapter = runBlocking { getChapter.await(chapterId) } val chapter = getChapter.await(chapterId)
if (manga != null && chapter != null) { withUIContext {
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply { if (manga != null && chapter != null) {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
context.startActivity(intent)
} else {
context.toast(MR.strings.chapter_error)
} }
context.startActivity(intent)
} else {
context.toast(MR.strings.chapter_error)
} }
} }
@@ -73,6 +73,7 @@ class SyncManager(
handler.await(inTransaction = true) { handler.await(inTransaction = true) {
mangasQueries.resetIsSyncing() mangasQueries.resetIsSyncing()
chaptersQueries.resetIsSyncing() chaptersQueries.resetIsSyncing()
categoriesQueries.resetIsSyncing()
} }
val syncOptions = syncPreferences.getSyncSettings() val syncOptions = syncPreferences.getSyncSettings()
@@ -156,7 +157,7 @@ class SyncManager(
} }
// Stop the sync early if the remote backup is null or empty // Stop the sync early if the remote backup is null or empty
if (remoteBackup.backupManga.size == 0) { if (remoteBackup.backupManga.isEmpty() && remoteBackup.backupCategories.isEmpty() && remoteBackup.backupSources.isEmpty()) {
notifier.showSyncError("No data found on remote server.") notifier.showSyncError("No data found on remote server.")
return return
} }
@@ -185,14 +186,40 @@ class SyncManager(
// SY <-- // SY <--
) )
// It's local sync no need to restore data. (just update remote data) val hasMangaChanges = filteredFavorites.isNotEmpty()
if (filteredFavorites.isEmpty()) { val hasCategoryChanges = remoteBackup.backupCategories != backup.backupCategories
val hasSourceChanges = remoteBackup.backupSources != backup.backupSources
val hasPreferenceChanges = remoteBackup.backupPreferences != backup.backupPreferences
val hasSourcePreferenceChanges = remoteBackup.backupSourcePreferences != backup.backupSourcePreferences
val hasExtensionRepoChanges = remoteBackup.backupExtensionRepo != backup.backupExtensionRepo
val hasSavedSearchChanges = remoteBackup.backupSavedSearches != backup.backupSavedSearches
if (!hasMangaChanges && !hasCategoryChanges && !hasSourceChanges &&
!hasPreferenceChanges && !hasSourcePreferenceChanges &&
!hasExtensionRepoChanges && !hasSavedSearchChanges
) {
// update the sync timestamp // update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time) syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Sync completed successfully") notifier.showSyncSuccess("Sync completed successfully")
return return
} }
if (syncOptions.categories) {
val mergedUids = newSyncData.backupCategories.map { it.uid }.toSet()
val mergedNames = newSyncData.backupCategories.map { it.name }.toSet()
val localCategories = getCategories.await().filterNot { it.id == 0L } // Exclude system category
val categoriesToDelete = localCategories.filter {
it.uid !in mergedUids && it.name !in mergedNames
}
if (categoriesToDelete.isNotEmpty()) {
handler.await(inTransaction = true) {
categoriesToDelete.forEach {
categoriesQueries.delete(it.id)
}
}
}
}
val backupUri = writeSyncDataToCache(context, newSyncData) val backupUri = writeSyncDataToCache(context, newSyncData)
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" } logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
if (backupUri != null) { if (backupUri != null) {
@@ -201,10 +228,14 @@ class SyncManager(
backupUri, backupUri,
sync = true, sync = true,
options = RestoreOptions( options = RestoreOptions(
appSettings = true, appSettings = syncOptions.appSettings,
sourceSettings = true, sourceSettings = syncOptions.sourceSettings,
libraryEntries = true, libraryEntries = syncOptions.libraryEntries,
extensionRepoSettings = true, categories = syncOptions.categories,
extensionRepoSettings = syncOptions.extensionRepoSettings,
// SY -->
savedSearches = syncOptions.savedSearches,
// SY <--
), ),
) )
@@ -14,6 +14,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import logcat.logcat import logcat.logcat
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Serializable @Serializable
data class SyncData( data class SyncData(
@@ -134,14 +136,31 @@ abstract class SyncService(
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
} }
val lastSyncTime = syncPreferences.lastSyncTimestamp().get().milliseconds.inWholeSeconds
val syncOptions = syncPreferences.getSyncSettings()
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey -> val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
val local = localMangaMap[compositeKey] val local = localMangaMap[compositeKey]
val remote = remoteMangaMap[compositeKey] val remote = remoteMangaMap[compositeKey]
// New version comparison logic // New version comparison logic
when { when {
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder) local != null && remote == null -> {
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder) if (lastSyncTime == 0L || local.lastModifiedAt > lastSyncTime) {
updateCategories(local, localCategoriesMapByOrder)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping local manga deleted on remote: ${local.title}." }
null
}
}
local == null && remote != null -> {
if (lastSyncTime == 0L || remote.lastModifiedAt > lastSyncTime) {
updateCategories(remote, remoteCategoriesMapByOrder)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote manga: ${remote.title}." }
null
}
}
local != null && remote != null -> { local != null && remote != null -> {
// Compare versions to decide which manga to keep // Compare versions to decide which manga to keep
if (local.version >= remote.version) { if (local.version >= remote.version) {
@@ -149,7 +168,7 @@ abstract class SyncService(
"Keeping local version of ${local.title} with merged chapters." "Keeping local version of ${local.title} with merged chapters."
} }
updateCategories( updateCategories(
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)), local.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
localCategoriesMapByOrder, localCategoriesMapByOrder,
) )
} else { } else {
@@ -157,7 +176,7 @@ abstract class SyncService(
"Keeping remote version of ${remote.title} with merged chapters." "Keeping remote version of ${remote.title} with merged chapters."
} }
updateCategories( updateCategories(
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)), remote.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
remoteCategoriesMapByOrder, remoteCategoriesMapByOrder,
) )
} }
@@ -197,9 +216,15 @@ abstract class SyncService(
private fun mergeChapters( private fun mergeChapters(
localChapters: List<BackupChapter>, localChapters: List<BackupChapter>,
remoteChapters: List<BackupChapter>, remoteChapters: List<BackupChapter>,
lastSyncTime: Long,
syncingChapters: Boolean,
): List<BackupChapter> { ): List<BackupChapter> {
val logTag = "MergeChapters" val logTag = "MergeChapters"
if (!syncingChapters) {
return remoteChapters // If not syncing chapters, keep remote untouched
}
fun chapterCompositeKey(chapter: BackupChapter): String { fun chapterCompositeKey(chapter: BackupChapter): String {
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}" return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
} }
@@ -223,12 +248,22 @@ abstract class SyncService(
when { when {
localChapter != null && remoteChapter == null -> { localChapter != null && remoteChapter == null -> {
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." } if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) {
localChapter logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
localChapter
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping local chapter deleted on remote: ${localChapter.name}." }
null
}
} }
localChapter == null && remoteChapter != null -> { localChapter == null && remoteChapter != null -> {
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." } if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) {
remoteChapter logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
remoteChapter
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote chapter: ${remoteChapter.name}." }
null
}
} }
localChapter != null && remoteChapter != null -> { localChapter != null && remoteChapter != null -> {
// Use version number to decide which chapter to keep // Use version number to decide which chapter to keep
@@ -274,37 +309,70 @@ abstract class SyncService(
localCategoriesList: List<BackupCategory>?, localCategoriesList: List<BackupCategory>?,
remoteCategoriesList: List<BackupCategory>?, remoteCategoriesList: List<BackupCategory>?,
): List<BackupCategory> { ): List<BackupCategory> {
val logTag = "MergeCategories"
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList() if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
if (remoteCategoriesList == null) return localCategoriesList if (remoteCategoriesList == null) return localCategoriesList
val localCategoriesMap = localCategoriesList.associateBy { it.name }
val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name }
val mergedCategoriesMap = mutableMapOf<String, BackupCategory>() val result = mutableListOf<BackupCategory>()
val processedLocals = mutableSetOf<BackupCategory>()
localCategoriesMap.forEach { (name, localCategory) -> val localMapByUid = localCategoriesList.filter { it.uid != 0L }.associateBy { it.uid }
val remoteCategory = remoteCategoriesMap[name] val localMapByName = localCategoriesList.associateBy { it.name }
if (remoteCategory != null) {
// Compare and merge local and remote categories val lastSyncTime = syncPreferences.lastSyncTimestamp().get()
val mergedCategory = if (localCategory.order > remoteCategory.order) {
localCategory remoteCategoriesList.forEach { remote ->
var localMatch: BackupCategory? = null
// 1. Try match by UID
if (remote.uid != 0L) {
localMatch = localMapByUid[remote.uid]
}
// 2. Try match by Name (fallback)
if (localMatch == null) {
localMatch = localMapByName[remote.name]
}
if (localMatch != null) {
processedLocals.add(localMatch)
// Conflict resolution
if (localMatch.version >= remote.version) {
logcat(LogPriority.DEBUG, logTag) { "Keeping local category: ${localMatch.name} (UID: ${localMatch.uid})" }
result.add(localMatch)
} else { } else {
remoteCategory logcat(LogPriority.DEBUG, logTag) { "Keeping remote category: ${remote.name} (UID: ${remote.uid})" }
// Preserve Local UID if Remote was 0
if (remote.uid == 0L) {
remote.uid = localMatch.uid
}
result.add(remote)
} }
mergedCategoriesMap[name] = mergedCategory
} else { } else {
// If the category is only in the local list, add it to the merged list val remoteModifiedTimeMillis = remote.lastModifiedAt.seconds.inWholeMilliseconds
mergedCategoriesMap[name] = localCategory if (lastSyncTime == 0L || remoteModifiedTimeMillis > lastSyncTime) {
logcat(LogPriority.DEBUG, logTag) { "Adding new remote category: ${remote.name} (UID: ${remote.uid})" }
result.add(remote)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote category: ${remote.name} (UID: ${remote.uid})" }
}
} }
} }
// Add any categories from the remote list that are not in the local list // Add remaining Local Categories
remoteCategoriesMap.forEach { (name, remoteCategory) -> localCategoriesList.forEach { local ->
if (!mergedCategoriesMap.containsKey(name)) { if (local !in processedLocals) {
mergedCategoriesMap[name] = remoteCategory val localModifiedTimeMillis = local.lastModifiedAt.seconds.inWholeMilliseconds
if (lastSyncTime == 0L || localModifiedTimeMillis > lastSyncTime) {
logcat(LogPriority.DEBUG, logTag) { "Keeping local only category: ${local.name} (UID: ${local.uid})" }
result.add(local)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping local category deleted on remote: ${local.name} (UID: ${local.uid})" }
}
} }
} }
return mergedCategoriesMap.values.toList() return result.sortedBy { it.order }
} }
private fun mergeSourcesLists( private fun mergeSourcesLists(
@@ -341,8 +409,8 @@ abstract class SyncService(
remoteSource remoteSource
} }
else -> { else -> {
logcat(LogPriority.DEBUG, logTag) { "Remote and local is not empty: $sourceId. Skipping." } logcat(LogPriority.DEBUG, logTag) { "Remote and local have the same source ID: $sourceId. Keeping local." }
null localSource
} }
} }
} }
@@ -387,8 +455,8 @@ abstract class SyncService(
remotePreference remotePreference
} }
else -> { else -> {
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have keys. Skipping: $key" } logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same preference key: $key. Keeping local." }
null localPreference
} }
} }
} }
@@ -507,10 +575,8 @@ abstract class SyncService(
} }
else -> { else -> {
logcat(LogPriority.DEBUG, logTag) { logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same saved search key: $compositeKey. Keeping local." }
"No saved search found for composite key: $compositeKey. Skipping." localSearch
}
null
} }
} }
} }
@@ -1,12 +1,15 @@
package eu.kanade.tachiyomi.di package eu.kanade.tachiyomi.di
import android.app.Application import android.app.Application
import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
import com.eygraber.sqldelight.androidx.driver.FileProvider
import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences
@@ -25,7 +28,6 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.AndroidSourceManager import eu.kanade.tachiyomi.source.AndroidSourceManager
import eu.kanade.tachiyomi.util.storage.CbzCrypto import eu.kanade.tachiyomi.util.storage.CbzCrypto
import exh.eh.EHentaiUpdateHelper import exh.eh.EHentaiUpdateHelper
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
@@ -52,10 +54,6 @@ import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
// SY -->
private const val LEGACY_DATABASE_NAME = "tachiyomi.db"
// SY <--
class AppModule(val app: Application) : InjektModule { class AppModule(val app: Application) : InjektModule {
// SY --> // SY -->
private val securityPreferences: SecurityPreferences by injectLazy() private val securityPreferences: SecurityPreferences by injectLazy()
@@ -68,40 +66,37 @@ class AppModule(val app: Application) : InjektModule {
// SY --> // SY -->
if (securityPreferences.encryptDatabase().get()) { if (securityPreferences.encryptDatabase().get()) {
System.loadLibrary("sqlcipher") System.loadLibrary("sqlcipher")
}
return@addSingletonFactory AndroidSqliteDriver(
schema = Database.Schema,
context = app,
name = CbzCrypto.DATABASE_NAME,
factory = SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql(), null, false, 25),
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
setPragma(db, "foreign_keys = ON")
setPragma(db, "journal_mode = WAL")
setPragma(db, "synchronous = NORMAL")
}
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
val cursor = db.query("PRAGMA $pragma")
cursor.moveToFirst()
cursor.close()
}
},
)
}
// SY <-- // SY <--
AndroidSqliteDriver(
AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.FileProvider(app, "tachiyomi.db"),
schema = Database.Schema, schema = Database.Schema,
context = app, configuration = AndroidxSqliteConfiguration(
// SY --> isForeignKeyConstraintsEnabled = true,
name = if (securityPreferences.encryptDatabase().get()) { ),
CbzCrypto.DATABASE_NAME
} else {
LEGACY_DATABASE_NAME
},
factory = if (securityPreferences.encryptDatabase().get()) {
SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql(), null, false, 25)
} else if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Support database inspector in Android Studio
FrameworkSQLiteOpenHelperFactory()
} else {
RequerySQLiteOpenHelperFactory()
},
// SY <--
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
setPragma(db, "foreign_keys = ON")
setPragma(db, "journal_mode = WAL")
setPragma(db, "synchronous = NORMAL")
}
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
val cursor = db.query("PRAGMA $pragma")
cursor.moveToFirst()
cursor.close()
}
},
) )
} }
addSingletonFactory { addSingletonFactory {
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -140,20 +141,22 @@ class ExtensionManager(
* Loads and registers the installed extensions. * Loads and registers the installed extensions.
*/ */
private fun initExtensions() { private fun initExtensions() {
val extensions = ExtensionLoader.loadExtensions(context) scope.launch {
val extensions = ExtensionLoader.loadExtensions(context)
installedExtensionMapFlow.value = extensions installedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>() .filterIsInstance<LoadResult.Success>()
.associate { it.extension.pkgName to it.extension } .associate { it.extension.pkgName to it.extension }
untrustedExtensionMapFlow.value = extensions untrustedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>() .filterIsInstance<LoadResult.Untrusted>()
.associate { it.extension.pkgName to it.extension } .associate { it.extension.pkgName to it.extension }
// SY --> // SY -->
.filterNotBlacklisted() .filterNotBlacklisted()
// SY <-- // SY <--
_isInitialized.value = true _isInitialized.value = true
}
} }
// EXH --> // EXH -->
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -114,7 +115,7 @@ internal object ExtensionLoader {
* *
* @param context The application context. * @param context The application context.
*/ */
fun loadExtensions(context: Context): List<LoadResult> { suspend fun loadExtensions(context: Context): List<LoadResult> {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -160,11 +161,10 @@ internal object ExtensionLoader {
if (extPkgs.isEmpty()) return emptyList() if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking { return coroutineScope {
val deferred = extPkgs.map { extPkgs.map {
async { loadExtension(context, it) } async { loadExtension(context, it) }
} }.awaitAll()
deferred.awaitAll()
} }
} }
@@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.common.util.lang.withIOContext
class NHentai(delegate: HttpSource, val context: Context) : class NHentai(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate), DelegatedHttpSource(delegate),
@@ -70,12 +71,8 @@ class NHentai(delegate: HttpSource, val context: Context) :
} }
override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) { override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
val body = input.body.string() if (nhConfig == null) getNhConfig()
val server = MEDIA_SERVER_REGEX.find(body)?.groupValues?.get(1)?.toInt() ?: 1 val jsonResponse = jsonParser.decodeFromString<JsonResponse>(input.body.string())
val json = GALLERY_JSON_REGEX.find(body)!!.groupValues[1].replace(
UNICODE_ESCAPE_REGEX,
) { it.groupValues[1].toInt(radix = 16).toChar().toString() }
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(json)
with(metadata) { with(metadata) {
nhId = jsonResponse.id nhId = jsonResponse.id
@@ -86,8 +83,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
mediaId = jsonResponse.mediaId mediaId = jsonResponse.mediaId
mediaServer = server
jsonResponse.title?.let { title -> jsonResponse.title?.let { title ->
japaneseTitle = title.japanese japaneseTitle = title.japanese
shortTitle = title.pretty shortTitle = title.pretty
@@ -96,15 +91,11 @@ class NHentai(delegate: HttpSource, val context: Context) :
preferredTitle = this@NHentai.preferredTitle preferredTitle = this@NHentai.preferredTitle
jsonResponse.images?.let { images -> coverImageUrl =
coverImageType = images.cover?.type jsonResponse.cover?.path?.let { "$thumbServer/$it" }
images.pages.mapNotNull { ?: jsonResponse.thumbnail?.path?.let { "$thumbServer/$it" }
it.type
}.let { pageImagePreviewUrls = jsonResponse.pages.mapNotNull { it.thumbnail }
pageImageTypes = it
}
thumbnailImageType = images.thumbnail?.type
}
scanlator = jsonResponse.scanlator?.trimOrNull() scanlator = jsonResponse.scanlator?.trimOrNull()
@@ -125,13 +116,22 @@ class NHentai(delegate: HttpSource, val context: Context) :
} }
} }
@Serializable
data class JsonConfig(
@SerialName("image_servers")
val imageServers: List<String> = emptyList(),
@SerialName("thumb_servers")
val thumbServers: List<String> = emptyList(),
)
@Serializable @Serializable
data class JsonResponse( data class JsonResponse(
val id: Long, val id: Long,
@SerialName("media_id") @SerialName("media_id")
val mediaId: String? = null, val mediaId: String? = null,
val title: JsonTitle? = null, val title: JsonTitle? = null,
val images: JsonImages? = null, val cover: JsonPage? = null,
val thumbnail: JsonPage? = null,
val scanlator: String? = null, val scanlator: String? = null,
@SerialName("upload_date") @SerialName("upload_date")
val uploadDate: Long? = null, val uploadDate: Long? = null,
@@ -140,6 +140,7 @@ class NHentai(delegate: HttpSource, val context: Context) :
val numPages: Int? = null, val numPages: Int? = null,
@SerialName("num_favorites") @SerialName("num_favorites")
val numFavorites: Long? = null, val numFavorites: Long? = null,
val pages: List<JsonPage> = emptyList(),
) )
@Serializable @Serializable
@@ -149,21 +150,12 @@ class NHentai(delegate: HttpSource, val context: Context) :
val pretty: String? = null, val pretty: String? = null,
) )
@Serializable
data class JsonImages(
val pages: List<JsonPage> = emptyList(),
val cover: JsonPage? = null,
val thumbnail: JsonPage? = null,
)
@Serializable @Serializable
data class JsonPage( data class JsonPage(
@SerialName("t") val path: String? = null,
val type: String? = null,
@SerialName("w")
val width: Long? = null, val width: Long? = null,
@SerialName("h")
val height: Long? = null, val height: Long? = null,
val thumbnail: String? = null,
) )
@Serializable @Serializable
@@ -188,15 +180,16 @@ class NHentai(delegate: HttpSource, val context: Context) :
} }
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage { override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
if (nhConfig == null) getNhConfig()
val metadata = fetchOrLoadMetadata(manga.id()) { val metadata = fetchOrLoadMetadata(manga.id()) {
client.newCall(mangaDetailsRequest(manga)).awaitSuccess() client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
} }
return PagePreviewPage( return PagePreviewPage(
page, page,
metadata.pageImageTypes.mapIndexed { index, s -> metadata.pageImagePreviewUrls.mapIndexed { index, path ->
PagePreviewInfo( PagePreviewInfo(
index + 1, index + 1,
imageUrl = thumbnailUrlFromType(metadata.mediaId!!, metadata.mediaServer ?: 1, index + 1, s)!!, imageUrl = "$thumbServer/$path",
) )
}, },
false, false,
@@ -204,15 +197,24 @@ class NHentai(delegate: HttpSource, val context: Context) :
) )
} }
private fun thumbnailUrlFromType( var nhConfig: JsonConfig? = null
mediaId: String, suspend fun getNhConfig() {
mediaServer: Int, try {
page: Int, val response =
t: String, withIOContext { client.newCall(GET("https://nhentai.net/api/v2/config", headers)).awaitSuccess() }
) = NHentaiSearchMetadata.typeToExtension(t)?.let { val body = response.body.string()
"https://t$mediaServer.nhentai.net/galleries/$mediaId/${page}t.$it" nhConfig = jsonParser.decodeFromString<JsonConfig>(body)
} catch (_: Exception) {
nhConfig = JsonConfig(
(1..4).map { n -> "https://i$n.nhentai.net" },
(1..4).map { n -> "https://t$n.nhentai.net" },
)
}
} }
val thumbServer
get() = nhConfig?.thumbServers?.random()
override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response { override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
return client.newCachelessCallWithProgress( return client.newCachelessCallWithProgress(
if (cacheControl != null) { if (cacheControl != null) {
@@ -230,10 +232,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
private val jsonParser = Json { private val jsonParser = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
private val GALLERY_JSON_REGEX = Regex(".parse\\(\"(.*)\"\\);")
private val MEDIA_SERVER_REGEX = Regex("media_server\\s*:\\s*(\\d+)")
private val UNICODE_ESCAPE_REGEX = Regex("\\\\u([0-9a-fA-F]{4})")
private const val TITLE_PREF = "Display manga title as:" private const val TITLE_PREF = "Display manga title as:"
} }
} }
@@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
@@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
fun getInstance(sourceId: Long): SourcePreferencesFragment { fun getInstance(sourceId: Long): SourcePreferencesFragment {
return SourcePreferencesFragment().apply { return SourcePreferencesFragment().apply {
arguments = bundleOf(SOURCE_ID to sourceId) arguments = Bundle().apply {
putLong(SOURCE_ID, sourceId)
}
} }
} }
} }
@@ -163,13 +163,6 @@ class MainActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val didMigration = if (isLaunch) {
addAnalytics()
Migrator.awaitAndRelease()
} else {
false
}
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079 // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) { if (!isTaskRoot) {
finish() finish()
@@ -177,11 +170,17 @@ class MainActivity : BaseActivity() {
} }
// SY --> // SY -->
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest") val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest")
// SY <-- // SY <--
setComposeContent { setComposeContent {
var didMigration by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(Unit) {
addAnalytics()
didMigration = Migrator.awaitAndRelease()
}
val context = LocalContext.current val context = LocalContext.current
var incognito by remember { mutableStateOf(getIncognitoState.await(null)) } var incognito by remember { mutableStateOf(getIncognitoState.await(null)) }
@@ -309,7 +308,7 @@ class MainActivity : BaseActivity() {
} }
// SY <-- // SY <--
var showChangelog by remember { mutableStateOf(didMigration && !BuildConfig.DEBUG) } var showChangelog by remember { mutableStateOf(didMigration == true && !BuildConfig.DEBUG) }
if (showChangelog) { if (showChangelog) {
// SY --> // SY -->
WhatsNewDialog(onDismissRequest = { showChangelog = false }) WhatsNewDialog(onDismissRequest = { showChangelog = false })
@@ -32,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -78,6 +79,7 @@ import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.reader.chapter.ReaderChapterItem
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@@ -101,6 +103,8 @@ import exh.source.isEhBasedSource
import exh.ui.ifSourcesLoaded import exh.ui.ifSourcesLoaded
import exh.util.defaultReaderType import exh.util.defaultReaderType
import exh.util.mangaType import exh.util.mangaType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
@@ -121,6 +125,7 @@ import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@@ -394,28 +399,36 @@ class ReaderActivity : BaseActivity() {
is ReaderViewModel.Dialog.ChapterList -> { is ReaderViewModel.Dialog.ChapterList -> {
var chapters by remember { var chapters by remember {
mutableStateOf(viewModel.getChapters().toImmutableList()) mutableStateOf<ImmutableList<ReaderChapterItem>?>(null)
}
LaunchedEffect(state.dialog) {
withIOContext {
chapters = viewModel.getChapters().toImmutableList()
}
}
if (chapters != null) {
ChapterListDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
chapters = chapters ?: persistentListOf(),
onClickChapter = {
viewModel.loadNewChapterFromDialog(it)
onDismissRequest()
},
onBookmark = { chapter ->
viewModel.toggleBookmark(chapter.id, !chapter.bookmark)
chapters = chapters?.map {
if (it.chapter.id == chapter.id) {
it.copy(chapter = chapter.copy(bookmark = !chapter.bookmark))
} else {
it
}
}?.toImmutableList()
},
state.dateRelativeTime,
)
} }
ChapterListDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
chapters = chapters,
onClickChapter = {
viewModel.loadNewChapterFromDialog(it)
onDismissRequest()
},
onBookmark = { chapter ->
viewModel.toggleBookmark(chapter.id, !chapter.bookmark)
chapters = chapters.map {
if (it.chapter.id == chapter.id) {
it.copy(chapter = chapter.copy(bookmark = !chapter.bookmark))
} else {
it
}
}.toImmutableList()
},
state.dateRelativeTime,
)
} }
// SY --> // SY -->
ReaderViewModel.Dialog.AutoScrollHelp -> AlertDialog( ReaderViewModel.Dialog.AutoScrollHelp -> AlertDialog(
@@ -591,8 +604,9 @@ class ReaderActivity : BaseActivity() {
} else { } else {
cropBorderContinuousVertical cropBorderContinuousVertical
} }
val readerBottomButtons by readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() } val readerBottomButtons by remember {
.collectAsState(persistentSetOf()) readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
}.collectAsState(persistentSetOf())
val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState() val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState()
val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState() val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState()
@@ -59,7 +59,6 @@ import exh.source.isEhBasedManga
import exh.util.defaultReaderType import exh.util.defaultReaderType
import exh.util.mangaType import exh.util.mangaType
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -183,19 +182,26 @@ class ReaderViewModel @JvmOverloads constructor(
private var chapterToDownload: Download? = null private var chapterToDownload: Download? = null
private val unfilteredChapterList by lazy { private var unfilteredChapterListCache: List<tachiyomi.domain.chapter.model.Chapter>? = null
val manga = manga!! private suspend fun getUnfilteredChapterList(): List<tachiyomi.domain.chapter.model.Chapter> {
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) } if (unfilteredChapterListCache == null) {
val manga = manga!!
unfilteredChapterListCache = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false)
}
return unfilteredChapterListCache!!
} }
/** /**
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
* time in a background thread to avoid blocking the UI. * time in a background thread to avoid blocking the UI.
*/ */
private val chapterList by lazy { private var chapterListCache: List<ReaderChapter>? = null
private suspend fun getChapterList(): List<ReaderChapter> {
chapterListCache?.let { return it }
val manga = manga!! val manga = manga!!
// SY --> // SY -->
val (chapters, mangaMap) = runBlocking { val (chapters, mangaMap) =
if (manga.source == MERGED_SOURCE_ID) { if (manga.source == MERGED_SOURCE_ID) {
getMergedChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to getMergedChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to
getMergedMangaById.await(manga.id) getMergedMangaById.await(manga.id)
@@ -203,7 +209,7 @@ class ReaderViewModel @JvmOverloads constructor(
} else { } else {
getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to null getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to null
} }
}
fun isChapterDownloaded(chapter: Chapter): Boolean { fun isChapterDownloaded(chapter: Chapter): Boolean {
val chapterManga = mangaMap?.get(chapter.mangaId) ?: manga val chapterManga = mangaMap?.get(chapter.mangaId) ?: manga
return downloadManager.isChapterDownloaded( return downloadManager.isChapterDownloaded(
@@ -253,7 +259,7 @@ class ReaderViewModel @JvmOverloads constructor(
else -> chapters else -> chapters
} }
chaptersForReader val result = chaptersForReader
.sortedWith(getChapterSort(manga, sortDescending = false)) .sortedWith(getChapterSort(manga, sortDescending = false))
.run { .run {
if (readerPreferences.skipDupe().get()) { if (readerPreferences.skipDupe().get()) {
@@ -271,6 +277,8 @@ class ReaderViewModel @JvmOverloads constructor(
} }
.map { it.toDbChapter() } .map { it.toDbChapter() }
.map(::ReaderChapter) .map(::ReaderChapter)
chapterListCache = result
return result
} }
private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) } private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) }
@@ -409,7 +417,7 @@ class ReaderViewModel @JvmOverloads constructor(
loadChapter( loadChapter(
loader!!, loader!!,
chapterList.first { chapterId == it.chapter.id }, getChapterList().first { chapterId == it.chapter.id },
/* SY --> */page, /* SY <-- */ /* SY --> */page, /* SY <-- */
) )
Result.success(true) Result.success(true)
@@ -427,10 +435,10 @@ class ReaderViewModel @JvmOverloads constructor(
} }
// SY --> // SY -->
fun getChapters(): List<ReaderChapterItem> { suspend fun getChapters(): List<ReaderChapterItem> {
val currentChapter = getCurrentChapter() val currentChapter = getCurrentChapter()
return chapterList.map { return getChapterList().map {
ReaderChapterItem( ReaderChapterItem(
chapter = it.chapter.toDomainChapter()!!, chapter = it.chapter.toDomainChapter()!!,
manga = manga!!, manga = manga!!,
@@ -454,6 +462,7 @@ class ReaderViewModel @JvmOverloads constructor(
): ViewerChapters { ): ViewerChapters {
loader.loadChapter(chapter /* SY --> */, page/* SY <-- */) loader.loadChapter(chapter /* SY --> */, page/* SY <-- */)
val chapterList = getChapterList()
val chapterPos = chapterList.indexOf(chapter) val chapterPos = chapterList.indexOf(chapter)
val newChapters = ViewerChapters( val newChapters = ViewerChapters(
chapter, chapter,
@@ -503,7 +512,7 @@ class ReaderViewModel @JvmOverloads constructor(
fun loadNewChapterFromDialog(chapter: Chapter) { fun loadNewChapterFromDialog(chapter: Chapter) {
viewModelScope.launchIO { viewModelScope.launchIO {
val newChapter = chapterList.firstOrNull { it.chapter.id == chapter.id } ?: return@launchIO val newChapter = getChapterList().firstOrNull { it.chapter.id == chapter.id } ?: return@launchIO
loadAdjacent(newChapter) loadAdjacent(newChapter)
} }
} }
@@ -665,11 +674,12 @@ class ReaderViewModel @JvmOverloads constructor(
* If both conditions are satisfied enqueues chapter for delete * If both conditions are satisfied enqueues chapter for delete
* @param currentChapter current chapter, which is going to be marked as read. * @param currentChapter current chapter, which is going to be marked as read.
*/ */
private fun deleteChapterIfNeeded(currentChapter: ReaderChapter) { private suspend fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots().get() val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots().get()
if (removeAfterReadSlots == -1) return if (removeAfterReadSlots == -1) return
// Determine which chapter should be deleted and enqueue // Determine which chapter should be deleted and enqueue
val chapterList = getChapterList()
val currentChapterPosition = chapterList.indexOf(currentChapter) val currentChapterPosition = chapterList.indexOf(currentChapter)
val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots) val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots)
@@ -739,7 +749,7 @@ class ReaderViewModel @JvmOverloads constructor(
// SY --> // SY -->
if (manga?.isEhBasedManga() == true) { if (manga?.isEhBasedManga() == true) {
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
val chapterUpdates = unfilteredChapterList val chapterUpdates = getUnfilteredChapterList()
.filter { it.sourceOrder > readerChapter.chapter.source_order } .filter { it.sourceOrder > readerChapter.chapter.source_order }
.map { chapter -> .map { chapter ->
ChapterUpdate( ChapterUpdate(
@@ -759,7 +769,7 @@ class ReaderViewModel @JvmOverloads constructor(
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING) .contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
if (!markDuplicateAsRead) return if (!markDuplicateAsRead) return
val duplicateUnreadChapters = unfilteredChapterList val duplicateUnreadChapters = getUnfilteredChapterList()
.mapNotNull { chapter -> .mapNotNull { chapter ->
if ( if (
!chapter.read && !chapter.read &&
@@ -774,7 +784,7 @@ class ReaderViewModel @JvmOverloads constructor(
updateChapter.awaitAll(duplicateUnreadChapters) updateChapter.awaitAll(duplicateUnreadChapters)
// SY --> // SY -->
duplicateUnreadChapters.forEach { chapterUpdate -> duplicateUnreadChapters.forEach { chapterUpdate ->
val chapter = unfilteredChapterList.first { it.id == chapterUpdate.id } val chapter = getUnfilteredChapterList().first { it.id == chapterUpdate.id }
deleteChapterIfNeeded(ReaderChapter(chapter)) deleteChapterIfNeeded(ReaderChapter(chapter))
} }
// SY <-- // SY <--
@@ -863,9 +873,9 @@ class ReaderViewModel @JvmOverloads constructor(
// SY --> // SY -->
fun toggleBookmark(chapterId: Long, bookmarked: Boolean) { fun toggleBookmark(chapterId: Long, bookmarked: Boolean) {
val chapter = chapterList.find { it.chapter.id == chapterId }?.chapter ?: return
chapter.bookmark = bookmarked
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
val chapter = getChapterList().find { it.chapter.id == chapterId }?.chapter ?: return@launchNonCancellable
chapter.bookmark = bookmarked
updateChapter.await( updateChapter.await(
ChapterUpdate( ChapterUpdate(
id = chapterId, id = chapterId,
@@ -900,7 +910,7 @@ class ReaderViewModel @JvmOverloads constructor(
*/ */
fun setMangaReadingMode(readingMode: ReadingMode) { fun setMangaReadingMode(readingMode: ReadingMode) {
val manga = manga ?: return val manga = manga ?: return
runBlocking(Dispatchers.IO) { viewModelScope.launchIO {
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong()) setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
val currChapters = state.value.viewerChapters val currChapters = state.value.viewerChapters
if (currChapters != null) { if (currChapters != null) {
@@ -251,7 +251,7 @@ class UpdatesScreenModel(
} }
} }
private fun startDownloadingNow(chapterId: Long) { private suspend fun startDownloadingNow(chapterId: Long) {
downloadManager.startDownloadNow(chapterId) downloadManager.startDownloadNow(chapterId)
} }
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
@@ -20,6 +21,7 @@ import java.time.ZoneId
class CrashLogUtil( class CrashLogUtil(
private val context: Context, private val context: Context,
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val preferences: BasePreferences = Injekt.get(),
) { ) {
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext { suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
@@ -44,6 +46,7 @@ class CrashLogUtil(
App ID: ${BuildConfig.APPLICATION_ID} App ID: ${BuildConfig.APPLICATION_ID}
App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME}) App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME})
Preview build: $syDebugVersion Preview build: $syDebugVersion
Installation ID: ${preferences.installationId().get()}
Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY}) Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY})
Device brand: ${Build.BRAND} Device brand: ${Build.BRAND}
Device manufacturer: ${Build.MANUFACTURER} Device manufacturer: ${Build.MANUFACTURER}
@@ -100,7 +100,7 @@ class EhLoginActivity : BaseActivity() {
let html = document.documentElement.innerHTML; let html = document.documentElement.innerHTML;
return html.includes("/cdn-cgi/"); return html.includes("/cdn-cgi/");
})(); })();
""".trimIndent() """.trimIndent(),
) { result -> ) { result ->
val isCloudflareBlock = result == "true" val isCloudflareBlock = result == "true"
@@ -60,8 +60,8 @@ fun NHentaiDescription(state: State.Success, openMetadataViewer: () -> Unit) {
binding.pages.text = context.pluralStringResource( binding.pages.text = context.pluralStringResource(
SYMR.plurals.num_pages, SYMR.plurals.num_pages,
meta.pageImageTypes.size, meta.pageImagePreviewUrls.size,
meta.pageImageTypes.size, meta.pageImagePreviewUrls.size,
) )
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24) binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24)
@@ -35,7 +35,7 @@ object Migrator {
result = null result = null
} }
fun awaitAndRelease(): Boolean = runBlocking { suspend fun awaitAndRelease(): Boolean {
await().also { release() } return await().also { release() }
} }
} }
@@ -0,0 +1,18 @@
package mihon.core.migration.migrations
import eu.kanade.domain.base.BasePreferences
import mihon.core.common.FeatureFlags
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import kotlin.uuid.ExperimentalUuidApi
class InstallationIdMigration : Migration {
override val version: Float = Migration.ALWAYS
@OptIn(ExperimentalUuidApi::class)
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
val installationId = migrationContext.get<BasePreferences>()?.installationId() ?: return false
if (!installationId.isSet()) installationId.set(FeatureFlags.newInstallationId())
return true
}
}
@@ -47,4 +47,5 @@ val migrations: List<Migration>
TrustExtensionRepositoryMigration(), TrustExtensionRepositoryMigration(),
CategoryPreferencesCleanupMigration(), CategoryPreferencesCleanupMigration(),
RemoveDuplicateReaderPreferenceMigration(), RemoveDuplicateReaderPreferenceMigration(),
InstallationIdMigration(),
) )
@@ -39,6 +39,10 @@ class MoveSortingModeSettingsMigration : Migration {
categoryId = it.id, categoryId = it.id,
flags = it.flags and 0b00111100L.inv(), flags = it.flags and 0b00111100L.inv(),
name = null, name = null,
version = it.version,
uid = it.uid,
last_modified_at = null,
isSyncing = null,
order = null, order = null,
) )
} }
@@ -73,6 +73,7 @@ class MigrateMangaUseCase(
updatedChapter = updatedChapter.copy( updatedChapter = updatedChapter.copy(
dateFetch = prevChapter.dateFetch, dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark, bookmark = prevChapter.bookmark,
lastPageRead = prevChapter.lastPageRead,
) )
} }
@@ -0,0 +1,12 @@
package mihon.core.common
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object FeatureFlags {
@OptIn(ExperimentalUuidApi::class)
fun newInstallationId(): String {
return Uuid.random().toHexDashString()
}
}
@@ -5,6 +5,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.asContextElement import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.RejectedExecutionException import java.util.concurrent.RejectedExecutionException
import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.AtomicInt
@@ -17,6 +19,10 @@ import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
// Global mutex to serialize transaction entry and prevent thread pool exhaustion.
// If you have multiple distinct database files/handlers, this should be a property of AndroidDatabaseHandler.
private val transactionMutex = Mutex()
/** /**
* Returns the transaction dispatcher if we are on a transaction, or the database dispatchers. * Returns the transaction dispatcher if we are on a transaction, or the database dispatchers.
*/ */
@@ -39,20 +45,41 @@ internal suspend fun AndroidDatabaseHandler.getCurrentDatabaseContext(): Corouti
* The dispatcher used to execute the given [block] will utilize threads from SQLDelight's query executor. * The dispatcher used to execute the given [block] will utilize threads from SQLDelight's query executor.
*/ */
internal suspend fun <T> AndroidDatabaseHandler.withTransaction(block: suspend () -> T): T { internal suspend fun <T> AndroidDatabaseHandler.withTransaction(block: suspend () -> T): T {
// Use inherited transaction context if available, this allows nested suspending transactions. val transactionElement = coroutineContext[TransactionElement]
val transactionContext =
coroutineContext[TransactionElement]?.transactionDispatcher ?: createTransactionContext() // If we are already in a transaction, we don't need to lock the Mutex.
return withContext(transactionContext) { // We just reuse the existing thread/context.
val transactionElement = coroutineContext[TransactionElement]!! if (transactionElement != null) {
transactionElement.acquire() return withContext(transactionElement.transactionDispatcher) {
try { transactionElement.acquire()
db.transactionWithResult { try {
runBlocking(transactionContext) { db.transactionWithResult {
block() runBlocking(transactionElement.transactionDispatcher) {
block()
}
} }
} finally {
transactionElement.release()
}
}
}
// transaction: Acquire Mutex BEFORE acquiring a thread.
// This ensures we only block a real thread when we have exclusive access.
return transactionMutex.withLock {
val transactionContext = createTransactionContext()
withContext(transactionContext) {
val element = coroutineContext[TransactionElement]!!
element.acquire()
try {
db.transactionWithResult {
runBlocking(transactionContext) {
block()
}
}
} finally {
element.release()
} }
} finally {
transactionElement.release()
} }
} }
} }
@@ -8,12 +8,18 @@ object CategoryMapper {
name: String, name: String,
order: Long, order: Long,
flags: Long, flags: Long,
version: Long,
uid: Long,
lastModifiedAt: Long,
): Category { ): Category {
return Category( return Category(
id = id, id = id,
name = name, name = name,
order = order, order = order,
flags = flags, flags = flags,
version = version,
uid = uid,
lastModifiedAt = lastModifiedAt,
) )
} }
} }
@@ -42,6 +42,9 @@ class CategoryRepositoryImpl(
name = category.name, name = category.name,
order = category.order, order = category.order,
flags = category.flags, flags = category.flags,
version = category.version,
uid = category.uid,
last_modified_at = category.lastModifiedAt,
) )
categoriesQueries.selectLastInsertedRowId() categoriesQueries.selectLastInsertedRowId()
} }
@@ -67,6 +70,10 @@ class CategoryRepositoryImpl(
name = update.name, name = update.name,
order = update.order, order = update.order,
flags = update.flags, flags = update.flags,
version = update.version,
uid = update.uid,
last_modified_at = update.lastModifiedAt,
isSyncing = null,
categoryId = update.id, categoryId = update.id,
) )
} }
@@ -6,11 +6,15 @@ CREATE TABLE categories(
name TEXT NOT NULL, name TEXT NOT NULL,
sort INTEGER NOT NULL, sort INTEGER NOT NULL,
flags INTEGER NOT NULL, flags INTEGER NOT NULL,
manga_order TEXT AS List<Long> NOT NULL manga_order TEXT AS List<Long> NOT NULL,
version INTEGER NOT NULL DEFAULT 0,
uid INTEGER NOT NULL DEFAULT 0,
last_modified_at INTEGER NOT NULL DEFAULT 0,
is_syncing INTEGER NOT NULL DEFAULT 0
); );
-- Insert system category -- Insert system category
INSERT OR IGNORE INTO categories(_id, name, sort, flags, manga_order) VALUES (0, "", -1, 0, ""); INSERT OR IGNORE INTO categories(_id, name, sort, flags, manga_order, version, uid, last_modified_at, is_syncing) VALUES (0, "", -1, 0, "", 0, 0, 0, 0);
-- Disallow deletion of default category -- Disallow deletion of default category
CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE
ON categories ON categories
@@ -20,8 +24,29 @@ BEGIN SELECT CASE
END; END;
END; END;
CREATE TRIGGER update_category_version AFTER UPDATE ON categories
WHEN new.is_syncing = 0 AND (
new.name != old.name OR
new.sort != old.sort OR
new.flags != old.flags
)
BEGIN
UPDATE categories
SET version = version + 1,
last_modified_at = strftime('%s', 'now')
WHERE _id = new._id;
END;
CREATE TRIGGER insert_category_uid AFTER INSERT ON categories
BEGIN
UPDATE categories
SET uid = CASE WHEN uid = 0 THEN abs(random()) ELSE uid END,
last_modified_at = CASE WHEN last_modified_at = 0 THEN strftime('%s', 'now') ELSE last_modified_at END
WHERE _id = new._id;
END;
getCategory: getCategory:
SELECT _id,name,sort,flags SELECT _id,name,sort,flags,version,uid,last_modified_at
FROM categories FROM categories
WHERE _id = :id WHERE _id = :id
LIMIT 1; LIMIT 1;
@@ -31,7 +56,10 @@ SELECT
_id AS id, _id AS id,
name, name,
sort AS `order`, sort AS `order`,
flags flags,
version,
uid,
last_modified_at
FROM categories FROM categories
ORDER BY sort; ORDER BY sort;
@@ -40,15 +68,18 @@ SELECT
C._id AS id, C._id AS id,
C.name, C.name,
C.sort AS `order`, C.sort AS `order`,
C.flags C.flags,
C.version,
C.uid,
C.last_modified_at
FROM categories C FROM categories C
JOIN mangas_categories MC JOIN mangas_categories MC
ON C._id = MC.category_id ON C._id = MC.category_id
WHERE MC.manga_id = :mangaId; WHERE MC.manga_id = :mangaId;
insert: insert:
INSERT INTO categories(name, sort, flags, manga_order) INSERT INTO categories(name, sort, flags, manga_order, version, uid, last_modified_at)
VALUES (:name, :order, :flags, ""); VALUES (:name, :order, :flags, "", :version, :uid, :last_modified_at);
delete: delete:
DELETE FROM categories DELETE FROM categories
@@ -58,7 +89,11 @@ update:
UPDATE categories UPDATE categories
SET name = coalesce(:name, name), SET name = coalesce(:name, name),
sort = coalesce(:order, sort), sort = coalesce(:order, sort),
flags = coalesce(:flags, flags) flags = coalesce(:flags, flags),
version = coalesce(:version, version),
uid = coalesce(:uid, uid),
last_modified_at = coalesce(:last_modified_at, last_modified_at),
is_syncing = coalesce(:isSyncing, is_syncing)
WHERE _id = :categoryId; WHERE _id = :categoryId;
updateAllFlags: updateAllFlags:
@@ -67,3 +102,8 @@ flags = coalesce(?, flags);
selectLastInsertedRowId: selectLastInsertedRowId:
SELECT last_insert_rowid(); SELECT last_insert_rowid();
resetIsSyncing:
UPDATE categories
SET is_syncing = 0
WHERE is_syncing = 1;
@@ -0,0 +1,27 @@
ALTER TABLE categories ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE categories ADD COLUMN uid INTEGER NOT NULL DEFAULT 0;
ALTER TABLE categories ADD COLUMN last_modified_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE categories ADD COLUMN is_syncing INTEGER NOT NULL DEFAULT 0;
UPDATE categories SET uid = abs(random());
CREATE TRIGGER insert_category_uid AFTER INSERT ON categories
BEGIN
UPDATE categories
SET uid = CASE WHEN uid = 0 THEN abs(random()) ELSE uid END,
last_modified_at = CASE WHEN last_modified_at = 0 THEN strftime('%s', 'now') ELSE last_modified_at END
WHERE _id = new._id;
END;
CREATE TRIGGER update_category_version AFTER UPDATE ON categories
WHEN new.is_syncing = 0 AND (
new.name != old.name OR
new.sort != old.sort OR
new.flags != old.flags
)
BEGIN
UPDATE categories
SET version = version + 1,
last_modified_at = strftime('%s', 'now')
WHERE _id = new._id;
END;
@@ -7,6 +7,9 @@ data class Category(
val name: String, val name: String,
val order: Long, val order: Long,
val flags: Long, val flags: Long,
val version: Long = 0,
val uid: Long = 0,
val lastModifiedAt: Long = 0,
) : Serializable { ) : Serializable {
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
@@ -5,4 +5,7 @@ data class CategoryUpdate(
val name: String? = null, val name: String? = null,
val order: Long? = null, val order: Long? = null,
val flags: Long? = null, val flags: Long? = null,
val version: Long? = null,
val uid: Long? = null,
val lastModifiedAt: Long? = null,
) )
+5 -2
View File
@@ -1,8 +1,9 @@
[versions] [versions]
agp_version = "8.13.2" agp_version = "8.13.2"
lifecycle_version = "2.10.0" lifecycle_version = "2.10.0"
paging_version = "3.4.1" paging_version = "3.4.2"
interpolator_version = "1.0.0" interpolator_version = "1.0.0"
sqlite = "2.6.2"
[libraries] [libraries]
gradle = { module = "com.android.tools.build:gradle", version.ref = "agp_version" } gradle = { module = "com.android.tools.build:gradle", version.ref = "agp_version" }
@@ -11,7 +12,7 @@ annotation = "androidx.annotation:annotation:1.9.1"
appcompat = "androidx.appcompat:appcompat:1.7.1" appcompat = "androidx.appcompat:appcompat:1.7.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.1" constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.1"
corektx = "androidx.core:core-ktx:1.17.0" corektx = "androidx.core:core-ktx:1.18.0"
splashscreen = "androidx.core:core-splashscreen:1.2.0" splashscreen = "androidx.core:core-splashscreen:1.2.0"
recyclerview = "androidx.recyclerview:recyclerview:1.4.0" recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
viewpager = "androidx.viewpager:viewpager:1.1.0" viewpager = "androidx.viewpager:viewpager:1.1.0"
@@ -33,5 +34,7 @@ test-ext = "androidx.test.ext:junit-ktx:1.3.0"
test-espresso-core = "androidx.test.espresso:espresso-core:3.7.0" test-espresso-core = "androidx.test.espresso:espresso-core:3.7.0"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0" test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
[bundles] [bundles]
lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"] lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"]
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
compose-bom = "2026.02.00" compose-bom = "2026.03.00"
[libraries] [libraries]
activity = "androidx.activity:activity-compose:1.12.4" activity = "androidx.activity:activity-compose:1.13.0"
bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
foundation = { module = "androidx.compose.foundation:foundation" } foundation = { module = "androidx.compose.foundation:foundation" }
animation = { module = "androidx.compose.animation:animation" } animation = { module = "androidx.compose.animation:animation" }
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
kotlin_version = "2.3.10" kotlin_version = "2.3.20"
serialization_version = "1.10.0" serialization_version = "1.10.0"
xml_serialization_version = "0.91.3" xml_serialization_version = "0.91.3"
+10 -15
View File
@@ -1,18 +1,17 @@
[versions] [versions]
aboutlib_version = "13.2.1" aboutlib_version = "13.2.1"
leakcanary = "2.14" leakcanary = "2.14"
moko = "0.26.0" moko = "0.26.1"
okhttp_version = "5.3.2" okhttp_version = "5.3.2"
shizuku_version = "13.1.5" shizuku_version = "13.1.5"
sqldelight = "2.2.1" sqldelight = "2.3.2"
sqlite = "2.6.2"
voyager = "1.1.0-beta03" voyager = "1.1.0-beta03"
spotless = "8.2.1" spotless = "8.3.0"
ktlint-core = "1.8.0" ktlint-core = "1.8.0"
firebase-bom = "34.9.0" firebase-bom = "34.10.0"
markdown = "0.39.2" markdown = "0.39.2"
junit = "6.0.3" junit = "6.0.3"
materialKolor = "5.0.0-alpha06" materialKolor = "5.0.0-alpha07"
[libraries] [libraries]
desugar = "com.android.tools:desugar_jdk_libs:2.1.5" desugar = "com.android.tools:desugar_jdk_libs:2.1.5"
@@ -24,7 +23,7 @@ okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp_ve
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp_version" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp_version" }
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp_version" } okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp_version" }
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp_version" } okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp_version" }
okio = "com.squareup.okio:okio:3.16.4" okio = "com.squareup.okio:okio:3.17.0"
conscrypt-android = "org.conscrypt:conscrypt-android:2.5.3" conscrypt-android = "org.conscrypt:conscrypt-android:2.5.3"
@@ -36,10 +35,6 @@ disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc" unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
libarchive = "me.zhanghai.android.libarchive:library:1.1.6" libarchive = "me.zhanghai.android.libarchive:library:1.1.6"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }
sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" }
sqlite-android = "com.github.requery:sqlite-android:3.49.0"
preferencektx = "androidx.preference:preference-ktx:1.2.1" preferencektx = "androidx.preference:preference-ktx:1.2.1"
injekt = "com.github.null2264:injekt-koin:ee267b2e27" injekt = "com.github.null2264:injekt-koin:ee267b2e27"
@@ -85,13 +80,14 @@ leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", ve
leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" } leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-androidx-driver = { module = "com.eygraber:sqldelight-androidx-driver", version = "0.0.17" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions-jvm", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions-jvm", version.ref = "sqldelight" }
sqldelight-android-paging = { module = "app.cash.sqldelight:androidx-paging3-extensions", version.ref = "sqldelight" } sqldelight-android-paging = { module = "app.cash.sqldelight:androidx-paging3-extensions", version.ref = "sqldelight" }
sqldelight-dialects-sql = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version = "2.2.1" } sqldelight-dialects-sql = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
kotest-assertions = "io.kotest:kotest-assertions-core:6.1.4" kotest-assertions = "io.kotest:kotest-assertions-core:6.1.7"
mockk = "io.mockk:mockk:1.14.9" mockk = "io.mockk:mockk:1.14.9"
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
@@ -119,10 +115,9 @@ firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.
[bundles] [bundles]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"] js-engine = ["quickjs-android"]
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]
coil = ["coil-core", "coil-gif", "coil-compose", "coil-network-okhttp"] coil = ["coil-core", "coil-gif", "coil-compose", "coil-network-okhttp"]
shizuku = ["shizuku-api", "shizuku-provider"] shizuku = ["shizuku-api", "shizuku-provider"]
sqldelight = ["sqldelight-android-driver", "sqldelight-coroutines", "sqldelight-android-paging"] sqldelight = ["sqldelight-android-driver", "sqldelight-androidx-driver", "sqldelight-coroutines", "sqldelight-android-paging"]
voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-transitions"] voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-transitions"]
test = ["junit-jupiter", "kotest-assertions", "mockk"] test = ["junit-jupiter", "kotest-assertions", "mockk"]
markdown = ["markdown-core", "markdown-coil"] markdown = ["markdown-core", "markdown-coil"]
+2 -2
View File
@@ -1,5 +1,5 @@
[versions] [versions]
koin = "4.1.1" koin = "4.2.0"
[libraries] [libraries]
xlog = "com.elvishew:xlog:1.11.1" xlog = "com.elvishew:xlog:1.11.1"
@@ -9,7 +9,7 @@ composeRatingbar = "com.github.a914-gowtham:compose-ratingbar:1.3.12"
versionsx = "com.github.ben-manes:gradle-versions-plugin:0.51.0" versionsx = "com.github.ben-manes:gradle-versions-plugin:0.51.0"
sqlcipher = "net.zetetic:sqlcipher-android:4.13.0" sqlcipher = "net.zetetic:sqlcipher-android:4.14.1"
exifinterface = "androidx.exifinterface:exifinterface:1.3.7" exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="cleanup_done">
<item quantity="one">Ачыстка завершана. Выдалена %d тэчка</item>
<item quantity="few">Ачыстка завершана. Выдалена %d тэчкі</item>
<item quantity="many">Ачыстка завершана. Выдалена %d тэчак</item>
<item quantity="other">Ачыстка завершана. Выдалена %d тэчак</item>
</plurals>
<plurals name="num_lock_times">
<item quantity="one">%d таймер блакіроўкі</item>
<item quantity="few">%d таймеры блакіроўкі</item>
<item quantity="many">%d таймераў блакіроўкі</item>
<item quantity="other">%d таймераў блакіроўкі</item>
</plurals>
<plurals name="eh_retry_toast">
<item quantity="one">Паўтор %1$d няўдалай старонкі…</item>
<item quantity="few">Паўтор %1$d няўдалых старонак…</item>
<item quantity="many">Паўтор %1$d няўдалых старонак…</item>
<item quantity="other">Паўтор %1$d няўдалых старонак…</item>
</plurals>
<plurals name="pref_tag_sorting_desc">
<item quantity="one">%1$d тэг у спісе сартавання. Ён дадае магчымасць сартаваць бібліятэку так, каб творы з больш прыярытэтнымі тэгамі ішлі першымі</item>
<item quantity="few">%1$d тэгі ў спісе сартавання. Яны дадаюць магчымасць сартаваць бібліятэку так, каб творы з больш прыярытэтнымі тэгамі ішлі першымі</item>
<item quantity="many">%1$d тэгаў у спісе сартавання. Яны дадаюць магчымасць сартаваць бібліятэку так, каб творы з больш прыярытэтнымі тэгамі ішлі першымі</item>
<item quantity="other">%1$d тэгаў у спісе сартавання. Яны дадаюць магчымасць сартаваць бібліятэку так, каб творы з больш прыярытэтнымі тэгамі ішлі першымі</item>
</plurals>
<plurals name="migrate_entry">
<item quantity="one">Перанесці %1$d%2$s твор?</item>
<item quantity="few">Перанесці %1$d%2$s творы?</item>
<item quantity="many">Перанесці %1$d%2$s твораў?</item>
<item quantity="other">Перанесці %1$d%2$s твораў?</item>
</plurals>
<plurals name="copy_entry">
<item quantity="one">Скапіяваць %1$d%2$s твор?</item>
<item quantity="few">Скапіяваць %1$d%2$s творы?</item>
<item quantity="many">Скапіяваць %1$d%2$s твораў?</item>
<item quantity="other">Скапіяваць %1$d%2$s твораў?</item>
</plurals>
<plurals name="entry_migrated">
<item quantity="one">%d твор перанесен</item>
<item quantity="few">%d творы перанесены</item>
<item quantity="many">%d твораў перанесена</item>
<item quantity="other">%d твораў перанесена</item>
</plurals>
<plurals name="num_pages">
<item quantity="one">%1$d старонка</item>
<item quantity="few">%1$d старонкі</item>
<item quantity="many">%1$d старонак</item>
<item quantity="other">%1$d старонак</item>
</plurals>
<plurals name="browse_language_and_pages">
<item quantity="one">%2$s, %1$d старонка</item>
<item quantity="few">%2$s, %1$d старонкі</item>
<item quantity="many">%2$s, %1$d старонак</item>
<item quantity="other">%2$s, %1$d старонак</item>
</plurals>
<plurals name="humanize_year">
<item quantity="one">%1$d год таму</item>
<item quantity="few">%1$d гады таму</item>
<item quantity="many">%1$d гадоў таму</item>
<item quantity="other">%1$d гадоў таму</item>
</plurals>
<plurals name="humanize_month">
<item quantity="one">%1$d месяц таму</item>
<item quantity="few">%1$d месяцы таму</item>
<item quantity="many">%1$d месяцаў таму</item>
<item quantity="other">%1$d месяцаў таму</item>
</plurals>
<plurals name="humanize_week">
<item quantity="one">%1$d тыдзень таму</item>
<item quantity="few">%1$d тыдні таму</item>
<item quantity="many">%1$d тыдняў таму</item>
<item quantity="other">%1$d тыдняў таму</item>
</plurals>
<plurals name="humanize_day">
<item quantity="one">%1$d дзень таму</item>
<item quantity="few">%1$d дні таму</item>
<item quantity="many">%1$d дзён таму</item>
<item quantity="other">%1$d дзён таму</item>
</plurals>
<plurals name="humanize_hour">
<item quantity="one">%1$d гадзіну таму</item>
<item quantity="few">%1$d гадзіны таму</item>
<item quantity="many">%1$d гадзін таму</item>
<item quantity="other">%1$d гадзін таму</item>
</plurals>
<plurals name="humanize_minute">
<item quantity="one">%1$d хвіліну таму</item>
<item quantity="few">%1$d хвіліны таму</item>
<item quantity="many">%1$d хвілін таму</item>
<item quantity="other">%1$d хвілін таму</item>
</plurals>
<plurals name="humanize_second">
<item quantity="one">%1$d секунду таму</item>
<item quantity="few">%1$d секунды таму</item>
<item quantity="many">%1$d секунд таму</item>
<item quantity="other">%1$d секунд таму</item>
</plurals>
<plurals name="row_count">
<item quantity="one">%d радок</item>
<item quantity="few">%d радкі</item>
<item quantity="many">%d радкоў</item>
<item quantity="other">%d радкоў</item>
</plurals>
</resources>
@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="clear_db_exclude_read">Пакідаць творы з прачытанымі раздзеламі</string>
<string name="author">Аўтар</string>
<string name="artist">Мастак</string>
<string name="action_skip_entry">Не міграваць</string>
<string name="action_search_manually">Шукаць уручную</string>
<string name="action_migrate_now">Міграваць зараз</string>
<string name="action_copy_now">Скапіяваць зараз</string>
<string name="action_clean_titles">Ачысціць назвы</string>
<string name="action_start_reading">Пачаць чытанне</string>
<string name="action_edit_info">Рэдагаваць зведкі</string>
<string name="entry_type_manga">Манга</string>
<string name="entry_type_manhwa">Манхва</string>
<string name="entry_type_manhua">Маньхуа</string>
<string name="entry_type_comic">Комікс</string>
<string name="entry_type_webtoon">Вэбтун</string>
<string name="pref_category_all_sources">Усе крыніцы</string>
<string name="pref_category_eh">E-Hentai</string>
<string name="pref_category_fork">Налады форка</string>
<string name="pref_category_mangadex">MangaDex</string>
<string name="pref_ehentai_summary">Уваход у E(x)Hentai, сінхранізацыя галерэй</string>
<string name="pref_mangadex_summary">Уваход у MangaDex, сінхранізацыя адсочванага</string>
<string name="changelog_version">Версія %1$s</string>
<string name="ehentai_prefs_account_settings">Налады ўліковага запісу E-Hentai</string>
<string name="enable_exhentai">Уключыць ExHentai</string>
<string name="requires_login">Патрабуюцца логін</string>
<string name="use_hentai_at_home">Выкарыстоўваць сетку Hentai@Home</string>
<string name="use_hentai_at_home_summary">Ці жадаеце вы загружаць выявы праз сетку Hentai@Home, калі яна даступная? Адключэнне параметра можа паменшыць колькасць даступных для прагляду старонак\nВарыянты:\n- Любы кліент (Рэкамендуецца)\n- Толькі кліенты партоў па змаўчанні (Можа быць марудней. Уключыце, калі вы знаходзіцеся за брандмаўэрам ці проксі, які блакуе нестандартныя выходныя порты.)</string>
<string name="use_hentai_at_home_option_1">Любы кліент (Рэкамендуецца)</string>
<string name="use_hentai_at_home_option_2">Толькі кліенты партоў па змаўчанні</string>
<string name="show_japanese_titles">Паказваць японскія назвы ў выніках пошуку</string>
<string name="show_japanese_titles_option_1">Зараз паказваюцца японскія назвы. Пасля змены ачысціце кэш раздзелаў (Налады → Дадаткова)</string>
<string name="show_japanese_titles_option_2">Зараз паказваюцца англійскія або лацінізаваныя назвы. Пасля змены ачысціце кэш раздзелаў (Налады → Дадаткова)</string>
<string name="use_original_images">Выкарыстоўваць арыгінальныя выявы</string>
<string name="use_original_images_on">Зараз выкарыстоўваюцца арыгінальныя выявы</string>
<string name="use_original_images_off">Зараз выкарыстоўваецца перадыскрэтызацыя выяў</string>
<string name="watched_tags">Адсочваныя тэгі</string>
<string name="watched_tags_summary">Адкрывае WebView са старонкай адсочваныя тэгі на E(x)Hentai</string>
<string name="watched_tags_exh">Адсочваныя тэгі ExHentai</string>
<string name="tag_filtering_threshold">Парог фільтрацыі тэгаў</string>
<string name="tag_filtering_threshhold_error">Павінна быць паміж -9999 і 0!</string>
<string name="tag_filtering_threshhold_summary">Вы можаце мякка фільтраваць тэгі на старонцы «My Tags» E(x)Hentai праз адмоўную вагу. Калі сумарная вага ніжэйшая за гэту, галерэя будзе схавана. Дыяпазон: ад -9999 да 0. Зараз: %1$d</string>
<string name="tag_watching_threshhold">Парог адсочвання тэгаў</string>
<string name="tag_watching_threshhold_error">Павінна быць паміж 0 і 9999!</string>
<string name="tag_watching_threshhold_summary">Новыя галерэі трапляюць у адсочванне, калі маюць хоць адзін тэг з дадатнай вагой, а іх сумарная вага не ніжэйшая за гэту. Дыяпазон: ад 0 да 9999. Зараз: %1$d</string>
<string name="language_filtering">Фільтрацыя па мовах</string>
<string name="language_filtering_summary">Выберыце мовы, галерэі на якіх трэба схаваць са спісаў і пошуку.\nТакія галерэі не будуць паказвацца зусім, незалежна ад запыту.\nПазначана ў дыялоге ⇒ выключана</string>
<string name="frong_page_categories">Катэгорыі на галоўнай старонцы</string>
<string name="fromt_page_categories_summary">Якія катэгорыі паказваць па змаўчанні на галоўнай старонцы і пры пошуку? Іх усё яшчэ можна ўключыць праз адпаведныя фільтры</string>
<string name="watched_list_default">Фільтр адсочваных спісаў па змаўчанні</string>
<string name="watched_list_state_summary">Ці павінны фільтр адсочваных спісаў быць уключаны па змаўчанні падчас прагляду E(x)Hentai</string>
<string name="eh_image_quality_summary">Якасць спампаваных выяў</string>
<string name="eh_image_quality">Якасць выяў</string>
<string name="eh_image_quality_auto">Аўта</string>
<string name="eh_image_quality_2400">2400x</string>
<string name="eh_image_quality_1600">1600x</string>
<string name="eh_image_quality_1280">1280x</string>
<string name="eh_image_quality_980">980x</string>
<string name="eh_image_quality_780">780x</string>
<string name="pref_enhanced_e_hentai_view">Пашыраны агляд E(x)Hentai</string>
<string name="pref_enhanced_e_hentai_view_summary">Пераключыць пашыранае меню агляду, створанае для E(x)Hentai</string>
<string name="favorites_sync">Сінхранізацыя абраннага E-Hentai</string>
<string name="disable_favorites_uploading">Адключыць запампоўку абраннага</string>
<string name="disable_favorites_uploading_summary">Абраннае толькі спампоўваецца з ExHentai. Змены ў праграме не будуць запампаваны назад, што беражэ ад выпадковых страт. Улічыце: калі прыбраць твор на сайце, ён знікне і ў праграме.</string>
<string name="show_favorite_sync_notes">Паказваць нататкі сінхранізацыі абранага</string>
<string name="show_favorite_sync_notes_summary">Паказваць звесткі пра функцыю сінхранізацыі абраннага</string>
<string name="please_login">Калі ласка, увайдзіце!</string>
<string name="ignore_sync_errors">Ігнараваць памылкі сінхранізацыі, калі магчыма</string>
<string name="ignore_sync_errors_summary">Не перарываць сінхранізацыю адразу пры памылке, памылкі адлюструюцца пасля завяршэння сінхранізацыі. Можа прывесці да страты абраннага. Карысна для вялікіх бібліятэк.</string>
<string name="force_sync_state_reset">Прымусовы скід стану сінхранізацыі</string>
<string name="force_sync_state_reset_summary">Выконвае поўную рэсінхранізацыя пры наступным запуску. Выдаленні не ўлічваюцца. Усё абраннае будзе перазапампавана на ExHentai і спампавана назад. Дапамагае аднавіцца пасля перарыванняў сінхранізацыі.</string>
<string name="sync_state_reset">Скід стану сінхранізацыі</string>
<string name="gallery_update_checker">Праверка абнаўленняў галерэй</string>
<string name="auto_update_restrictions">Абмежаванні аўтаабнаўленняў</string>
<string name="time_between_batches">Час паміж пачкамі абнаўленняў</string>
<string name="time_between_batches_never">Ніколі не абнаўляць галерэі</string>
<string name="time_between_batches_1_hour">1 гадзіна</string>
<string name="time_between_batches_2_hours">2 гадзіны</string>
<string name="time_between_batches_3_hours">3 гадзіны</string>
<string name="time_between_batches_6_hours">6 гадзін</string>
<string name="time_between_batches_12_hours">12 гадзін</string>
<string name="time_between_batches_24_hours">24 гадзіны</string>
<string name="time_between_batches_48_hours">48 гадзін</string>
<string name="time_between_batches_summary_1">Зараз %1$s не будзе правяраць галерэі вашай бібліятэцы на абнаўленні.</string>
<string name="time_between_batches_summary_2">%1$s правярае і абнаўляе галерэі пачкамі. Гэта значыць, што яна пачакае %2$d гадз., праверыць %3$d галерэй, пачакае %2$d гадз., праверыць %3$d і г.д.…</string>
<string name="show_updater_statistics">Паказаць статыстыку абнаўляльніку</string>
<string name="gallery_updater_statistics_collection">Збор статыстыкі…</string>
<string name="gallery_updater_statistics">Статыстыка абнаўляльніку галерэй</string>
<string name="gallery_updater_stats_text">Апошні раз абнаўляльнік запускаўся %1$s і праверыў %2$d з %3$d галерэй, гатовых да праверкі.</string>
<string name="gallery_updater_not_ran_yet">Абнаўляльнік яшчэ не запускаўся.</string>
<string name="gallery_updater_stats_time">\nГалерэі, правераныя за апошнюю…\n- гадзіну: %1$d\n- 6 гадзін: %2$d\n- 12 гадзін: %3$d\n- дзень: %4$d\n- 2 дні: %5$d\n- тыдзень: %6$d\n- месяц: %7$d\n- год: %8$d</string>
<string name="settings_profile_note">Нататка профілю налад</string>
<string name="settings_profile_note_message">Праграма дадасць новы профіль налад на E(x)Hentai для аптымізацыі сваёй працы. Калі ласка, упэўніцеся, што на абодвух сайтах не больш за тры профілі.\n\nКалі вы не ведаеце, што такое профілі налад, проста націсніце «ОК».</string>
<string name="eh_settings_successfully_uploaded">Налады паспяхова запампаваны!</string>
<string name="eh_settings_configuration_failed">Памылка канфігурацыі!</string>
<string name="eh_settings_configuration_failed_message">Падчас канфігуравання адбылася памылка: %1$s</string>
<string name="eh_settings_uploading_to_server">Запампоўка налад на сервер</string>
<string name="eh_settings_uploading_to_server_message">Калі ласка, пачакайце, гэта можа заняць некаторы час…</string>
<string name="eh_settings_out_of_slots_error">На %1$s скончыліся слоты для профіляў. Калі ласка, выдаліце хоць адзін!</string>
<string name="recheck_login_status">Пераправерыць статус уваходу</string>
<string name="alternative_login_page">Альтэрнатыўная старонка ўваходу</string>
<string name="skip_page_restyling">Прапусціць пераафармленне старонкі</string>
<string name="custom_igneous_cookie">Уласныя igneous-кукі</string>
<string name="custom_igneous_cookie_message">Гэты параметр прызначаны для карыстальнікаў, што не могуць атрымаць доступ да ExHentai звычайным спосабам і вымушаны уводзіць пэўнае значэнне igneous-кукі.</string>
<string name="developer_tools">Інструменты распрацоўшчыка</string>
<string name="toggle_hentai_features">Уключыць убудаваныя хентай-функцыі</string>
<string name="toggle_hentai_features_summary">Гэта параметр уключае эксперыментальныя хентай-функцыі</string>
<string name="toggle_delegated_sources">Уключыць дэлегаваныя крыніцы</string>
<string name="toggle_delegated_sources_summary">Ужыць паляпшэнні %1$s да наступных крыніц (калі яны ўсталяваны): %2$s</string>
<string name="log_level">Узровень журналавання</string>
<string name="log_level_summary">Змена гэтага параметра можа паўплываць на прадукцыйнасць праграмы, прымусова перазапусціце яе пасля змены. Бягучае значэнне: %s</string>
<string name="enable_source_blacklist">Уключыць чорны спіс крыніц</string>
<string name="enable_source_blacklist_summary">Схаваць пашырэнні і крыніцы, несумяшчальныя з %1$s. Прымусова перазапусціце праграму пасля змены.</string>
<string name="open_debug_menu">Адкрыць меню адладкі</string>
<string name="open_debug_menu_summary"><![CDATA[НЕ ЧАПАЙЦЕ ГЭТАЕ МЕНЮ, КАЛІ ВЫ НЕ ВЕДАЕЦЕ, ШТО РОБІЦЕ! <font color=\'red\'>ГЭТА МОЖА ПАШКОДЗІЦЬ ВАШУ БІБЛІЯТЭКУ!</font>]]></string>
<string name="starting_cleanup">Пачатак ачысткі</string>
<string name="clean_up_downloaded_chapters">Ачыстка спампаваных раздзелаў</string>
<string name="delete_unused_chapters">Выдаленне тэчак з неіснуючымі, часткова спампаванымі і прачытанымі раздзеламі</string>
<string name="no_folders_to_cleanup">Няма тэчак для ачысткі</string>
<string name="clean_orphaned_downloads">Ачысціць асірацелае</string>
<string name="clean_read_downloads">Ачысціць прачытанае</string>
<string name="clean_read_entries_not_in_library">Ачысціць пазабібліятэчныя творы</string>
<string name="data_saver">Ашчада даных</string>
<string name="data_saver_summary">Сціскаць выявы перад спампоўкай ці адкрыццём у чытанцы</string>
<string name="data_saver_downloader">Ашчада даных пры спампоўцы</string>
<string name="data_saver_ignore_jpeg">Ігнараваць JPEG-выявы</string>
<string name="data_saver_ignore_gif">Ігнараваць GIF-анімацыі</string>
<string name="data_saver_image_quality">Якасць выяў</string>
<string name="data_saver_image_quality_summary">Чым вышэйшае значэнне, тым лепшая якасць, але і большы памер файла. 80%% — гэта аптымальны баланс паміж памерам файла і якасцю выявы</string>
<string name="data_saver_image_format">Сціснуць у JPEG</string>
<string name="data_saver_image_format_summary_on">Файлы JPEG значна меншыя за WebP (што мацней ашчаджае даныя), але пры гэтым мацней губляецца якасць выяў.\nЗараз сціскаецца ў JPEG</string>
<string name="data_saver_image_format_summary_off">Файлы JPEG значна меншыя за WebP (што мацней ашчаджае даныя), але маюць горшую якасць.\nЗараз сціскаецца ў WebP</string>
<string name="data_saver_color_bw">Ператварыць у чорна-белае</string>
<string name="bandwidth_hero">Bandwidth Hero (патрабуецца проксі-сервер Bandwidth Hero)</string>
<string name="wsrv">wsrv.nl</string>
<string name="bandwidth_data_saver_server">Проксі-сервер Bandwidth Hero</string>
<string name="data_saver_server_summary">Увядзіце URL-адрас проксі-сервера Bandwidth Hero тут</string>
<string name="pref_include_chapter_url_hash">Уключаць URL хэш раздзела</string>
<string name="pref_include_chapter_url_hash_desc">Дадаваць першыя шэсць знакаў MD5-хэша URL-адраса раздзела да назвы яго файла альбо тэчкі.</string>
<string name="log_minimal">Мінімальны</string>
<string name="log_extra">Падрабязны</string>
<string name="log_extreme">Адладка</string>
<string name="log_minimal_desc">Толькі крытычныя памылкі</string>
<string name="log_extra_desc">Журналаваць усё</string>
<string name="log_extreme_desc">Рэжым інспекцыі сеткі</string>
<string name="toggle_expand_search_filters">Рэжым інспекцыі сеткі</string>
<string name="put_recommends_in_overflow">Рэкамендацыі ў дадатковым меню</string>
<string name="put_recommends_in_overflow_summary">Змясціць кнопку «Рэкамендацыі» ў дадатковым меню замест старонкі самога твора</string>
<string name="put_merge_in_overflow">Аб’яднанне ў дадатковым меню</string>
<string name="put_merge_in_overflow_summary">Змясціць кнопку «Аб’яднанне» ў дадатковым меню замест старонкі самога твора</string>
<string name="pref_previews_row_count">Памер сеткі перадпрагляду</string>
<string name="pref_category_navbar">Панэль навігацыі</string>
</resources>
@@ -665,4 +665,6 @@
<string name="only_show_updated_entries">新しい章を含むエントリーのみを表示する</string> <string name="only_show_updated_entries">新しい章を含むエントリーのみを表示する</string>
<string name="rec_search">共通の推奨事項を見つける</string> <string name="rec_search">共通の推奨事項を見つける</string>
<string name="rec_hide_library_entries">ライブラリに既に存在する結果を非表示にする</string> <string name="rec_hide_library_entries">ライブラリに既に存在する結果を非表示にする</string>
<string name="pref_include_chapter_url_hash">章のURLハッシュを含める</string>
<string name="pref_include_chapter_url_hash_desc">章の URL の MD5 ハッシュの最初の 6 文字を章のファイル名またはフォルダ名に追加します。</string>
</resources> </resources>
@@ -143,4 +143,232 @@
<string name="log_extra_desc">전체 로그</string> <string name="log_extra_desc">전체 로그</string>
<string name="log_extreme_desc">네트워크 검사 모드</string> <string name="log_extreme_desc">네트워크 검사 모드</string>
<string name="toggle_expand_search_filters">모든 검색 필터를 기본으로 확장</string> <string name="toggle_expand_search_filters">모든 검색 필터를 기본으로 확장</string>
<string name="pref_category_navbar">네비게이션 바</string>
<string name="pref_hide_updates_button">네비게이션 바에 업데이트 표시</string>
<string name="pref_hide_history_button">네비게이션 바에 이력 표시</string>
<string name="pref_show_bottom_bar_labels">항상 네비게이션 라벨 표시</string>
<string name="pref_tracker_resolve_using_source_metadata">소스 메타데이터를 사용해 엔트리를 선택합니다</string>
<string name="pref_tracker_resolve_using_source_metadata_summary">소스가 트래커에 링크를 제공하는 경우 자동으로 제목을 매칭합니다. 현재 MangaDex에서 지원 중입니다</string>
<string name="pref_sorting_settings">정렬 설정</string>
<string name="pref_skip_pre_migration_summary">마이그레이션하기 전 마지막으로 저장된 설정과 소스를 사용해 일괄 마이그레이션</string>
<string name="library_group_updates">라이브러리 동적 카테고리 업데이트</string>
<string name="library_group_updates_global">항상 전역 업데이트 시작</string>
<string name="library_group_updates_all_but_ungrouped">그룹화되지 않은 카테고리 업데이트에 대해서만 전역 업데이트 시작</string>
<string name="library_group_updates_all">항상 카테고리 업데이트 시작</string>
<string name="pref_mark_read_dupe_chapters">중복되는 챕터를 읽음으로 표시</string>
<string name="pref_mark_read_dupe_chapters_summary">읽은 후 중복되는 챕터에 읽음으로 표시</string>
<string name="pref_library_mark_duplicate_chapters">중복되는 새 챕터를 읽음으로 표시</string>
<string name="pref_library_mark_duplicate_chapters_summary">이전에 읽은 경우 자동으로 새 챕터를 읽음으로 표시</string>
<string name="update_30min">30분마다</string>
<string name="update_1hour">1시간마다</string>
<string name="update_3hour">3시간마다</string>
<string name="pref_hide_feed">피드 탭 숨기기</string>
<string name="pref_feed_position">피드 탭 위치</string>
<string name="pref_feed_position_summery">피드 탭을 첫 번째 탭으로 설정하시겠습니까? 탐색 창을 열 때 기본값이 되며, 모바일 데이터를 사용하는 경우에는 권장하지 않습니다</string>
<string name="pref_source_source_filtering">카테고리 내 소스 필터</string>
<string name="pref_source_source_filtering_summery">카테고리에 있는 소스를 필터링해 해당 언어에 포함되지 않도록 합니다</string>
<string name="pref_source_navigation">최신 버튼 대체</string>
<string name="pref_source_navigation_summery">최신 버튼을 최신 및 탐색 기능을 모두 포함하는 커스텀 탐색 보기로 대체</string>
<string name="pref_local_source_hidden_folders">로컬 소스의 숨겨진 폴더</string>
<string name="pref_local_source_hidden_folders_summery">로컬 소스에 숨겨진 폴더 읽기를 허용</string>
<string name="custom_entry_info">커스텀 작품 정보</string>
<string name="all_read_entries">모두 읽은 작품</string>
<string name="label_sync">동기화</string>
<string name="label_triggers">트리거</string>
<string name="sync_error">라이브러리 동기화 실패</string>
<string name="sync_complete">라이브러리 동기화 완료</string>
<string name="sync_in_progress">동기화 이미 실행 중</string>
<string name="pref_sync_host">호스트</string>
<string name="pref_sync_host_summ">라이브러리에 동기화할 호스트 주소 입력</string>
<string name="pref_sync_api_key">API 키</string>
<string name="pref_sync_api_key_summ">라이브러리 동기화를 위한 API 키 입력</string>
<string name="pref_sync_now_group_title">동기화 액션</string>
<string name="pref_sync_now">지금 동기화</string>
<string name="pref_sync_now_subtitle">데이터 즉시 동기화 시작</string>
<string name="pref_sync_service">서비스</string>
<string name="pref_sync_service_category">동기화</string>
<string name="pref_sync_automatic_category">자동 동기화</string>
<string name="pref_sync_interval">동기화 간격</string>
<string name="pref_choose_what_to_sync">동기화 항목 선택</string>
<string name="syncyomi">SyncYomi</string>
<string name="scan_qr_code">QR 코드 스캔</string>
<string name="last_synchronization">마지막 동기화: %1$s</string>
<string name="google_drive">Google 드라이브</string>
<string name="pref_google_drive_sign_in">로그인</string>
<string name="pref_google_drive_purge_sync_data">Google 드라이브 동기화 데이터 삭제</string>
<string name="google_drive_sync_data_purged">Google 드라이브 동기화 데이터 삭제됨</string>
<string name="google_drive_sync_data_not_found">Google 드라이브에 동기화 데이터 없음</string>
<string name="google_drive_sync_data_purge_error">Google 드라이브 동기화 데이터 삭제 중 오류 발생, 다시 로그인해 주십시오.</string>
<string name="google_drive_login_success">Google 드라이브 로그인됨</string>
<string name="google_drive_login_failed">Google 드라이브 로그인 실패: %s</string>
<string name="google_drive_not_signed_in">Google 드라이브 로그인되어 있지 않음</string>
<string name="error_uploading_sync_data">Google 드라이브 동기화 데이터 업로드 중 오류 발생</string>
<string name="error_deleting_google_drive_lock_file">Google 드라이브 잠금 파일 삭제 오류</string>
<string name="error_before_sync_gdrive">동기화 이전 오류 발생: %s</string>
<string name="pref_purge_confirmation_title">삭제 확인</string>
<string name="pref_purge_confirmation_message">동기화 데이터를 삭제하면 Google 드라이브에서 모든 동기화 데이터가 삭제됩니다. 계속하시겠습니까?</string>
<string name="pref_sync_options">동기화 트리거 생성</string>
<string name="pref_sync_options_summ">동기화 트리거 설정에 사용할 수 있습니다</string>
<string name="sync_on_chapter_read">챕터를 모두 읽었을 때 동기화</string>
<string name="sync_on_chapter_open">챕터를 열 때 동기화</string>
<string name="sync_on_app_start">앱을 시작할 때 동기화</string>
<string name="sync_on_app_resume">앱을 재시작할 때 동기화</string>
<string name="sync_library">라이브러리 동기화</string>
<string name="biometric_lock_times">생체 인식 잠금 시간</string>
<string name="action_edit_biometric_lock_times">잠금 시간 설정</string>
<string name="biometric_lock_times_empty">생체 인식 잠금 시간이 설정되어 있지 않습니다. 플러스 버튼을 탭하여 설정합니다.</string>
<string name="biometric_lock_time_conflicts">잠금 시간이 기존 설정과 충돌합니다!</string>
<string name="biometric_lock_start_time">시작 시간 입력</string>
<string name="biometric_lock_end_time">종료 시간 입력</string>
<string name="delete_time_range">시간 범위 삭제</string>
<string name="delete_time_range_confirmation">시간 범위 %s을(를) 삭제하시겠습니까?</string>
<string name="biometric_lock_days">생체 인식 잠금 요일</string>
<string name="biometric_lock_days_summary">앱을 잠궈둘 요일</string>
<string name="sunday">일요일</string>
<string name="monday">월요일</string>
<string name="tuesday">화요일</string>
<string name="wednesday">수요일</string>
<string name="thursday">목요일</string>
<string name="friday">금요일</string>
<string name="saturday">토요일</string>
<string name="encrypt_database">데이터베이스 암호화</string>
<string name="encrypt_database_subtitle">적용하려면 앱 재시작 필요</string>
<string name="encrypt_database_message"><![CDATA[<font color=\'red\'>활성화하면 새 데이터베이스를 생성합니다. 데이터 보존을 위해 아래와 같이 진행하십시오.<br>1. 설정 -> 백업 -> 생성<br>2. 시스템 설정 -> 앱 데이터 삭제<br>3. 앱을 열어 옵션 활성화<br>4. 시스템 설정 -> 강제 재시작<br>5. 설정 -> 백업 -> 복원</font>]]></string>
<string name="set_cbz_zip_password">CBZ 아카이브 패스워드 설정</string>
<string name="password_protect_downloads">패스워드로 다운로드 보호</string>
<string name="password_protect_downloads_summary">패스워드로 CBZ 아카이브 다운로드를 암호화합니다.\n경고: 패스워드를 분실하면 아카이브 내부 데이터를 영구히 잃습니다</string>
<string name="delete_cbz_archive_password">CBZ 아카이브 패스워드 삭제</string>
<string name="cbz_archive_password">CBZ 아카이브 패스워드</string>
<string name="wrong_cbz_archive_password">잘못된 CBZ 아카이브 패스워드</string>
<string name="encryption_type">암호화 유형</string>
<string name="aes_256">AES 256</string>
<string name="aes_128">AES 128</string>
<string name="standard_zip_encryption">표준 zip 암호화 (빠르지만 안전하지 않음)</string>
<string name="page_downloading">페이지 다운로드</string>
<string name="download_threads">스레드 다운로드</string>
<string name="download_threads_summary">값이 높을수록 이미지 다운로드 속도가 크게 빨라질 수 있지만 차단될 수 있습니다. 추천 값은 2 또는 3입니다. 현재 값: %s</string>
<string name="aggressively_load_pages">적극적으로 페이지 불러오기</string>
<string name="aggressively_load_pages_summary">보고 있는 챕터만 다운로드하는 대신 챕터 전체를 천천히 다운로드합니다.</string>
<string name="skip_queue_on_retry">재시도할 때 대기열 생략</string>
<string name="skip_queue_on_retry_summary">일반적으로는 실패한 다운로드에서 재시도 버튼을 누르면 마지막 페이지 다운로드까지 기다렸다가 실패한 페이지를 다시 다운로드합니다. 이렇게 하면 재시도 버튼을 누르는 즉시 실패한 페이지를 다시 다운로드합니다.</string>
<string name="reader_preload_amount">미리 불러올 페이지 수</string>
<string name="reader_preload_amount_4_pages">4 페이지</string>
<string name="reader_preload_amount_6_pages">6 페이지</string>
<string name="reader_preload_amount_8_pages">8 페이지</string>
<string name="reader_preload_amount_10_pages">10 페이지</string>
<string name="reader_preload_amount_12_pages">12 페이지</string>
<string name="reader_preload_amount_14_pages">14 페이지</string>
<string name="reader_preload_amount_16_pages">16 페이지</string>
<string name="reader_preload_amount_20_pages">20 페이지</string>
<string name="reader_preload_amount_summary">읽을 때 미리 불러올 페이지 수입니다. 높을수록 읽기 경험이 쾌적해지는 대신 캐시 사용량이 늘어납니다. 값을 크게 할당할 경우 캐시 할당량을 늘리는 것이 좋습니다</string>
<string name="reader_cache_size">리더 캐시 크기</string>
<string name="reader_cache_size_summary">읽기 중에 장치에 저장할 이미지의 양입니다. 값이 높을수록 디스크 공간 사용량이 증가하는 대신 읽기 환경이 쾌적해집니다</string>
<string name="preserve_reading_position">읽은 작품의 독서 위치 보존</string>
<string name="auto_webtoon_mode">자동 웹툰 모드</string>
<string name="auto_webtoon_mode_summary">긴 스트립 형식을 사용할 가능성이 감지된 작품에 자동 웹툰 모드 사용</string>
<string name="enable_zoom_out">줌 아웃 활성화</string>
<string name="tap_scroll_page">페이지별 탭 스크롤</string>
<string name="tap_scroll_page_summary">활성화하면 탭 액션이 화면 크기 대신 페이지로 스크롤합니다</string>
<string name="reader_bottom_buttons">리더 하단 버튼</string>
<string name="reader_bottom_buttons_summary">리더 하단에 표시되는 버튼 커스텀</string>
<string name="pref_show_vert_seekbar_landscape">가로 화면일 때 수직 탐색 바 표시</string>
<string name="pref_show_vert_seekbar_landscape_summary">카로 화면일 때 수직 탐색 바를 활성화합니다</string>
<string name="pref_left_handed_vertical_seekbar">왼손잡이용 수직 탐색 바</string>
<string name="pref_left_handed_vertical_seekbar_summary">탐색 바의 좌우 위치를 바꿉니다</string>
<string name="pref_force_horz_seekbar">수평 탐색 바 강제</string>
<string name="pref_force_horz_seekbar_summary">수직 탐색 바를 완전히 제거하고 수평 방향으로 변경합니다</string>
<string name="pref_smooth_scroll">부드러운 자동 스크롤</string>
<string name="eh_autoscroll">자동 스크롤</string>
<string name="eh_retry_all">모두 재시도</string>
<string name="eh_boost_page">부스트 페이지</string>
<string name="eh_autoscroll_help_message">지정된 간격으로 다음 페이지로 자동 스크롤합니다. 간격은 초 단위로 지정됩니다.</string>
<string name="eh_autoscroll_freq_invalid">부적절한 값</string>
<string name="eh_retry_all_help_message">실패한 모든 페이지를 다운로드 대기열에 다시 추가합니다.</string>
<string name="eh_boost_page_help_message">일반적으로는 동시에 특정 수의 페이지만 동시에 다운로드할 수 있습니다. 즉, 페이지가 다운로드되기를 기다릴 수 있지만 다운로드 슬롯이 남을 때까지는 페이지 다운로드를 시작하지 않습니다. \'페이지 부스트\'를 누르면 남는 슬롯 여부와 관계없이 현재 페이지를 다운로드합니다.</string>
<string name="eh_boost_page_invalid">이 페이지는 부스트할 수 없습니다(잘못된 페이지)!</string>
<string name="eh_autoscroll_help">자동 스크롤 도움말</string>
<string name="eh_retry_all_help">모두 재시도 도움말</string>
<string name="eh_boost_page_errored">페이지 불러오기 실패. 재시도 버튼을 눌러주십시오!</string>
<string name="eh_boost_page_downloading">이 페이지는 이미 다운로드 중입니다!</string>
<string name="eh_boost_page_downloaded">이 페이지는 이미 다운로드했습니다!</string>
<string name="eh_boost_boosted">현재 페이지 부스트됨!</string>
<string name="eh_boost_invalid_loader">이 페이지는 부스트할 수 없습니다(잘못된 페이지 로더)!</string>
<string name="pref_crop_borders_pager">페이지 분할 크롭</string>
<string name="action_set_first_page_cover">첫 페이지를 표지로 설정</string>
<string name="action_set_second_page_cover">두번째 페이지를 표지로 설정</string>
<string name="action_save_first_page">첫 페이지 저장</string>
<string name="action_save_second_page">두 번째 페이지 저장</string>
<string name="action_share_first_page">첫 페이지 공유</string>
<string name="action_share_second_page">두 번째 페이지 공유</string>
<string name="action_save_combined_page">병합된 페이지 저장</string>
<string name="action_share_combined_page">병합된 페이지 공유</string>
<string name="action_copy_first_page">첫 페이지 복사</string>
<string name="action_copy_second_page">두 번째 페이지 복사</string>
<string name="action_copy_combined_page">병합된 페이지 복사</string>
<string name="share_pages_info">%1$s:%2$s, 페이지 %3$s</string>
<string name="page_layout">페이지 레이아웃</string>
<string name="center_margin">중앙 여백</string>
<string name="center_margin_none">없음</string>
<string name="center_margin_double_page">양면 페이지에 추가</string>
<string name="center_margin_wide_page">넓은 폭 페이지에 추가</string>
<string name="center_margin_double_and_wide_page">모두 추가</string>
<string name="pref_center_margin">중앙 여백 유형</string>
<string name="pref_center_margin_summary">폴더블 기기의 접히는 공간 대응을 위해 여백을 삽입합니다.</string>
<string name="archive_mode_load_from_file">파일에서 불러오기</string>
<string name="archive_mode_load_into_memory">메모리에 불러오기</string>
<string name="archive_mode_cache_to_disk">디스크에 복사</string>
<string name="pref_archive_reader_mode">액티브 리더 모드</string>
<string name="pref_archive_reader_mode_summary">CBZ, CBR 등 아카이브 내부의 이미지를 불러오는 방식</string>
<string name="az_recommends">추천 작품 보기</string>
<string name="merge">병합</string>
<string name="put_recommends_in_overflow">오버플로우 메뉴에서 추천 작품 보기</string>
<string name="put_recommends_in_overflow_summary">엔트리 페이지 대신 오버플로우 메뉴에 추천 작품 보기 버튼을 배치</string>
<string name="put_merge_in_overflow">오버플로우 메뉴에서 병합</string>
<string name="put_merge_in_overflow_summary">엔트리 페이지 대신 오버플로우 메뉴에 병합 버튼을 배치</string>
<string name="pref_previews_row_count">미리보기 표시 행수</string>
<string name="eh_boost_page_help">부스트 페이지 도움말</string>
<string name="eh_auto_webtoon_snack">웹툰 스타일 불러오는 중</string>
<string name="time_between_batches_summary_2">%1$s은(는) 갤러리를 일괄적으로 확인/업데이트합니다. 즉, %2$d시간 대기 후 %3$d개의 갤러리를 확인하고, 다시 %2$d시간 대기 후 %3$d개를 확인하는 방식으로 계속 반복됩니다…</string>
<string name="gallery_updater_stats_text">업데이터는 %1$s에 마지막으로 실행되었으며, 확인 준비가 된 %3$d개의 갤러리 중 %2$d개를 확인했습니다.</string>
<string name="pref_crop_borders_continuous_vertical">테두리 자르기 연속 세로</string>
<string name="pref_crop_borders_webtoon">테두리 자르기 - 웹툰</string>
<string name="shift_double_pages">페이지 한 장 밀기</string>
<string name="double_pages">양면 페이지</string>
<string name="single_page">단일 페이지</string>
<string name="automatic_orientation">자동 (방향에 따라)</string>
<string name="automatic_can_still_switch">자동 페이지 레이아웃을 사용하는 중에도 이 설정을 변경하지 않고 읽는 중에 레이아웃을 전환할 수 있습니다</string>
<string name="invert_double_pages">양면 페이지 반전</string>
<string name="merge_with_another_source">다른 항목과 병합</string>
<string name="entry_merged">항목이 병합되었습니다!</string>
<string name="failed_merge">병합 실패: %1$s</string>
<string name="merge_unknown_entry">알 수 없는 항목 ID: %1$d</string>
<string name="merged_already">이 항목은 이미 현재 항목과 병합되어 있습니다!</string>
<string name="merge_duplicate">병합할 항목이 중복됩니다!</string>
<string name="reset_tags">태그 초기화</string>
<string name="add_tags">태그 추가</string>
<string name="reset_info">정보 초기화</string>
<string name="title_hint">제목: %1$s</string>
<string name="description_hint">설명: %1$s</string>
<string name="author_hint">저자: %1$s</string>
<string name="artist_hint">아티스트: %1$s</string>
<string name="thumbnail_url_hint">썸네일 Url: %1$s</string>
<string name="multi_tags_comma_separated">태그 입력, 콤마로 구분합니다.</string>
<string name="select_tracker">트래커 선택</string>
<string name="entry_not_tracked">항목이 추적되지 않음.</string>
<string name="fill_from_tracker">트래커에서 입력</string>
<string name="find_in_another_source">다른 소스에서 찾기</string>
<string name="data_saver_exclude">데이터 사용 최적화 제외</string>
<string name="data_saver_stop_exclude">데이터 사용 최적화 제외 중지</string>
<string name="searching_source">소스 검색 중…</string>
<string name="could_not_find_entry">소스에서 항목을 찾을 수 없습니다!</string>
<string name="automatic_search_error">자동 검색 중 오류 발생!</string>
<string name="saved_searches">저장된 검색</string>
<string name="save_search">현재의 검색 조건을 저장하시겠습니까?</string>
<string name="save_search_hint">검색 이름</string>
<string name="save_search_failed_to_load">저장된 검색 불러오기 실패!</string>
<string name="save_search_failed_to_load_message">저장된 검색을 불러오는 중 오류가 발생했습니다.</string>
<string name="save_search_delete">저장된 검색 조건을 삭제하시겠습니까?</string>
<string name="save_search_delete_message">저징된 검색 조건 \'%1$s\'을(를) 삭제하시겠습니까?</string>
<string name="save_search_invalid">저장된 검색 조건이 올바르지 않아 필터가 변경되었습니다</string>
<string name="save_search_invalid_name">유효하지 않은 저장된 검색 이름</string>
</resources> </resources>
@@ -2,6 +2,7 @@
<resources> <resources>
<plurals name="cleanup_done"> <plurals name="cleanup_done">
<item quantity="one">Limpeza feita. %d pasta removida</item> <item quantity="one">Limpeza feita. %d pasta removida</item>
<item quantity="many">Limpeza feita. %d pastas removidas</item>
<item quantity="other">Limpeza feita. %d pastas removidas</item> <item quantity="other">Limpeza feita. %d pastas removidas</item>
</plurals> </plurals>
<plurals name="num_lock_times"> <plurals name="num_lock_times">
@@ -9,11 +10,10 @@
<item quantity="other">%d tempos de bloqueio</item> <item quantity="other">%d tempos de bloqueio</item>
</plurals> </plurals>
<plurals name="pref_tag_sorting_desc"> <plurals name="pref_tag_sorting_desc">
<item quantity="zero">Sem tags na lista de ordenação. Isto dá uma opção na biblioteca de ordenar por uma lista de tags baseada em prioridade, ou seja, os mangás serão ordenados de modo a priorizar aqueles com as tags que deseja</item>
<item quantity="one">%1$d tag na lista de ordenação. Isto dá uma opção na biblioteca de ordenar por uma lista de tags baseada em prioridade, ou seja, os mangás serão ordenados de modo a priorizar aqueles com as tags que deseja</item> <item quantity="one">%1$d tag na lista de ordenação. Isto dá uma opção na biblioteca de ordenar por uma lista de tags baseada em prioridade, ou seja, os mangás serão ordenados de modo a priorizar aqueles com as tags que deseja</item>
<item quantity="many">%1$d tags na lista de ordenação. Isto dá uma opção na biblioteca de ordenar por uma lista de tags baseada em prioridade, ou seja, os mangás serão ordenados de modo a priorizar aqueles com as tags que deseja</item>
<item quantity="other">%1$d tags na lista de ordenação. Isto dá uma opção na biblioteca de ordenar por uma lista de tags baseada em prioridade, ou seja, os mangás serão ordenados de modo a priorizar aqueles com as tags que deseja</item> <item quantity="other">%1$d tags na lista de ordenação. Isto dá uma opção na biblioteca de ordenar por uma lista de tags baseada em prioridade, ou seja, os mangás serão ordenados de modo a priorizar aqueles com as tags que deseja</item>
</plurals> </plurals>
<plurals name="migrate_entry"> <plurals name="migrate_entry">
<item quantity="one">Migrar %1$d%2$s mangá?</item> <item quantity="one">Migrar %1$d%2$s mangá?</item>
<item quantity="other">Migrar %1$d%2$s mangás?</item> <item quantity="other">Migrar %1$d%2$s mangás?</item>
@@ -26,19 +26,16 @@
<item quantity="one">%d mangá migrado</item> <item quantity="one">%d mangá migrado</item>
<item quantity="other">%d mangás migrados</item> <item quantity="other">%d mangás migrados</item>
</plurals> </plurals>
<!-- Extra gallery info --> <!-- Extra gallery info -->
<plurals name="num_pages"> <plurals name="num_pages">
<item quantity="one">%1$d página</item> <item quantity="one">%1$d página</item>
<item quantity="other">%1$d páginas</item> <item quantity="other">%1$d páginas</item>
</plurals> </plurals>
<!-- Enhanced E/ExHentai Browse View --> <!-- Enhanced E/ExHentai Browse View -->
<plurals name="browse_language_and_pages"> <plurals name="browse_language_and_pages">
<item quantity="one">%2$s, %1$d página</item> <item quantity="one">%2$s, %1$d página</item>
<item quantity="other">%2$s, %1$d páginas</item> <item quantity="other">%2$s, %1$d páginas</item>
</plurals> </plurals>
<!-- Humanize time --> <!-- Humanize time -->
<plurals name="humanize_year"> <plurals name="humanize_year">
<item quantity="one">há %1$d ano</item> <item quantity="one">há %1$d ano</item>
@@ -68,4 +65,14 @@
<item quantity="one">há %1$d segundo</item> <item quantity="one">há %1$d segundo</item>
<item quantity="other">há %1$d segundo</item> <item quantity="other">há %1$d segundo</item>
</plurals> </plurals>
<plurals name="eh_retry_toast">
<item quantity="one">Tentando novamente %1$d página com falha…</item>
<item quantity="many">Tentando novamente %1$d páginas com falha…</item>
<item quantity="other">Tentando novamente %1$d páginas com falha…</item>
</plurals>
<plurals name="row_count">
<item quantity="one">%d linha</item>
<item quantity="many">%d linhas</item>
<item quantity="other">%d linhas</item>
</plurals>
</resources> </resources>
@@ -662,4 +662,16 @@
<string name="encrypt_database_message"><![CDATA[<font color=\'red\'>HABILITAR ESTA OPÇÃO CRIARÁ UM NOVO BANCO DE DADOS. SIGA ESTAS EPATAS PARA MANTER SEUS DADOS SEGUROS<br>1. CONFIGURAÇÕES -> DADOS E ARMAZENAMENTO -> CRIAR BACKUP<br>2. CONFIGURAÇÕES DO SISTEMA -> LIMPAR DADOS DO APP<br>3. ABRA O APP E ATIVE ISSO<br>4. CONFIGURAÇÕES DO SISTEMA -> FORÇAR REINICIALIZAÇÃO<br>5. CONFIGURAÇÕES -> DADOS E ARMAZENAMENTO -> RESTAURAR BACKUP</font>]]></string> <string name="encrypt_database_message"><![CDATA[<font color=\'red\'>HABILITAR ESTA OPÇÃO CRIARÁ UM NOVO BANCO DE DADOS. SIGA ESTAS EPATAS PARA MANTER SEUS DADOS SEGUROS<br>1. CONFIGURAÇÕES -> DADOS E ARMAZENAMENTO -> CRIAR BACKUP<br>2. CONFIGURAÇÕES DO SISTEMA -> LIMPAR DADOS DO APP<br>3. ABRA O APP E ATIVE ISSO<br>4. CONFIGURAÇÕES DO SISTEMA -> FORÇAR REINICIALIZAÇÃO<br>5. CONFIGURAÇÕES -> DADOS E ARMAZENAMENTO -> RESTAURAR BACKUP</font>]]></string>
<string name="aes_256">AES 256</string> <string name="aes_256">AES 256</string>
<string name="aes_128">AES 128</string> <string name="aes_128">AES 128</string>
<string name="eh_boost_invalid_loader">Esta página não pode ser impulsionada (carregador de página inválido)!</string>
<string name="pref_center_margin_summary">Insira um espaçador para preencher o espaço morto em dispositivos dobráveis.</string>
<string name="pref_archive_reader_mode_summary">A forma como as imagens dentro de arquivos, como CBZ ou CBR, são carregadas.</string>
<string name="entry_not_tracked">A entrada não é rastreada.</string>
<string name="fill_from_tracker">Preencher a partir do rastreador</string>
<string name="favorites_sync_unable_to_add_to_remote">Não foi possível adicionar a galeria ao servidor remoto: \'%1$s\' (GID: %2$s)!</string>
<string name="filename">Nome do arquivo</string>
<string name="file_extension">Extensão de arquivo</string>
<string name="final_chapter">Capítulo final</string>
<string name="data_saver_stop_exclude">Parar de excluir da economia de dados</string>
<string name="rec_processing_state">Processando entrada %1$d de %2$d</string>
<string name="base_url">Url base</string>
</resources> </resources>
@@ -692,8 +692,8 @@
<string name="scan_qr_code">Сканировать QR-код</string> <string name="scan_qr_code">Сканировать QR-код</string>
<string name="filename">Название файла</string> <string name="filename">Название файла</string>
<string name="file_extension">Расширение файла</string> <string name="file_extension">Расширение файла</string>
<string name="base_url">Главный URL</string> <string name="base_url">Основной URL-адрес</string>
<string name="final_chapter">Последняя глава</string> <string name="final_chapter">Последняя глава</string>
<string name="pref_include_chapter_url_hash">Включать URL хэш главы</string> <string name="pref_include_chapter_url_hash">Включать URL-хэш главы</string>
<string name="pref_include_chapter_url_hash_desc">Добавить первые шесть символов хеша MD5 URL-адреса главы к имени файла или папки главы.</string> <string name="pref_include_chapter_url_hash_desc">Добавить первые шесть символов хеша MD5 из URL-адреса главы к имени файла или папки главы.</string>
</resources> </resources>
@@ -53,7 +53,7 @@
<item quantity="other">%1$d சில நாட்களுக்கு முன்பு</item> <item quantity="other">%1$d சில நாட்களுக்கு முன்பு</item>
</plurals> </plurals>
<plurals name="humanize_hour"> <plurals name="humanize_hour">
<item quantity="one">%1$d மணி நேரத்திற்கு முன்பு</item> <item quantity="one">%1$d மணி முன்பு</item>
<item quantity="other">%1$d மணி நேரத்திற்கு முன்பு</item> <item quantity="other">%1$d மணி நேரத்திற்கு முன்பு</item>
</plurals> </plurals>
<plurals name="humanize_minute"> <plurals name="humanize_minute">
@@ -620,4 +620,10 @@
<string name="rec_error_string">தேடல் செயல்பாட்டின் போது பிழை ஏற்பட்டது: %1$s</string> <string name="rec_error_string">தேடல் செயல்பாட்டின் போது பிழை ஏற்பட்டது: %1$s</string>
<string name="rec_processing_state">செயலாக்க நுழைவு %1$d %2$d</string> <string name="rec_processing_state">செயலாக்க நுழைவு %1$d %2$d</string>
<string name="similar_titles">ஒத்த தலைப்புகள்</string> <string name="similar_titles">ஒத்த தலைப்புகள்</string>
<string name="pref_include_chapter_url_hash">அத்தியாய முகவரி ஆசைச் சேர்க்கவும்</string>
<string name="pref_include_chapter_url_hash_desc">அத்தியாயம் முகவரி இன் MD5 ஆசின் முதல் ஆறு எழுத்துக்களை அத்தியாயக் கோப்பு அல்லது கோப்புறை பெயரில் இணைக்கவும்.</string>
<string name="filename">கோப்பு பெயர்</string>
<string name="file_extension">கோப்பு நீட்டிப்பு</string>
<string name="base_url">அடிப்படை முகவரி</string>
<string name="final_chapter">இறுதி அத்தியாயம்</string>
</resources> </resources>
@@ -30,7 +30,7 @@
</plurals> </plurals>
<plurals name="eh_retry_toast"> <plurals name="eh_retry_toast">
<item quantity="one">%1$d başarısız sayfa tekrar deneniyor…</item> <item quantity="one">%1$d başarısız sayfa tekrar deneniyor…</item>
<item quantity="other">%1$d başarısız sayfa tekrar deneniyor…</item> <item quantity="other">%1$d başarısız sayfalar tekrar deneniyor…</item>
</plurals> </plurals>
<plurals name="browse_language_and_pages"> <plurals name="browse_language_and_pages">
<item quantity="one">%2$s, %1$d sayfa</item> <item quantity="one">%2$s, %1$d sayfa</item>
@@ -38,7 +38,7 @@
</plurals> </plurals>
<plurals name="cleanup_done"> <plurals name="cleanup_done">
<item quantity="one">Temizleme tamamlandı. %d klasör kaldırıldı</item> <item quantity="one">Temizleme tamamlandı. %d klasör kaldırıldı</item>
<item quantity="other">Temizleme tamamlandı. %d klasör kaldırıldı</item> <item quantity="other">Temizleme tamamlandı. %d klasörleri kaldırıldı</item>
</plurals> </plurals>
<plurals name="entry_migrated"> <plurals name="entry_migrated">
<item quantity="one">%d girdi taşındı</item> <item quantity="one">%d girdi taşındı</item>
@@ -53,8 +53,8 @@
<item quantity="other">%1$d dakika önce</item> <item quantity="other">%1$d dakika önce</item>
</plurals> </plurals>
<plurals name="num_lock_times"> <plurals name="num_lock_times">
<item quantity="one">%d kilit zamanı</item> <item quantity="one">%d kilitli zaman</item>
<item quantity="other">%d kilit zamanı</item> <item quantity="other">%d kilitli zamanlar</item>
</plurals> </plurals>
<plurals name="pref_tag_sorting_desc"> <plurals name="pref_tag_sorting_desc">
<item quantity="one">Sıralama listesinde %1$d etiket. Bu, kitaplığa önceliğe dayalı bir sıralama seçeneği ekler. Girdiler, istediğiniz etiketlere sahip olma durumuna göre öncelik alacaktır</item> <item quantity="one">Sıralama listesinde %1$d etiket. Bu, kitaplığa önceliğe dayalı bir sıralama seçeneği ekler. Girdiler, istediğiniz etiketlere sahip olma durumuna göre öncelik alacaktır</item>
@@ -170,6 +170,7 @@
<!-- Operations --> <!-- Operations -->
<string name="loading">Loading…</string> <string name="loading">Loading…</string>
<string name="calculating">Calculating…</string>
<string name="internal_error">InternalError: Check crash logs for further information</string> <string name="internal_error">InternalError: Check crash logs for further information</string>
<!-- Shortcuts--> <!-- Shortcuts-->
@@ -28,15 +28,13 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
var favoritesCount: Long? = null var favoritesCount: Long? = null
var mediaId: String? = null var mediaId: String? = null
var mediaServer: Int? = null
var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE) var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE)
var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH) var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH)
var shortTitle by titleDelegate(TITLE_TYPE_SHORT) var shortTitle by titleDelegate(TITLE_TYPE_SHORT)
var coverImageType: String? = null var coverImageUrl: String? = null
var pageImageTypes: List<String> = emptyList() var pageImagePreviewUrls: List<String> = emptyList()
var thumbnailImageType: String? = null
var scanlator: String? = null var scanlator: String? = null
@@ -45,14 +43,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
override fun createMangaInfo(manga: SManga): SManga { override fun createMangaInfo(manga: SManga): SManga {
val key = nhId?.let { nhIdToPath(it) } val key = nhId?.let { nhIdToPath(it) }
val cover = if (mediaId != null) {
typeToExtension(coverImageType)?.let {
"https://t${mediaServer ?: 1}.nhentai.net/galleries/$mediaId/cover.$it"
}
} else {
null
}
val title = when (preferredTitle) { val title = when (preferredTitle) {
TITLE_TYPE_SHORT -> shortTitle ?: englishTitle ?: japaneseTitle ?: manga.title TITLE_TYPE_SHORT -> shortTitle ?: englishTitle ?: japaneseTitle ?: manga.title
0, TITLE_TYPE_ENGLISH -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title 0, TITLE_TYPE_ENGLISH -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title
@@ -85,7 +75,7 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
return manga.copy( return manga.copy(
url = key ?: manga.url, url = key ?: manga.url,
thumbnail_url = cover ?: manga.thumbnail_url, thumbnail_url = coverImageUrl ?: manga.thumbnail_url,
title = title, title = title,
artist = group ?: manga.artist, artist = group ?: manga.artist,
author = artist ?: manga.artist, author = artist ?: manga.artist,
@@ -113,9 +103,8 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
getItem(japaneseTitle) { stringResource(SYMR.strings.japanese_title) }, getItem(japaneseTitle) { stringResource(SYMR.strings.japanese_title) },
getItem(englishTitle) { stringResource(SYMR.strings.english_title) }, getItem(englishTitle) { stringResource(SYMR.strings.english_title) },
getItem(shortTitle) { stringResource(SYMR.strings.short_title) }, getItem(shortTitle) { stringResource(SYMR.strings.short_title) },
getItem(coverImageType) { stringResource(SYMR.strings.cover_image_file_type) }, getItem(coverImageUrl) { stringResource(SYMR.strings.thumbnail_url) },
getItem(pageImageTypes.size) { stringResource(SYMR.strings.page_count) }, getItem(pageImagePreviewUrls.size) { stringResource(SYMR.strings.page_count) },
getItem(thumbnailImageType) { stringResource(SYMR.strings.thumbnail_image_file_type) },
getItem(scanlator) { stringResource(MR.strings.scanlator) }, getItem(scanlator) { stringResource(MR.strings.scanlator) },
) )
} }
@@ -134,15 +123,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
private const val NHENTAI_GROUP_NAMESPACE = "group" private const val NHENTAI_GROUP_NAMESPACE = "group"
const val NHENTAI_CATEGORIES_NAMESPACE = "category" const val NHENTAI_CATEGORIES_NAMESPACE = "category"
fun typeToExtension(t: String?) =
when (t) {
"w" -> "webp"
"p" -> "png"
"j" -> "jpg"
"g" -> "gif"
else -> null
}
fun nhUrlToId(url: String) = fun nhUrlToId(url: String) =
url.split("/").last { it.isNotBlank() }.toLong() url.split("/").last { it.isNotBlank() }.toLong()