Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e96895345e | |||
| eec1236b8b | |||
| ee1e783126 | |||
| f3ab39cb1f | |||
| ba75395648 | |||
| fe0b14ab97 | |||
| 91d2140288 | |||
| 0417969dd6 | |||
| 5d8d2ce48a | |||
| b15277f134 | |||
| 76ca27f681 | |||
| 56923c76d4 | |||
| 32e19736b9 | |||
| 11b01b2771 | |||
| 460ff13e54 | |||
| 57f77c8105 | |||
| a2eb22964a | |||
| 7158bae26a | |||
| 807ce846d5 | |||
| 0b68f2c62a | |||
| b7d6cc8dd0 | |||
| 8b1fd30902 | |||
| aff43f3aeb | |||
| 0047d2e5d8 | |||
| d87385f5b3 | |||
| c17e9573b7 | |||
| 9c01119d24 | |||
| bbc839e234 | |||
| 917f20894b | |||
| 3a3b719b8b | |||
| 1903437ecf | |||
| 5c26bb3a52 |
@@ -31,7 +31,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "eu.kanade.tachiyomi.sy"
|
||||
|
||||
versionCode = 75
|
||||
versionCode = 77
|
||||
versionName = "1.12.0"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
@@ -192,7 +192,7 @@ dependencies {
|
||||
implementation(androidx.paging.runtime)
|
||||
implementation(androidx.paging.compose)
|
||||
|
||||
implementation(libs.bundles.sqlite)
|
||||
implementation(androidx.sqlite.bundled)
|
||||
// SY -->
|
||||
implementation(sylibs.sqlcipher)
|
||||
// SY <--
|
||||
|
||||
Vendored
+3
-1
@@ -298,4 +298,6 @@
|
||||
-dontwarn org.ietf.jgss.GSSException
|
||||
-dontwarn org.ietf.jgss.GSSManager
|
||||
-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 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 subtitle: CharSequence? = null,
|
||||
override val enabled: Boolean = true,
|
||||
val widget: @Composable (() -> Unit)? = null,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
) : PreferenceItem<String, Unit>() {
|
||||
override val icon: ImageVector? = null
|
||||
|
||||
@@ -147,6 +147,7 @@ internal fun PreferenceItem(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
widget = item.widget,
|
||||
onPreferenceClick = item.onClick,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-2
@@ -223,6 +223,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
private fun getDataGroup(): Preference.PreferenceGroup {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.label_data),
|
||||
@@ -231,8 +232,10 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
title = stringResource(MR.strings.pref_invalidate_download_cache),
|
||||
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
|
||||
onClick = {
|
||||
Injekt.get<DownloadCache>().invalidateCache()
|
||||
context.toast(MR.strings.download_cache_invalidated)
|
||||
scope.launch {
|
||||
Injekt.get<DownloadCache>().invalidateCache()
|
||||
context.toast(MR.strings.download_cache_invalidated)
|
||||
}
|
||||
},
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
|
||||
@@ -303,7 +303,10 @@ object SettingsDataScreen : SearchableSettings {
|
||||
|
||||
val chapterCache = remember { Injekt.get<ChapterCache>() }
|
||||
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 -->
|
||||
val pagePreviewCache = remember { Injekt.get<PagePreviewCache>() }
|
||||
|
||||
@@ -9,12 +9,18 @@ import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
@@ -45,10 +51,24 @@ private fun StorageInfo(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) }
|
||||
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
|
||||
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
|
||||
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
|
||||
var available by remember(file) { mutableStateOf(-1L) }
|
||||
var total by remember(file) { mutableStateOf(-1L) }
|
||||
|
||||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
@@ -58,13 +78,15 @@ private fun StorageInfo(
|
||||
style = MaterialTheme.typography.header,
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
progress = { (1 - (available / total.toFloat())) },
|
||||
)
|
||||
if (total > 0) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
progress = { (1 - (available / total.toFloat())) },
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(MR.strings.available_disk_space_info, availableText, totalText),
|
||||
|
||||
+42
@@ -1,24 +1,38 @@
|
||||
package eu.kanade.presentation.more.settings.screen.debug
|
||||
|
||||
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.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.profileinstaller.ProfileVerifier
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
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.PreferenceScaffold
|
||||
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import kotlinx.collections.immutable.mutate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.guava.await
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.core.common.FeatureFlags
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class DebugInfoScreen : Screen() {
|
||||
|
||||
@@ -47,6 +61,12 @@ class DebugInfoScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
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(
|
||||
title = "App info",
|
||||
preferenceItems = persistentListOf(
|
||||
@@ -58,6 +78,28 @@ class DebugInfoScreen : Screen() {
|
||||
title = "Build time",
|
||||
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(),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = "WebView version",
|
||||
|
||||
@@ -13,12 +13,18 @@ class BackupCategory(
|
||||
@ProtoNumber(100) var flags: Long = 0,
|
||||
// SY specific values
|
||||
/*@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(
|
||||
id = id,
|
||||
name = this@BackupCategory.name,
|
||||
flags = this@BackupCategory.flags,
|
||||
order = this@BackupCategory.order,
|
||||
version = this@BackupCategory.version,
|
||||
uid = this@BackupCategory.uid,
|
||||
lastModifiedAt = this@BackupCategory.lastModifiedAt,
|
||||
/*mangaOrder = this@BackupCategory.mangaOrder*/
|
||||
)
|
||||
}
|
||||
@@ -29,5 +35,8 @@ val backupCategoryMapper = { category: Category ->
|
||||
name = category.name,
|
||||
order = category.order,
|
||||
flags = category.flags,
|
||||
version = category.version,
|
||||
uid = category.uid,
|
||||
lastModifiedAt = category.lastModifiedAt,
|
||||
)
|
||||
}
|
||||
|
||||
+48
-5
@@ -17,20 +17,63 @@ class CategoriesRestorer(
|
||||
if (backupCategories.isNotEmpty()) {
|
||||
val dbCategories = getCategories.await()
|
||||
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
|
||||
|
||||
val categories = backupCategories
|
||||
.sortedBy { it.order }
|
||||
.map {
|
||||
val dbCategory = dbCategoriesByName[it.name]
|
||||
if (dbCategory != null) return@map dbCategory
|
||||
// SY -->
|
||||
.map { backupCategory ->
|
||||
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++
|
||||
handler.awaitOneExecutable {
|
||||
categoriesQueries.insert(it.name, order, it.flags)
|
||||
categoriesQueries.insert(
|
||||
backupCategory.name,
|
||||
order,
|
||||
backupCategory.flags,
|
||||
backupCategory.version,
|
||||
backupCategory.uid,
|
||||
backupCategory.lastModifiedAt,
|
||||
)
|
||||
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(
|
||||
(dbCategories + categories)
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import okhttp3.Response
|
||||
@@ -63,17 +64,13 @@ class ChapterCache(
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
val readableSize: String
|
||||
get() = Formatter.formatFileSize(context, realSize)
|
||||
suspend fun getReadableSize(): String = withContext(Dispatchers.IO) {
|
||||
val size = DiskUtil.getDirectorySize(cacheDir)
|
||||
Formatter.formatFileSize(context, size)
|
||||
}
|
||||
|
||||
// --> EH
|
||||
// Cache size is in MB
|
||||
|
||||
@@ -12,14 +12,17 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
@@ -109,13 +112,19 @@ class DownloadCache(
|
||||
ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes())
|
||||
}
|
||||
rootDownloadsDir = diskCache
|
||||
lastRenew = System.currentTimeMillis()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to initialize from disk cache" }
|
||||
diskCacheFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
sourceManager.catalogueSources
|
||||
.map { sources -> sources.map { it.id }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
restartRenewal()
|
||||
}
|
||||
}
|
||||
|
||||
storageManager.changes
|
||||
@@ -353,19 +362,34 @@ class DownloadCache(
|
||||
notifyChanges()
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
lastRenew = 0L
|
||||
renewalJob?.cancel()
|
||||
suspend fun invalidateCache() {
|
||||
renewalJob?.cancelAndJoin()
|
||||
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.
|
||||
*
|
||||
* @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
|
||||
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
|
||||
if ((!forceRenew && lastRenew + renewInterval >= System.currentTimeMillis()) || renewalJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -376,15 +400,14 @@ class DownloadCache(
|
||||
|
||||
// Try to wait until extensions and sources have loaded
|
||||
// SY -->
|
||||
var sources = emptyList<Source>()
|
||||
withTimeoutOrNull(30.seconds) {
|
||||
extensionManager.isInitialized.first { it }
|
||||
sourceManager.isInitialized.first { it }
|
||||
|
||||
sources = getSources()
|
||||
// SY <--
|
||||
sourceManager.catalogueSources.first { it.isNotEmpty() }
|
||||
// SY -->
|
||||
}
|
||||
// SY <--
|
||||
|
||||
val sources = getSources()
|
||||
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
||||
|
||||
rootDownloadsDirMutex.withLock {
|
||||
@@ -459,8 +482,9 @@ class DownloadCache(
|
||||
|
||||
private var updateDiskCacheJob: Job? = null
|
||||
private fun updateDiskCache() {
|
||||
updateDiskCacheJob?.cancel()
|
||||
val previousJob = updateDiskCacheJob
|
||||
updateDiskCacheJob = scope.launchIO {
|
||||
previousJob?.cancelAndJoin()
|
||||
delay(1000)
|
||||
ensureActive()
|
||||
val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir)
|
||||
|
||||
@@ -109,10 +109,10 @@ class DownloadManager(
|
||||
return queueState.value.find { it.chapter.id == chapterId }
|
||||
}
|
||||
|
||||
fun startDownloadNow(chapterId: Long) {
|
||||
suspend fun startDownloadNow(chapterId: Long) {
|
||||
val existingDownload = getQueuedDownloadOrNull(chapterId)
|
||||
// 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 {
|
||||
existingDownload?.let { remove(it) }
|
||||
add(0, toAdd)
|
||||
|
||||
@@ -89,7 +89,7 @@ class DownloadStore(
|
||||
/**
|
||||
* 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
|
||||
.mapNotNull { it.value as? String }
|
||||
.mapNotNull { deserialize(it) }
|
||||
@@ -100,10 +100,10 @@ class DownloadStore(
|
||||
val cachedManga = mutableMapOf<Long, Manga?>()
|
||||
for ((mangaId, chapterId) in objs) {
|
||||
val manga = cachedManga.getOrPut(mangaId) {
|
||||
runBlocking { getManga.await(mangaId) }
|
||||
getManga.await(mangaId)
|
||||
} ?: 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,9 @@ class Downloader(
|
||||
var isPaused: Boolean = false
|
||||
|
||||
init {
|
||||
launchNow {
|
||||
val chapters = async { store.restore() }
|
||||
addAllToQueue(chapters.await())
|
||||
scope.launch {
|
||||
val chapters = store.restore()
|
||||
addAllToQueue(chapters)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import tachiyomi.core.common.Constants
|
||||
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.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
@@ -84,11 +85,18 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
|
||||
// Open reader activity
|
||||
ACTION_OPEN_CHAPTER -> {
|
||||
openChapter(
|
||||
context,
|
||||
intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
||||
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
|
||||
)
|
||||
val pendingResult = goAsync()
|
||||
launchIO {
|
||||
try {
|
||||
openChapter(
|
||||
context,
|
||||
intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
||||
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
|
||||
)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mark updated manga chapters as read
|
||||
ACTION_MARK_AS_READ -> {
|
||||
@@ -153,16 +161,18 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
* @param mangaId id of manga
|
||||
* @param chapterId id of chapter
|
||||
*/
|
||||
private fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
|
||||
val manga = runBlocking { getManga.await(mangaId) }
|
||||
val chapter = runBlocking { getChapter.await(chapterId) }
|
||||
if (manga != null && chapter != null) {
|
||||
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
private suspend fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
|
||||
val manga = getManga.await(mangaId)
|
||||
val chapter = getChapter.await(chapterId)
|
||||
withUIContext {
|
||||
if (manga != null && chapter != null) {
|
||||
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) {
|
||||
mangasQueries.resetIsSyncing()
|
||||
chaptersQueries.resetIsSyncing()
|
||||
categoriesQueries.resetIsSyncing()
|
||||
}
|
||||
|
||||
val syncOptions = syncPreferences.getSyncSettings()
|
||||
@@ -156,7 +157,7 @@ class SyncManager(
|
||||
}
|
||||
|
||||
// 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.")
|
||||
return
|
||||
}
|
||||
@@ -185,14 +186,40 @@ class SyncManager(
|
||||
// SY <--
|
||||
)
|
||||
|
||||
// It's local sync no need to restore data. (just update remote data)
|
||||
if (filteredFavorites.isEmpty()) {
|
||||
val hasMangaChanges = filteredFavorites.isNotEmpty()
|
||||
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
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
notifier.showSyncSuccess("Sync completed successfully")
|
||||
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)
|
||||
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
|
||||
if (backupUri != null) {
|
||||
@@ -201,10 +228,14 @@ class SyncManager(
|
||||
backupUri,
|
||||
sync = true,
|
||||
options = RestoreOptions(
|
||||
appSettings = true,
|
||||
sourceSettings = true,
|
||||
libraryEntries = true,
|
||||
extensionRepoSettings = true,
|
||||
appSettings = syncOptions.appSettings,
|
||||
sourceSettings = syncOptions.sourceSettings,
|
||||
libraryEntries = syncOptions.libraryEntries,
|
||||
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 logcat.LogPriority
|
||||
import logcat.logcat
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Serializable
|
||||
data class SyncData(
|
||||
@@ -134,14 +136,31 @@ abstract class SyncService(
|
||||
"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 local = localMangaMap[compositeKey]
|
||||
val remote = remoteMangaMap[compositeKey]
|
||||
|
||||
// New version comparison logic
|
||||
when {
|
||||
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder)
|
||||
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder)
|
||||
local != null && remote == null -> {
|
||||
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 -> {
|
||||
// Compare versions to decide which manga to keep
|
||||
if (local.version >= remote.version) {
|
||||
@@ -149,7 +168,7 @@ abstract class SyncService(
|
||||
"Keeping local version of ${local.title} with merged chapters."
|
||||
}
|
||||
updateCategories(
|
||||
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
|
||||
local.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
|
||||
localCategoriesMapByOrder,
|
||||
)
|
||||
} else {
|
||||
@@ -157,7 +176,7 @@ abstract class SyncService(
|
||||
"Keeping remote version of ${remote.title} with merged chapters."
|
||||
}
|
||||
updateCategories(
|
||||
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
|
||||
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
|
||||
remoteCategoriesMapByOrder,
|
||||
)
|
||||
}
|
||||
@@ -197,9 +216,15 @@ abstract class SyncService(
|
||||
private fun mergeChapters(
|
||||
localChapters: List<BackupChapter>,
|
||||
remoteChapters: List<BackupChapter>,
|
||||
lastSyncTime: Long,
|
||||
syncingChapters: Boolean,
|
||||
): List<BackupChapter> {
|
||||
val logTag = "MergeChapters"
|
||||
|
||||
if (!syncingChapters) {
|
||||
return remoteChapters // If not syncing chapters, keep remote untouched
|
||||
}
|
||||
|
||||
fun chapterCompositeKey(chapter: BackupChapter): String {
|
||||
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
|
||||
}
|
||||
@@ -223,12 +248,22 @@ abstract class SyncService(
|
||||
|
||||
when {
|
||||
localChapter != null && remoteChapter == null -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
|
||||
localChapter
|
||||
if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) {
|
||||
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 -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
|
||||
remoteChapter
|
||||
if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) {
|
||||
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 -> {
|
||||
// Use version number to decide which chapter to keep
|
||||
@@ -274,37 +309,70 @@ abstract class SyncService(
|
||||
localCategoriesList: List<BackupCategory>?,
|
||||
remoteCategoriesList: List<BackupCategory>?,
|
||||
): List<BackupCategory> {
|
||||
val logTag = "MergeCategories"
|
||||
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
|
||||
if (remoteCategoriesList == null) return localCategoriesList
|
||||
val localCategoriesMap = localCategoriesList.associateBy { it.name }
|
||||
val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name }
|
||||
|
||||
val mergedCategoriesMap = mutableMapOf<String, BackupCategory>()
|
||||
val result = mutableListOf<BackupCategory>()
|
||||
val processedLocals = mutableSetOf<BackupCategory>()
|
||||
|
||||
localCategoriesMap.forEach { (name, localCategory) ->
|
||||
val remoteCategory = remoteCategoriesMap[name]
|
||||
if (remoteCategory != null) {
|
||||
// Compare and merge local and remote categories
|
||||
val mergedCategory = if (localCategory.order > remoteCategory.order) {
|
||||
localCategory
|
||||
val localMapByUid = localCategoriesList.filter { it.uid != 0L }.associateBy { it.uid }
|
||||
val localMapByName = localCategoriesList.associateBy { it.name }
|
||||
|
||||
val lastSyncTime = syncPreferences.lastSyncTimestamp().get()
|
||||
|
||||
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 {
|
||||
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 {
|
||||
// If the category is only in the local list, add it to the merged list
|
||||
mergedCategoriesMap[name] = localCategory
|
||||
val remoteModifiedTimeMillis = remote.lastModifiedAt.seconds.inWholeMilliseconds
|
||||
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
|
||||
remoteCategoriesMap.forEach { (name, remoteCategory) ->
|
||||
if (!mergedCategoriesMap.containsKey(name)) {
|
||||
mergedCategoriesMap[name] = remoteCategory
|
||||
// Add remaining Local Categories
|
||||
localCategoriesList.forEach { local ->
|
||||
if (local !in processedLocals) {
|
||||
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(
|
||||
@@ -341,8 +409,8 @@ abstract class SyncService(
|
||||
remoteSource
|
||||
}
|
||||
else -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "Remote and local is not empty: $sourceId. Skipping." }
|
||||
null
|
||||
logcat(LogPriority.DEBUG, logTag) { "Remote and local have the same source ID: $sourceId. Keeping local." }
|
||||
localSource
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,8 +455,8 @@ abstract class SyncService(
|
||||
remotePreference
|
||||
}
|
||||
else -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have keys. Skipping: $key" }
|
||||
null
|
||||
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same preference key: $key. Keeping local." }
|
||||
localPreference
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,10 +575,8 @@ abstract class SyncService(
|
||||
}
|
||||
|
||||
else -> {
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"No saved search found for composite key: $compositeKey. Skipping."
|
||||
}
|
||||
null
|
||||
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same saved search key: $compositeKey. Keeping local." }
|
||||
localSearch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package eu.kanade.tachiyomi.di
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
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.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.tachiyomi.BuildConfig
|
||||
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.util.storage.CbzCrypto
|
||||
import exh.eh.EHentaiUpdateHelper
|
||||
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
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.injectLazy
|
||||
|
||||
// SY -->
|
||||
private const val LEGACY_DATABASE_NAME = "tachiyomi.db"
|
||||
// SY <--
|
||||
|
||||
class AppModule(val app: Application) : InjektModule {
|
||||
// SY -->
|
||||
private val securityPreferences: SecurityPreferences by injectLazy()
|
||||
@@ -68,40 +66,37 @@ class AppModule(val app: Application) : InjektModule {
|
||||
// SY -->
|
||||
if (securityPreferences.encryptDatabase().get()) {
|
||||
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 <--
|
||||
AndroidSqliteDriver(
|
||||
|
||||
AndroidxSqliteDriver(
|
||||
driver = BundledSQLiteDriver(),
|
||||
databaseType = AndroidxSqliteDatabaseType.FileProvider(app, "tachiyomi.db"),
|
||||
schema = Database.Schema,
|
||||
context = app,
|
||||
// SY -->
|
||||
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()
|
||||
}
|
||||
},
|
||||
configuration = AndroidxSqliteConfiguration(
|
||||
isForeignKeyConstraintsEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
addSingletonFactory {
|
||||
|
||||
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -140,20 +141,22 @@ class ExtensionManager(
|
||||
* Loads and registers the installed extensions.
|
||||
*/
|
||||
private fun initExtensions() {
|
||||
val extensions = ExtensionLoader.loadExtensions(context)
|
||||
scope.launch {
|
||||
val extensions = ExtensionLoader.loadExtensions(context)
|
||||
|
||||
installedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Success>()
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
installedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Success>()
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
|
||||
untrustedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Untrusted>()
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
// SY -->
|
||||
.filterNotBlacklisted()
|
||||
// SY <--
|
||||
untrustedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Untrusted>()
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
// SY -->
|
||||
.filterNotBlacklisted()
|
||||
// SY <--
|
||||
|
||||
_isInitialized.value = true
|
||||
_isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
|
||||
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
||||
import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -114,7 +115,7 @@ internal object ExtensionLoader {
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadExtensions(context: Context): List<LoadResult> {
|
||||
suspend fun loadExtensions(context: Context): List<LoadResult> {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -160,11 +161,10 @@ internal object ExtensionLoader {
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
return coroutineScope {
|
||||
extPkgs.map {
|
||||
async { loadExtension(context, it) }
|
||||
}
|
||||
deferred.awaitAll()
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
|
||||
class NHentai(delegate: HttpSource, val context: Context) :
|
||||
DelegatedHttpSource(delegate),
|
||||
@@ -70,12 +71,8 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
}
|
||||
|
||||
override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
|
||||
val body = input.body.string()
|
||||
val server = MEDIA_SERVER_REGEX.find(body)?.groupValues?.get(1)?.toInt() ?: 1
|
||||
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)
|
||||
if (nhConfig == null) getNhConfig()
|
||||
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(input.body.string())
|
||||
|
||||
with(metadata) {
|
||||
nhId = jsonResponse.id
|
||||
@@ -86,8 +83,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
|
||||
mediaId = jsonResponse.mediaId
|
||||
|
||||
mediaServer = server
|
||||
|
||||
jsonResponse.title?.let { title ->
|
||||
japaneseTitle = title.japanese
|
||||
shortTitle = title.pretty
|
||||
@@ -96,15 +91,11 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
|
||||
preferredTitle = this@NHentai.preferredTitle
|
||||
|
||||
jsonResponse.images?.let { images ->
|
||||
coverImageType = images.cover?.type
|
||||
images.pages.mapNotNull {
|
||||
it.type
|
||||
}.let {
|
||||
pageImageTypes = it
|
||||
}
|
||||
thumbnailImageType = images.thumbnail?.type
|
||||
}
|
||||
coverImageUrl =
|
||||
jsonResponse.cover?.path?.let { "$thumbServer/$it" }
|
||||
?: jsonResponse.thumbnail?.path?.let { "$thumbServer/$it" }
|
||||
|
||||
pageImagePreviewUrls = jsonResponse.pages.mapNotNull { it.thumbnail }
|
||||
|
||||
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
|
||||
data class JsonResponse(
|
||||
val id: Long,
|
||||
@SerialName("media_id")
|
||||
val mediaId: String? = null,
|
||||
val title: JsonTitle? = null,
|
||||
val images: JsonImages? = null,
|
||||
val cover: JsonPage? = null,
|
||||
val thumbnail: JsonPage? = null,
|
||||
val scanlator: String? = null,
|
||||
@SerialName("upload_date")
|
||||
val uploadDate: Long? = null,
|
||||
@@ -140,6 +140,7 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
val numPages: Int? = null,
|
||||
@SerialName("num_favorites")
|
||||
val numFavorites: Long? = null,
|
||||
val pages: List<JsonPage> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -149,21 +150,12 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
val pretty: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JsonImages(
|
||||
val pages: List<JsonPage> = emptyList(),
|
||||
val cover: JsonPage? = null,
|
||||
val thumbnail: JsonPage? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JsonPage(
|
||||
@SerialName("t")
|
||||
val type: String? = null,
|
||||
@SerialName("w")
|
||||
val path: String? = null,
|
||||
val width: Long? = null,
|
||||
@SerialName("h")
|
||||
val height: Long? = null,
|
||||
val thumbnail: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -188,15 +180,16 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
}
|
||||
|
||||
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
|
||||
if (nhConfig == null) getNhConfig()
|
||||
val metadata = fetchOrLoadMetadata(manga.id()) {
|
||||
client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
|
||||
}
|
||||
return PagePreviewPage(
|
||||
page,
|
||||
metadata.pageImageTypes.mapIndexed { index, s ->
|
||||
metadata.pageImagePreviewUrls.mapIndexed { index, path ->
|
||||
PagePreviewInfo(
|
||||
index + 1,
|
||||
imageUrl = thumbnailUrlFromType(metadata.mediaId!!, metadata.mediaServer ?: 1, index + 1, s)!!,
|
||||
imageUrl = "$thumbServer/$path",
|
||||
)
|
||||
},
|
||||
false,
|
||||
@@ -204,15 +197,24 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
)
|
||||
}
|
||||
|
||||
private fun thumbnailUrlFromType(
|
||||
mediaId: String,
|
||||
mediaServer: Int,
|
||||
page: Int,
|
||||
t: String,
|
||||
) = NHentaiSearchMetadata.typeToExtension(t)?.let {
|
||||
"https://t$mediaServer.nhentai.net/galleries/$mediaId/${page}t.$it"
|
||||
var nhConfig: JsonConfig? = null
|
||||
suspend fun getNhConfig() {
|
||||
try {
|
||||
val response =
|
||||
withIOContext { client.newCall(GET("https://nhentai.net/api/v2/config", headers)).awaitSuccess() }
|
||||
val body = response.body.string()
|
||||
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 {
|
||||
return client.newCachelessCallWithProgress(
|
||||
if (cacheControl != null) {
|
||||
@@ -230,10 +232,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
||||
private val jsonParser = Json {
|
||||
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:"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
@@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
||||
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
||||
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)
|
||||
|
||||
val didMigration = if (isLaunch) {
|
||||
addAnalytics()
|
||||
Migrator.awaitAndRelease()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
|
||||
if (!isTaskRoot) {
|
||||
finish()
|
||||
@@ -177,11 +170,17 @@ class MainActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
// SY -->
|
||||
@Suppress("KotlinConstantConditions")
|
||||
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
|
||||
val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest")
|
||||
// SY <--
|
||||
|
||||
setComposeContent {
|
||||
var didMigration by remember { mutableStateOf<Boolean?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
addAnalytics()
|
||||
didMigration = Migrator.awaitAndRelease()
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var incognito by remember { mutableStateOf(getIncognitoState.await(null)) }
|
||||
@@ -309,7 +308,7 @@ class MainActivity : BaseActivity() {
|
||||
}
|
||||
// SY <--
|
||||
|
||||
var showChangelog by remember { mutableStateOf(didMigration && !BuildConfig.DEBUG) }
|
||||
var showChangelog by remember { mutableStateOf(didMigration == true && !BuildConfig.DEBUG) }
|
||||
if (showChangelog) {
|
||||
// SY -->
|
||||
WhatsNewDialog(onDismissRequest = { showChangelog = false })
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.Error
|
||||
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.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
@@ -101,6 +103,8 @@ import exh.source.isEhBasedSource
|
||||
import exh.ui.ifSourcesLoaded
|
||||
import exh.util.defaultReaderType
|
||||
import exh.util.mangaType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
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.util.lang.launchIO
|
||||
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.system.logcat
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
@@ -394,28 +399,36 @@ class ReaderActivity : BaseActivity() {
|
||||
|
||||
is ReaderViewModel.Dialog.ChapterList -> {
|
||||
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 -->
|
||||
ReaderViewModel.Dialog.AutoScrollHelp -> AlertDialog(
|
||||
@@ -591,8 +604,9 @@ class ReaderActivity : BaseActivity() {
|
||||
} else {
|
||||
cropBorderContinuousVertical
|
||||
}
|
||||
val readerBottomButtons by readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
|
||||
.collectAsState(persistentSetOf())
|
||||
val readerBottomButtons by remember {
|
||||
readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
|
||||
}.collectAsState(persistentSetOf())
|
||||
val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState()
|
||||
|
||||
val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState()
|
||||
|
||||
@@ -59,7 +59,6 @@ import exh.source.isEhBasedManga
|
||||
import exh.util.defaultReaderType
|
||||
import exh.util.mangaType
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -183,19 +182,26 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
|
||||
private var chapterToDownload: Download? = null
|
||||
|
||||
private val unfilteredChapterList by lazy {
|
||||
val manga = manga!!
|
||||
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) }
|
||||
private var unfilteredChapterListCache: List<tachiyomi.domain.chapter.model.Chapter>? = null
|
||||
private suspend fun getUnfilteredChapterList(): List<tachiyomi.domain.chapter.model.Chapter> {
|
||||
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
|
||||
* 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!!
|
||||
// SY -->
|
||||
val (chapters, mangaMap) = runBlocking {
|
||||
val (chapters, mangaMap) =
|
||||
if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to
|
||||
getMergedMangaById.await(manga.id)
|
||||
@@ -203,7 +209,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
} else {
|
||||
getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to null
|
||||
}
|
||||
}
|
||||
|
||||
fun isChapterDownloaded(chapter: Chapter): Boolean {
|
||||
val chapterManga = mangaMap?.get(chapter.mangaId) ?: manga
|
||||
return downloadManager.isChapterDownloaded(
|
||||
@@ -253,7 +259,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
else -> chapters
|
||||
}
|
||||
|
||||
chaptersForReader
|
||||
val result = chaptersForReader
|
||||
.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||
.run {
|
||||
if (readerPreferences.skipDupe().get()) {
|
||||
@@ -271,6 +277,8 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
}
|
||||
.map { it.toDbChapter() }
|
||||
.map(::ReaderChapter)
|
||||
chapterListCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) }
|
||||
@@ -409,7 +417,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
|
||||
loadChapter(
|
||||
loader!!,
|
||||
chapterList.first { chapterId == it.chapter.id },
|
||||
getChapterList().first { chapterId == it.chapter.id },
|
||||
/* SY --> */page, /* SY <-- */
|
||||
)
|
||||
Result.success(true)
|
||||
@@ -427,10 +435,10 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
// SY -->
|
||||
fun getChapters(): List<ReaderChapterItem> {
|
||||
suspend fun getChapters(): List<ReaderChapterItem> {
|
||||
val currentChapter = getCurrentChapter()
|
||||
|
||||
return chapterList.map {
|
||||
return getChapterList().map {
|
||||
ReaderChapterItem(
|
||||
chapter = it.chapter.toDomainChapter()!!,
|
||||
manga = manga!!,
|
||||
@@ -454,6 +462,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
): ViewerChapters {
|
||||
loader.loadChapter(chapter /* SY --> */, page/* SY <-- */)
|
||||
|
||||
val chapterList = getChapterList()
|
||||
val chapterPos = chapterList.indexOf(chapter)
|
||||
val newChapters = ViewerChapters(
|
||||
chapter,
|
||||
@@ -503,7 +512,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
|
||||
fun loadNewChapterFromDialog(chapter: Chapter) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -665,11 +674,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
* If both conditions are satisfied enqueues chapter for delete
|
||||
* @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()
|
||||
if (removeAfterReadSlots == -1) return
|
||||
|
||||
// Determine which chapter should be deleted and enqueue
|
||||
val chapterList = getChapterList()
|
||||
val currentChapterPosition = chapterList.indexOf(currentChapter)
|
||||
val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots)
|
||||
|
||||
@@ -739,7 +749,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
// SY -->
|
||||
if (manga?.isEhBasedManga() == true) {
|
||||
viewModelScope.launchNonCancellable {
|
||||
val chapterUpdates = unfilteredChapterList
|
||||
val chapterUpdates = getUnfilteredChapterList()
|
||||
.filter { it.sourceOrder > readerChapter.chapter.source_order }
|
||||
.map { chapter ->
|
||||
ChapterUpdate(
|
||||
@@ -759,7 +769,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
||||
if (!markDuplicateAsRead) return
|
||||
|
||||
val duplicateUnreadChapters = unfilteredChapterList
|
||||
val duplicateUnreadChapters = getUnfilteredChapterList()
|
||||
.mapNotNull { chapter ->
|
||||
if (
|
||||
!chapter.read &&
|
||||
@@ -774,7 +784,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
updateChapter.awaitAll(duplicateUnreadChapters)
|
||||
// SY -->
|
||||
duplicateUnreadChapters.forEach { chapterUpdate ->
|
||||
val chapter = unfilteredChapterList.first { it.id == chapterUpdate.id }
|
||||
val chapter = getUnfilteredChapterList().first { it.id == chapterUpdate.id }
|
||||
deleteChapterIfNeeded(ReaderChapter(chapter))
|
||||
}
|
||||
// SY <--
|
||||
@@ -863,9 +873,9 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
|
||||
// SY -->
|
||||
fun toggleBookmark(chapterId: Long, bookmarked: Boolean) {
|
||||
val chapter = chapterList.find { it.chapter.id == chapterId }?.chapter ?: return
|
||||
chapter.bookmark = bookmarked
|
||||
viewModelScope.launchNonCancellable {
|
||||
val chapter = getChapterList().find { it.chapter.id == chapterId }?.chapter ?: return@launchNonCancellable
|
||||
chapter.bookmark = bookmarked
|
||||
updateChapter.await(
|
||||
ChapterUpdate(
|
||||
id = chapterId,
|
||||
@@ -900,7 +910,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
*/
|
||||
fun setMangaReadingMode(readingMode: ReadingMode) {
|
||||
val manga = manga ?: return
|
||||
runBlocking(Dispatchers.IO) {
|
||||
viewModelScope.launchIO {
|
||||
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
|
||||
val currChapters = state.value.viewerChapters
|
||||
if (currChapters != null) {
|
||||
|
||||
@@ -251,7 +251,7 @@ class UpdatesScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadingNow(chapterId: Long) {
|
||||
private suspend fun startDownloadingNow(chapterId: Long) {
|
||||
downloadManager.startDownloadNow(chapterId)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
@@ -20,6 +21,7 @@ import java.time.ZoneId
|
||||
class CrashLogUtil(
|
||||
private val context: Context,
|
||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
||||
private val preferences: BasePreferences = Injekt.get(),
|
||||
) {
|
||||
|
||||
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
|
||||
@@ -44,6 +46,7 @@ class CrashLogUtil(
|
||||
App ID: ${BuildConfig.APPLICATION_ID}
|
||||
App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME})
|
||||
Preview build: $syDebugVersion
|
||||
Installation ID: ${preferences.installationId().get()}
|
||||
Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY})
|
||||
Device brand: ${Build.BRAND}
|
||||
Device manufacturer: ${Build.MANUFACTURER}
|
||||
|
||||
@@ -100,7 +100,7 @@ class EhLoginActivity : BaseActivity() {
|
||||
let html = document.documentElement.innerHTML;
|
||||
return html.includes("/cdn-cgi/");
|
||||
})();
|
||||
""".trimIndent()
|
||||
""".trimIndent(),
|
||||
) { result ->
|
||||
val isCloudflareBlock = result == "true"
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ fun NHentaiDescription(state: State.Success, openMetadataViewer: () -> Unit) {
|
||||
|
||||
binding.pages.text = context.pluralStringResource(
|
||||
SYMR.plurals.num_pages,
|
||||
meta.pageImageTypes.size,
|
||||
meta.pageImageTypes.size,
|
||||
meta.pageImagePreviewUrls.size,
|
||||
meta.pageImagePreviewUrls.size,
|
||||
)
|
||||
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ object Migrator {
|
||||
result = null
|
||||
}
|
||||
|
||||
fun awaitAndRelease(): Boolean = runBlocking {
|
||||
await().also { release() }
|
||||
suspend fun awaitAndRelease(): Boolean {
|
||||
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(),
|
||||
CategoryPreferencesCleanupMigration(),
|
||||
RemoveDuplicateReaderPreferenceMigration(),
|
||||
InstallationIdMigration(),
|
||||
)
|
||||
|
||||
@@ -39,6 +39,10 @@ class MoveSortingModeSettingsMigration : Migration {
|
||||
categoryId = it.id,
|
||||
flags = it.flags and 0b00111100L.inv(),
|
||||
name = null,
|
||||
version = it.version,
|
||||
uid = it.uid,
|
||||
last_modified_at = null,
|
||||
isSyncing = null,
|
||||
order = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ class MigrateMangaUseCase(
|
||||
updatedChapter = updatedChapter.copy(
|
||||
dateFetch = prevChapter.dateFetch,
|
||||
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.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
@@ -17,6 +19,10 @@ import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
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.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
internal suspend fun <T> AndroidDatabaseHandler.withTransaction(block: suspend () -> T): T {
|
||||
// Use inherited transaction context if available, this allows nested suspending transactions.
|
||||
val transactionContext =
|
||||
coroutineContext[TransactionElement]?.transactionDispatcher ?: createTransactionContext()
|
||||
return withContext(transactionContext) {
|
||||
val transactionElement = coroutineContext[TransactionElement]!!
|
||||
transactionElement.acquire()
|
||||
try {
|
||||
db.transactionWithResult {
|
||||
runBlocking(transactionContext) {
|
||||
block()
|
||||
val transactionElement = coroutineContext[TransactionElement]
|
||||
|
||||
// If we are already in a transaction, we don't need to lock the Mutex.
|
||||
// We just reuse the existing thread/context.
|
||||
if (transactionElement != null) {
|
||||
return withContext(transactionElement.transactionDispatcher) {
|
||||
transactionElement.acquire()
|
||||
try {
|
||||
db.transactionWithResult {
|
||||
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,
|
||||
order: Long,
|
||||
flags: Long,
|
||||
version: Long,
|
||||
uid: Long,
|
||||
lastModifiedAt: Long,
|
||||
): Category {
|
||||
return Category(
|
||||
id = id,
|
||||
name = name,
|
||||
order = order,
|
||||
flags = flags,
|
||||
version = version,
|
||||
uid = uid,
|
||||
lastModifiedAt = lastModifiedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ class CategoryRepositoryImpl(
|
||||
name = category.name,
|
||||
order = category.order,
|
||||
flags = category.flags,
|
||||
version = category.version,
|
||||
uid = category.uid,
|
||||
last_modified_at = category.lastModifiedAt,
|
||||
)
|
||||
categoriesQueries.selectLastInsertedRowId()
|
||||
}
|
||||
@@ -67,6 +70,10 @@ class CategoryRepositoryImpl(
|
||||
name = update.name,
|
||||
order = update.order,
|
||||
flags = update.flags,
|
||||
version = update.version,
|
||||
uid = update.uid,
|
||||
last_modified_at = update.lastModifiedAt,
|
||||
isSyncing = null,
|
||||
categoryId = update.id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ CREATE TABLE categories(
|
||||
name TEXT NOT NULL,
|
||||
sort 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 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
|
||||
CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE
|
||||
ON categories
|
||||
@@ -20,8 +24,29 @@ BEGIN SELECT CASE
|
||||
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:
|
||||
SELECT _id,name,sort,flags
|
||||
SELECT _id,name,sort,flags,version,uid,last_modified_at
|
||||
FROM categories
|
||||
WHERE _id = :id
|
||||
LIMIT 1;
|
||||
@@ -31,7 +56,10 @@ SELECT
|
||||
_id AS id,
|
||||
name,
|
||||
sort AS `order`,
|
||||
flags
|
||||
flags,
|
||||
version,
|
||||
uid,
|
||||
last_modified_at
|
||||
FROM categories
|
||||
ORDER BY sort;
|
||||
|
||||
@@ -40,15 +68,18 @@ SELECT
|
||||
C._id AS id,
|
||||
C.name,
|
||||
C.sort AS `order`,
|
||||
C.flags
|
||||
C.flags,
|
||||
C.version,
|
||||
C.uid,
|
||||
C.last_modified_at
|
||||
FROM categories C
|
||||
JOIN mangas_categories MC
|
||||
ON C._id = MC.category_id
|
||||
WHERE MC.manga_id = :mangaId;
|
||||
|
||||
insert:
|
||||
INSERT INTO categories(name, sort, flags, manga_order)
|
||||
VALUES (:name, :order, :flags, "");
|
||||
INSERT INTO categories(name, sort, flags, manga_order, version, uid, last_modified_at)
|
||||
VALUES (:name, :order, :flags, "", :version, :uid, :last_modified_at);
|
||||
|
||||
delete:
|
||||
DELETE FROM categories
|
||||
@@ -58,7 +89,11 @@ update:
|
||||
UPDATE categories
|
||||
SET name = coalesce(:name, name),
|
||||
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;
|
||||
|
||||
updateAllFlags:
|
||||
@@ -66,4 +101,9 @@ UPDATE categories SET
|
||||
flags = coalesce(?, flags);
|
||||
|
||||
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 order: Long,
|
||||
val flags: Long,
|
||||
val version: Long = 0,
|
||||
val uid: Long = 0,
|
||||
val lastModifiedAt: Long = 0,
|
||||
) : Serializable {
|
||||
|
||||
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
|
||||
|
||||
@@ -5,4 +5,7 @@ data class CategoryUpdate(
|
||||
val name: String? = null,
|
||||
val order: Long? = null,
|
||||
val flags: Long? = null,
|
||||
val version: Long? = null,
|
||||
val uid: Long? = null,
|
||||
val lastModifiedAt: Long? = null,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[versions]
|
||||
agp_version = "8.13.2"
|
||||
lifecycle_version = "2.10.0"
|
||||
paging_version = "3.4.1"
|
||||
paging_version = "3.4.2"
|
||||
interpolator_version = "1.0.0"
|
||||
sqlite = "2.6.2"
|
||||
|
||||
[libraries]
|
||||
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"
|
||||
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
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"
|
||||
recyclerview = "androidx.recyclerview:recyclerview:1.4.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-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"
|
||||
|
||||
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
|
||||
|
||||
[bundles]
|
||||
lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
compose-bom = "2026.02.00"
|
||||
compose-bom = "2026.03.00"
|
||||
|
||||
[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" }
|
||||
foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
animation = { module = "androidx.compose.animation:animation" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
kotlin_version = "2.3.10"
|
||||
kotlin_version = "2.3.20"
|
||||
serialization_version = "1.10.0"
|
||||
xml_serialization_version = "0.91.3"
|
||||
|
||||
|
||||
+10
-15
@@ -1,18 +1,17 @@
|
||||
[versions]
|
||||
aboutlib_version = "13.2.1"
|
||||
leakcanary = "2.14"
|
||||
moko = "0.26.0"
|
||||
moko = "0.26.1"
|
||||
okhttp_version = "5.3.2"
|
||||
shizuku_version = "13.1.5"
|
||||
sqldelight = "2.2.1"
|
||||
sqlite = "2.6.2"
|
||||
sqldelight = "2.3.2"
|
||||
voyager = "1.1.0-beta03"
|
||||
spotless = "8.2.1"
|
||||
spotless = "8.3.0"
|
||||
ktlint-core = "1.8.0"
|
||||
firebase-bom = "34.9.0"
|
||||
firebase-bom = "34.10.0"
|
||||
markdown = "0.39.2"
|
||||
junit = "6.0.3"
|
||||
materialKolor = "5.0.0-alpha06"
|
||||
materialKolor = "5.0.0-alpha07"
|
||||
|
||||
[libraries]
|
||||
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-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", 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"
|
||||
|
||||
@@ -36,10 +35,6 @@ disklrucache = "com.jakewharton:disklrucache:2.0.2"
|
||||
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
|
||||
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"
|
||||
|
||||
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" }
|
||||
|
||||
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-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-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"
|
||||
|
||||
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]
|
||||
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]
|
||||
js-engine = ["quickjs-android"]
|
||||
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]
|
||||
coil = ["coil-core", "coil-gif", "coil-compose", "coil-network-okhttp"]
|
||||
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"]
|
||||
test = ["junit-jupiter", "kotest-assertions", "mockk"]
|
||||
markdown = ["markdown-core", "markdown-coil"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
koin = "4.1.1"
|
||||
koin = "4.2.0"
|
||||
|
||||
[libraries]
|
||||
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"
|
||||
|
||||
sqlcipher = "net.zetetic:sqlcipher-android:4.13.0"
|
||||
sqlcipher = "net.zetetic:sqlcipher-android:4.14.1"
|
||||
|
||||
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="rec_search">共通の推奨事項を見つける</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>
|
||||
|
||||
@@ -143,4 +143,232 @@
|
||||
<string name="log_extra_desc">전체 로그</string>
|
||||
<string name="log_extreme_desc">네트워크 검사 모드</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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<resources>
|
||||
<plurals name="cleanup_done">
|
||||
<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>
|
||||
</plurals>
|
||||
<plurals name="num_lock_times">
|
||||
@@ -9,11 +10,10 @@
|
||||
<item quantity="other">%d tempos de bloqueio</item>
|
||||
</plurals>
|
||||
<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="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>
|
||||
</plurals>
|
||||
|
||||
<plurals name="migrate_entry">
|
||||
<item quantity="one">Migrar %1$d%2$s mangá?</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="other">%d mangás migrados</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Extra gallery info -->
|
||||
<plurals name="num_pages">
|
||||
<item quantity="one">%1$d página</item>
|
||||
<item quantity="other">%1$d páginas</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Enhanced E/ExHentai Browse View -->
|
||||
<plurals name="browse_language_and_pages">
|
||||
<item quantity="one">%2$s, %1$d página</item>
|
||||
<item quantity="other">%2$s, %1$d páginas</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Humanize time -->
|
||||
<plurals name="humanize_year">
|
||||
<item quantity="one">há %1$d ano</item>
|
||||
@@ -68,4 +65,14 @@
|
||||
<item quantity="one">há %1$d segundo</item>
|
||||
<item quantity="other">há %1$d segundo</item>
|
||||
</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>
|
||||
|
||||
@@ -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="aes_256">AES 256</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>
|
||||
|
||||
@@ -692,8 +692,8 @@
|
||||
<string name="scan_qr_code">Сканировать QR-код</string>
|
||||
<string name="filename">Название файла</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="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">Включать URL-хэш главы</string>
|
||||
<string name="pref_include_chapter_url_hash_desc">Добавить первые шесть символов хеша MD5 из URL-адреса главы к имени файла или папки главы.</string>
|
||||
</resources>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<item quantity="other">%1$d சில நாட்களுக்கு முன்பு</item>
|
||||
</plurals>
|
||||
<plurals name="humanize_hour">
|
||||
<item quantity="one">%1$d மணி நேரத்திற்கு முன்பு</item>
|
||||
<item quantity="one">%1$d மணி முன்பு</item>
|
||||
<item quantity="other">%1$d மணி நேரத்திற்கு முன்பு</item>
|
||||
</plurals>
|
||||
<plurals name="humanize_minute">
|
||||
|
||||
@@ -620,4 +620,10 @@
|
||||
<string name="rec_error_string">தேடல் செயல்பாட்டின் போது பிழை ஏற்பட்டது: %1$s</string>
|
||||
<string name="rec_processing_state">செயலாக்க நுழைவு %1$d %2$d</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>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</plurals>
|
||||
<plurals name="eh_retry_toast">
|
||||
<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 name="browse_language_and_pages">
|
||||
<item quantity="one">%2$s, %1$d sayfa</item>
|
||||
@@ -38,7 +38,7 @@
|
||||
</plurals>
|
||||
<plurals name="cleanup_done">
|
||||
<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 name="entry_migrated">
|
||||
<item quantity="one">%d girdi taşındı</item>
|
||||
@@ -53,8 +53,8 @@
|
||||
<item quantity="other">%1$d dakika önce</item>
|
||||
</plurals>
|
||||
<plurals name="num_lock_times">
|
||||
<item quantity="one">%d kilit zamanı</item>
|
||||
<item quantity="other">%d kilit zamanı</item>
|
||||
<item quantity="one">%d kilitli zaman</item>
|
||||
<item quantity="other">%d kilitli zamanlar</item>
|
||||
</plurals>
|
||||
<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>
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
|
||||
<!-- Operations -->
|
||||
<string name="loading">Loading…</string>
|
||||
<string name="calculating">Calculating…</string>
|
||||
<string name="internal_error">InternalError: Check crash logs for further information</string>
|
||||
|
||||
<!-- Shortcuts-->
|
||||
|
||||
@@ -28,15 +28,13 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
var favoritesCount: Long? = null
|
||||
|
||||
var mediaId: String? = null
|
||||
var mediaServer: Int? = null
|
||||
|
||||
var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE)
|
||||
var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH)
|
||||
var shortTitle by titleDelegate(TITLE_TYPE_SHORT)
|
||||
|
||||
var coverImageType: String? = null
|
||||
var pageImageTypes: List<String> = emptyList()
|
||||
var thumbnailImageType: String? = null
|
||||
var coverImageUrl: String? = null
|
||||
var pageImagePreviewUrls: List<String> = emptyList()
|
||||
|
||||
var scanlator: String? = null
|
||||
|
||||
@@ -45,14 +43,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
override fun createMangaInfo(manga: SManga): SManga {
|
||||
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) {
|
||||
TITLE_TYPE_SHORT -> shortTitle ?: englishTitle ?: japaneseTitle ?: manga.title
|
||||
0, TITLE_TYPE_ENGLISH -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title
|
||||
@@ -85,7 +75,7 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
|
||||
return manga.copy(
|
||||
url = key ?: manga.url,
|
||||
thumbnail_url = cover ?: manga.thumbnail_url,
|
||||
thumbnail_url = coverImageUrl ?: manga.thumbnail_url,
|
||||
title = title,
|
||||
artist = group ?: manga.artist,
|
||||
author = artist ?: manga.artist,
|
||||
@@ -113,9 +103,8 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
getItem(japaneseTitle) { stringResource(SYMR.strings.japanese_title) },
|
||||
getItem(englishTitle) { stringResource(SYMR.strings.english_title) },
|
||||
getItem(shortTitle) { stringResource(SYMR.strings.short_title) },
|
||||
getItem(coverImageType) { stringResource(SYMR.strings.cover_image_file_type) },
|
||||
getItem(pageImageTypes.size) { stringResource(SYMR.strings.page_count) },
|
||||
getItem(thumbnailImageType) { stringResource(SYMR.strings.thumbnail_image_file_type) },
|
||||
getItem(coverImageUrl) { stringResource(SYMR.strings.thumbnail_url) },
|
||||
getItem(pageImagePreviewUrls.size) { stringResource(SYMR.strings.page_count) },
|
||||
getItem(scanlator) { stringResource(MR.strings.scanlator) },
|
||||
)
|
||||
}
|
||||
@@ -134,15 +123,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
private const val NHENTAI_GROUP_NAMESPACE = "group"
|
||||
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) =
|
||||
url.split("/").last { it.isNotBlank() }.toLong()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user