diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 5a7bfdd10..bebc653c0 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -3,7 +3,8 @@ on: pull_request: paths-ignore: - '**.md' - - 'i18n/src/main/res/**/strings.xml' + - 'i18n/src/commonMain/resources/**/strings.xml' + - 'i18n/src/commonMain/resources/**/plurals.xml' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4397098b5..d85e85370 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 112 + versionCode = 113 versionName = "0.14.7" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") @@ -123,6 +123,7 @@ android { buildFeatures { viewBinding = true compose = true + buildConfig = true // Disable some unused things aidl = false diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7d560094..d7a1aa265 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -160,7 +160,8 @@ + android:exported="false" + android:foregroundServiceType="shortService" /> Unit, + onLongClick: () -> Unit, isSelected: Boolean = false, title: String? = null, - coverData: tachiyomi.domain.manga.model.MangaCover, + onClickContinueReading: (() -> Unit)? = null, coverAlpha: Float = 1f, coverBadgeStart: @Composable (RowScope.() -> Unit)? = null, coverBadgeEnd: @Composable (RowScope.() -> Unit)? = null, - onLongClick: () -> Unit, - onClick: () -> Unit, - onClickContinueReading: (() -> Unit)? = null, ) { GridItemSelectable( isSelected = isSelected, @@ -163,15 +163,15 @@ private fun BoxScope.CoverTextOverlay( */ @Composable fun MangaComfortableGridItem( - isSelected: Boolean = false, - title: String, - titleMaxLines: Int = 2, coverData: tachiyomi.domain.manga.model.MangaCover, + title: String, + onClick: () -> Unit, + onLongClick: () -> Unit, + isSelected: Boolean = false, + titleMaxLines: Int = 2, coverAlpha: Float = 1f, coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, - onLongClick: () -> Unit, - onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, ) { GridItemSelectable( @@ -253,10 +253,10 @@ private fun MangaGridCover( @Composable private fun GridItemTitle( - modifier: Modifier, title: String, style: TextStyle, minLines: Int, + modifier: Modifier = Modifier, maxLines: Int = 2, ) { Text( @@ -276,10 +276,10 @@ private fun GridItemTitle( */ @Composable private fun GridItemSelectable( - modifier: Modifier = Modifier, isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { Box( @@ -316,13 +316,13 @@ private fun Modifier.selectedOutline( */ @Composable fun MangaListItem( - isSelected: Boolean = false, - title: String, coverData: tachiyomi.domain.manga.model.MangaCover, - coverAlpha: Float = 1f, - badge: @Composable (RowScope.() -> Unit), - onLongClick: () -> Unit, + title: String, onClick: () -> Unit, + onLongClick: () -> Unit, + badge: @Composable (RowScope.() -> Unit), + isSelected: Boolean = false, + coverAlpha: Float = 1f, onClickContinueReading: (() -> Unit)? = null, ) { Row( diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt index 991b49486..b14d2ed14 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -189,7 +188,7 @@ fun MangaChapterListItem( text = readProgress, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.alpha(ReadItemAlpha), + color = LocalContentColor.current.copy(alpha = ReadItemAlpha), ) if (scanlator != null) DotSeparatorText() } diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt index a643a15b2..5d33206b2 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -124,7 +124,7 @@ fun MangaInfoBox( ) } .blur(4.dp) - .alpha(.2f), + .alpha(0.2f), ) // Manga & source info diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt index e25eb8b25..12c14ce78 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaToolbar.kt @@ -35,10 +35,8 @@ import tachiyomi.presentation.core.theme.active @Composable fun MangaToolbar( - modifier: Modifier = Modifier, title: String, titleAlphaProvider: () -> Float, - backgroundAlphaProvider: () -> Float = titleAlphaProvider, hasFilters: Boolean, onBackClicked: () -> Unit, onClickFilter: () -> Unit, @@ -47,10 +45,14 @@ fun MangaToolbar( onClickEditCategory: (() -> Unit)?, onClickRefresh: () -> Unit, onClickMigrate: (() -> Unit)?, + // For action mode actionModeCounter: Int, onSelectAll: () -> Unit, onInvertSelection: () -> Unit, + + modifier: Modifier = Modifier, + backgroundAlphaProvider: () -> Float = titleAlphaProvider, ) { Column( modifier = modifier, @@ -62,7 +64,7 @@ fun MangaToolbar( text = if (isActionMode) actionModeCounter.toString() else title, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.alpha(if (isActionMode) 1f else titleAlphaProvider()), + color = LocalContentColor.current.copy(alpha = if (isActionMode) 1f else titleAlphaProvider()), ) }, navigationIcon = { diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/GuidesStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/GuidesStep.kt index 5899dae55..77e0e7b88 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/GuidesStep.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/GuidesStep.kt @@ -17,34 +17,38 @@ import eu.kanade.presentation.theme.TachiyomiTheme import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource -@Composable -internal fun GuidesStep( - onRestoreBackup: () -> Unit, -) { - val handler = LocalUriHandler.current +internal class GuidesStep( + private val onRestoreBackup: () -> Unit, +) : OnboardingStep { + override val isComplete: Boolean = true - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(stringResource(MR.strings.onboarding_guides_new_user, stringResource(MR.strings.app_name))) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { handler.openUri(GETTING_STARTED_URL) }, + @Composable + override fun Content() { + val handler = LocalUriHandler.current + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(MR.strings.getting_started_guide)) - } + Text(stringResource(MR.strings.onboarding_guides_new_user, stringResource(MR.strings.app_name))) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { handler.openUri(GETTING_STARTED_URL) }, + ) { + Text(stringResource(MR.strings.getting_started_guide)) + } - HorizontalDivider( - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) + HorizontalDivider( + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) - Text(stringResource(MR.strings.onboarding_guides_returning_user, stringResource(MR.strings.app_name))) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onRestoreBackup, - ) { - Text(stringResource(MR.strings.pref_restore_backup)) + Text(stringResource(MR.strings.onboarding_guides_returning_user, stringResource(MR.strings.app_name))) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onRestoreBackup, + ) { + Text(stringResource(MR.strings.pref_restore_backup)) + } } } } @@ -57,6 +61,6 @@ private fun GuidesStepPreview() { TachiyomiTheme { GuidesStep( onRestoreBackup = {}, - ) + ).Content() } } diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt index ef42a68fc..c5fd8c2fa 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt @@ -13,15 +13,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.util.system.toast import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.rememberSlideDistance -import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource @@ -29,24 +26,21 @@ import tachiyomi.presentation.core.screens.InfoScreen @Composable fun OnboardingScreen( - storagePreferences: StoragePreferences, - uiPreferences: UiPreferences, onComplete: () -> Unit, onRestoreBackup: () -> Unit, ) { - val context = LocalContext.current val slideDistance = rememberSlideDistance() - var currentStep by remember { mutableIntStateOf(0) } - val steps: List<@Composable () -> Unit> = remember { + var currentStep by rememberSaveable { mutableIntStateOf(0) } + val steps = remember { listOf( - { ThemeStep(uiPreferences = uiPreferences) }, - { StorageStep(storagePref = storagePreferences.baseStorageDirectory()) }, - // TODO: prompt for notification permissions when bumping target to Android 13 - { GuidesStep(onRestoreBackup = onRestoreBackup) }, + ThemeStep(), + StorageStep(), + PermissionStep(), + GuidesStep(onRestoreBackup = onRestoreBackup), ) } - val isLastStep = currentStep == steps.size - 1 + val isLastStep = currentStep == steps.lastIndex BackHandler(enabled = currentStep != 0, onBack = { currentStep-- }) @@ -61,16 +55,12 @@ fun OnboardingScreen( MR.strings.onboarding_action_next }, ), + canAccept = steps[currentStep].isComplete, onAcceptClick = { if (isLastStep) { onComplete() } else { - // TODO: this is kind of janky - if (currentStep == 1 && !storagePreferences.baseStorageDirectory().isSet()) { - context.toast(MR.strings.onboarding_storage_selection_required) - } else { - currentStep++ - } + currentStep++ } }, ) { @@ -91,7 +81,7 @@ fun OnboardingScreen( }, label = "stepContent", ) { - steps[it]() + steps[it].Content() } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingStep.kt new file mode 100644 index 000000000..81b0a9f91 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingStep.kt @@ -0,0 +1,11 @@ +package eu.kanade.presentation.more.onboarding + +import androidx.compose.runtime.Composable + +internal interface OnboardingStep { + + val isComplete: Boolean + + @Composable + fun Content() +} diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt new file mode 100644 index 000000000..e7e3ec598 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt @@ -0,0 +1,181 @@ +package eu.kanade.presentation.more.onboarding + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.secondaryItemAlpha + +internal class PermissionStep : OnboardingStep { + + private var installGranted by mutableStateOf(false) + private var notificationGranted by mutableStateOf(false) + private var batteryGranted by mutableStateOf(false) + + override val isComplete: Boolean + get() = installGranted + + @Composable + override fun Content() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner.lifecycle) { + val observer = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + installGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else { + @Suppress("DEPRECATION") + Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS) != 0 + } + notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } else { + true + } + batteryGranted = context.getSystemService()!! + .isIgnoringBatteryOptimizations(context.packageName) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + Column( + modifier = Modifier.padding(vertical = 16.dp), + ) { + SectionHeader(stringResource(MR.strings.onboarding_permission_type_required)) + + PermissionItem( + title = stringResource(MR.strings.onboarding_permission_install_apps), + subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description), + granted = installGranted, + onButtonClick = { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + } + } else { + Intent(Settings.ACTION_SECURITY_SETTINGS) + } + context.startActivity(intent) + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SectionHeader(stringResource(MR.strings.onboarding_permission_type_optional)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionRequester = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { + // no-op. resulting checks is being done on resume + }, + ) + PermissionItem( + title = stringResource(MR.strings.onboarding_permission_notifications), + subtitle = stringResource(MR.strings.onboarding_permission_notifications_description), + granted = notificationGranted, + onButtonClick = { permissionRequester.launch(Manifest.permission.POST_NOTIFICATIONS) }, + ) + } + + PermissionItem( + title = stringResource(MR.strings.onboarding_permission_ignore_battery_opts), + subtitle = stringResource(MR.strings.onboarding_permission_ignore_battery_opts_description), + granted = batteryGranted, + onButtonClick = { + @SuppressLint("BatteryLife") + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + }, + ) + } + } + + @Composable + private fun SectionHeader( + text: String, + modifier: Modifier = Modifier, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + modifier = modifier + .padding(horizontal = 16.dp) + .secondaryItemAlpha(), + ) + } + + @Composable + private fun PermissionItem( + title: String, + subtitle: String, + granted: Boolean, + modifier: Modifier = Modifier, + onButtonClick: () -> Unit, + ) { + ListItem( + modifier = modifier, + headlineContent = { Text(text = title) }, + supportingContent = { Text(text = subtitle) }, + trailingContent = { + OutlinedButton( + enabled = !granted, + onClick = onButtonClick, + ) { + if (granted) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Text(stringResource(MR.strings.onboarding_permission_action_grant)) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/StorageStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/StorageStep.kt index 062e5a7c9..74a1be4ae 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/StorageStep.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/StorageStep.kt @@ -7,46 +7,66 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import eu.kanade.presentation.more.settings.screen.SettingsDataScreen import eu.kanade.tachiyomi.util.system.toast -import tachiyomi.core.preference.Preference +import kotlinx.coroutines.flow.collectLatest +import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Button import tachiyomi.presentation.core.i18n.stringResource +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get -@Composable -internal fun StorageStep( - storagePref: Preference, -) { - val context = LocalContext.current - val pickStorageLocation = SettingsDataScreen.storageLocationPicker(storagePref) +internal class StorageStep : OnboardingStep { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - stringResource( - MR.strings.onboarding_storage_info, - stringResource(MR.strings.app_name), - SettingsDataScreen.storageLocationText(storagePref), - ), - ) + private val storagePref = Injekt.get().baseStorageDirectory() - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - try { - pickStorageLocation.launch(null) - } catch (e: ActivityNotFoundException) { - context.toast(MR.strings.file_picker_error) - } - }, + private var _isComplete by mutableStateOf(false) + + override val isComplete: Boolean + get() = _isComplete + + @Composable + override fun Content() { + val context = LocalContext.current + val pickStorageLocation = SettingsDataScreen.storageLocationPicker(storagePref) + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(MR.strings.onboarding_storage_action_select)) + Text( + stringResource( + MR.strings.onboarding_storage_info, + stringResource(MR.strings.app_name), + SettingsDataScreen.storageLocationText(storagePref), + ), + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + try { + pickStorageLocation.launch(null) + } catch (e: ActivityNotFoundException) { + context.toast(MR.strings.file_picker_error) + } + }, + ) { + Text(stringResource(MR.strings.onboarding_storage_action_select)) + } + } + + LaunchedEffect(Unit) { + storagePref.changes() + .collectLatest { _isComplete = storagePref.isSet() } } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/ThemeStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/ThemeStep.kt index 69951e0b5..dfd7517dc 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/ThemeStep.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/ThemeStep.kt @@ -8,33 +8,40 @@ import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget import tachiyomi.presentation.core.util.collectAsState +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get -@Composable -internal fun ThemeStep( - uiPreferences: UiPreferences, -) { - val themeModePref = uiPreferences.themeMode() - val themeMode by themeModePref.collectAsState() +internal class ThemeStep : OnboardingStep { - val appThemePref = uiPreferences.appTheme() - val appTheme by appThemePref.collectAsState() + override val isComplete: Boolean = true - val amoledPref = uiPreferences.themeDarkAmoled() - val amoled by amoledPref.collectAsState() + private val uiPreferences: UiPreferences = Injekt.get() - Column { - AppThemeModePreferenceWidget( - value = themeMode, - onItemClick = { - themeModePref.set(it) - setAppCompatDelegateThemeMode(it) - }, - ) + @Composable + override fun Content() { + val themeModePref = uiPreferences.themeMode() + val themeMode by themeModePref.collectAsState() - AppThemePreferenceWidget( - value = appTheme, - amoled = amoled, - onItemClick = { appThemePref.set(it) }, - ) + val appThemePref = uiPreferences.appTheme() + val appTheme by appThemePref.collectAsState() + + val amoledPref = uiPreferences.themeDarkAmoled() + val amoled by amoledPref.collectAsState() + + Column { + AppThemeModePreferenceWidget( + value = themeMode, + onItemClick = { + themeModePref.set(it) + setAppCompatDelegateThemeMode(it) + }, + ) + + AppThemePreferenceWidget( + value = appTheme, + amoled = amoled, + onItemClick = { appThemePref.set(it) }, + ) + } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index c3286d78a..83ac603d9 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -37,9 +37,9 @@ import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.util.relativeTimeSpanString -import eu.kanade.tachiyomi.data.backup.BackupCreateJob import eu.kanade.tachiyomi.data.backup.BackupFileValidator -import eu.kanade.tachiyomi.data.backup.BackupRestoreJob +import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob +import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.sync.SyncDataJob @@ -107,7 +107,6 @@ object SettingsDataScreen : SearchableSettings { UniFile.fromUri(context, uri)?.let { storageDirPref.set(it.uri.toString()) } - Injekt.get().invalidateCache() } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt index 96c6ead6a..261a1b20a 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt @@ -29,8 +29,8 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.util.Screen -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags -import eu.kanade.tachiyomi.data.backup.BackupCreateJob +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags +import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.toast diff --git a/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogHome.kt b/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogHome.kt index 78b8fc953..07693aa3a 100644 --- a/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogHome.kt +++ b/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogHome.kt @@ -248,7 +248,6 @@ private fun TrackDetailsItem( Box( modifier = modifier .clickable(onClick = onClick) - .alpha(if (text == null) UnsetStatusTextAlpha else 1f) .fillMaxHeight() .padding(12.dp), contentAlignment = Alignment.Center, @@ -259,7 +258,7 @@ private fun TrackDetailsItem( overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (text == null) UnsetStatusTextAlpha else 1f), ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index f5f6c4f81..775bfe78d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -7,7 +7,7 @@ import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences -import eu.kanade.tachiyomi.data.backup.BackupCreateJob +import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.network.NetworkPreferences @@ -66,10 +66,6 @@ object Migrations { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - if (oldVersion < 14) { - // Restore jobs after upgrading to Evernote's job scheduler. - LibraryUpdateJob.setupTask(context) - } if (oldVersion < 15) { // Delete internal chapter cache dir. File(context.cacheDir, "chapter_disk_cache").deleteRecursively() @@ -96,11 +92,6 @@ object Migrations { } } } - if (oldVersion < 43) { - // Restore jobs after migrating from Evernote's job scheduler to WorkManager. - LibraryUpdateJob.setupTask(context) - BackupCreateJob.setupTask(context) - } if (oldVersion < 44) { // Reset sorting preference if using removed sort by source val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0) @@ -259,9 +250,6 @@ object Migrations { basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY) } } - if (oldVersion < 76) { - BackupCreateJob.setupTask(context) - } if (oldVersion < 77) { val oldReaderTap = prefs.getBoolean("reader_tap", false) if (!oldReaderTap) { @@ -374,9 +362,6 @@ object Migrations { } } } - if (oldVersion < 100) { - BackupCreateJob.setupTask(context) - } if (oldVersion < 105) { val pref = libraryPreferences.autoUpdateDeviceRestrictions() if (pref.isSet() && "battery_not_low" in pref.get()) { @@ -396,12 +381,7 @@ object Migrations { newKey = { Preference.privateKey(it) }, ) } - if (oldVersion < 111) { - File(context.cacheDir, "dl_index_cache") - .takeIf { it.exists() } - ?.delete() - } - if (oldVersion < 112) { + if (oldVersion < 113) { val prefsToReplace = listOf( "pref_download_only", "incognito_mode", @@ -421,6 +401,9 @@ object Migrations { filterPredicate = { it.key in prefsToReplace }, newKey = { Preference.appStateKey(it) }, ) + + // Deleting old download cache index files, but might as well clear it all out + context.cacheDir.deleteRecursively() } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt index 71bdbcc08..be914cf5b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt @@ -68,18 +68,18 @@ class BackupNotifier(private val context: Context) { } } - fun showBackupComplete(unifile: UniFile) { + fun showBackupComplete(file: UniFile) { context.cancelNotification(Notifications.ID_BACKUP_PROGRESS) with(completeNotificationBuilder) { setContentTitle(context.stringResource(MR.strings.backup_created)) - setContentText(unifile.filePath ?: unifile.name) + setContentText(file.filePath ?: file.name) clearActions() addAction( R.drawable.ic_share_24dp, context.stringResource(MR.strings.action_share), - NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri), + NotificationReceiver.shareBackupPendingBroadcast(context, file.uri), ) show(Notifications.ID_BACKUP_COMPLETE) @@ -88,13 +88,16 @@ class BackupNotifier(private val context: Context) { fun showRestoreProgress( content: String = "", - contentTitle: String = context.stringResource( - MR.strings.restoring_backup, - ), progress: Int = 0, maxAmount: Int = 100, + sync: Boolean = false, ): NotificationCompat.Builder { val builder = with(progressNotificationBuilder) { + val contentTitle = if (sync) { + context.stringResource(MR.strings.syncing_library) + } else { + context.stringResource(MR.strings.restoring_backup) + } setContentTitle(contentTitle) if (!preferences.hideNotificationContent().get()) { @@ -133,10 +136,14 @@ class BackupNotifier(private val context: Context) { errorCount: Int, path: String?, file: String?, - contentTitle: String = context.stringResource( - MR.strings.restore_completed, - ), + sync: Boolean, ) { + val contentTitle = if (sync) { + context.stringResource(MR.strings.library_sync_complete) + } else { + context.stringResource(MR.strings.restore_completed) + } + context.cancelNotification(Notifications.ID_RESTORE_PROGRESS) val timeString = context.stringResource( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt deleted file mode 100644 index 35377eb0f..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt +++ /dev/null @@ -1,679 +0,0 @@ -package eu.kanade.tachiyomi.data.backup - -import android.content.Context -import android.net.Uri -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.tachiyomi.data.backup.models.BackupCategory -import eu.kanade.tachiyomi.data.backup.models.BackupHistory -import eu.kanade.tachiyomi.data.backup.models.BackupManga -import eu.kanade.tachiyomi.data.backup.models.BackupPreference -import eu.kanade.tachiyomi.data.backup.models.BackupSource -import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences -import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue -import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.source.model.copyFrom -import eu.kanade.tachiyomi.source.sourcePreferences -import eu.kanade.tachiyomi.util.BackupUtil -import eu.kanade.tachiyomi.util.system.createFileInCacheDir -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.ensureActive -import tachiyomi.core.i18n.stringResource -import tachiyomi.core.preference.AndroidPreferenceStore -import tachiyomi.core.preference.PreferenceStore -import tachiyomi.data.DatabaseHandler -import tachiyomi.data.Manga_sync -import tachiyomi.data.Mangas -import tachiyomi.data.UpdateStrategyColumnAdapter -import tachiyomi.domain.category.interactor.GetCategories -import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId -import tachiyomi.domain.chapter.model.Chapter -import tachiyomi.domain.history.model.HistoryUpdate -import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.manga.interactor.FetchInterval -import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.sync.SyncPreferences -import tachiyomi.domain.track.model.Track -import tachiyomi.i18n.MR -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.io.File -import java.text.SimpleDateFormat -import java.time.ZonedDateTime -import java.util.Date -import java.util.Locale -import kotlin.math.max - -class BackupRestorer( - private val context: Context, - private val notifier: BackupNotifier, -) { - - private val handler: DatabaseHandler = Injekt.get() - private val updateManga: UpdateManga = Injekt.get() - private val getCategories: GetCategories = Injekt.get() - private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get() - private val fetchInterval: FetchInterval = Injekt.get() - - private val preferenceStore: PreferenceStore = Injekt.get() - private val libraryPreferences: LibraryPreferences = Injekt.get() - private val syncPreferences: SyncPreferences = Injekt.get() - - private var now = ZonedDateTime.now() - private var currentFetchWindow = fetchInterval.getWindow(now) - - private var restoreAmount = 0 - private var restoreProgress = 0 - - /** - * Mapping of source ID to source name from backup data - */ - private var sourceMapping: Map = emptyMap() - - private val errors = mutableListOf>() - - suspend fun syncFromBackup(uri: Uri, sync: Boolean) { - val startTime = System.currentTimeMillis() - restoreProgress = 0 - errors.clear() - - performRestore(uri, sync) - - val endTime = System.currentTimeMillis() - val time = endTime - startTime - - val logFile = writeErrorLog() - - if (sync) { - syncPreferences.lastSyncTimestamp().set(Date().time) - } else { - notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) - } - } - - private fun writeErrorLog(): File { - try { - if (errors.isNotEmpty()) { - val file = context.createFileInCacheDir("tachiyomi_restore.txt") - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) - - file.bufferedWriter().use { out -> - errors.forEach { (date, message) -> - out.write("[${sdf.format(date)}] $message\n") - } - } - return file - } - } catch (e: Exception) { - // Empty - } - return File("") - } - - private suspend fun performRestore(uri: Uri, sync: Boolean) { - val backup = BackupUtil.decodeBackup(context, uri) - - restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs - - // Store source mapping for error messages - val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources - sourceMapping = backupMaps.associate { it.sourceId to it.name } - now = ZonedDateTime.now() - currentFetchWindow = fetchInterval.getWindow(now) - - coroutineScope { - ensureActive() - restoreCategories(backup.backupCategories) - - ensureActive() - restoreAppPreferences(backup.backupPreferences) - - ensureActive() - restoreSourcePreferences(backup.backupSourcePreferences) - - // Restore individual manga - backup.backupManga.forEach { - ensureActive() - restoreManga(it, backup.backupCategories, sync) - } - - // TODO: optionally trigger online library + tracker update - } - } - - private suspend fun restoreCategories(backupCategories: List) { - if (backupCategories.isNotEmpty()) { - val dbCategories = getCategories.await() - val dbCategoriesByName = dbCategories.associateBy { it.name } - - val categories = backupCategories.map { - dbCategoriesByName[it.name] - ?: handler.awaitOneExecutable { - categoriesQueries.insert(it.name, it.order, it.flags) - categoriesQueries.selectLastInsertedRowId() - }.let { id -> it.toCategory(id) } - } - - libraryPreferences.categorizedDisplaySettings().set( - (dbCategories + categories) - .distinctBy { it.flags } - .size > 1, - ) - } - - restoreProgress += 1 - showRestoreProgress( - restoreProgress, - restoreAmount, - context.stringResource(MR.strings.categories), - context.stringResource(MR.strings.restoring_backup), - ) - } - - private suspend fun restoreManga(backupManga: BackupManga, backupCategories: List, sync: Boolean) { - val manga = backupManga.getMangaImpl() - val chapters = backupManga.getChaptersImpl() - val categories = backupManga.categories.map { it.toInt() } - val history = - backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead, it.readDuration) } + backupManga.history - val tracks = backupManga.getTrackingImpl() - - try { - val dbManga = getMangaFromDatabase(manga.url, manga.source) - val restoredManga = if (dbManga == null) { - // Manga not in database - restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) - } else { - // Manga in database - // Copy information from manga already in database - val updatedManga = restoreExistingManga(manga, dbManga) - // Fetch rest of manga information - restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories) - } - updateManga.awaitUpdateFetchInterval(restoredManga, now, currentFetchWindow) - } catch (e: Exception) { - val sourceName = sourceMapping[manga.source] ?: manga.source.toString() - errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") - } - - restoreProgress += 1 - if (sync) { - showRestoreProgress( - restoreProgress, - restoreAmount, - manga.title, - context.stringResource(MR.strings.syncing_library), - ) - } else { - showRestoreProgress( - restoreProgress, - restoreAmount, - manga.title, - context.stringResource(MR.strings.restoring_backup), - ) - } - } - - /** - * Returns manga - * - * @return [Manga], null if not found - */ - private suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? { - return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) } - } - - private suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga { - var updatedManga = manga.copy(id = dbManga._id) - updatedManga = updatedManga.copyFrom(dbManga) - updateManga(updatedManga) - return updatedManga - } - - suspend fun updateManga(manga: Manga): Long { - handler.await(true) { - mangasQueries.update( - source = manga.source, - url = manga.url, - artist = manga.artist, - author = manga.author, - description = manga.description, - genre = manga.genre?.joinToString(separator = ", "), - title = manga.title, - status = manga.status, - thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, - lastUpdate = manga.lastUpdate, - nextUpdate = null, - calculateInterval = null, - initialized = manga.initialized, - viewer = manga.viewerFlags, - chapterFlags = manga.chapterFlags, - coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, - mangaId = manga.id, - updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), - ) - } - return manga.id - } - - /** - * Fetches manga information - * - * @param manga manga that needs updating - * @param chapters chapters of manga that needs updating - * @param categories categories that need updating - */ - private suspend fun restoreExistingManga( - manga: Manga, - chapters: List, - categories: List, - history: List, - tracks: List, - backupCategories: List, - ): Manga { - val fetchedManga = restoreNewManga(manga) - restoreChapters(fetchedManga, chapters) - restoreExtras(fetchedManga, categories, history, tracks, backupCategories) - return fetchedManga - } - - private suspend fun restoreChapters(manga: Manga, chapters: List) { - val dbChaptersByUrl = getChaptersByMangaId.await(manga.id) - .associateBy { it.url } - - val processed = chapters.map { chapter -> - var updatedChapter = chapter - - val dbChapter = dbChaptersByUrl[updatedChapter.url] - if (dbChapter != null) { - updatedChapter = updatedChapter - .copyFrom(dbChapter) - .copy( - id = dbChapter.id, - bookmark = updatedChapter.bookmark || dbChapter.bookmark, - // Overwrite read status with the backup's status - read = updatedChapter.read, - ) - // Update lastPageRead if the chapter is marked as read - if (updatedChapter.read) { - updatedChapter = updatedChapter.copy( - lastPageRead = if (updatedChapter.lastPageRead > 0) { - updatedChapter.lastPageRead - } else { - dbChapter.lastPageRead - }, - ) - } - } - - updatedChapter.copy(mangaId = manga.id) - } - - val (existingChapters, newChapters) = processed.partition { it.id > 0 } - updateKnownChapters(existingChapters) - insertChapters(newChapters) - } - - /** - * Inserts list of chapters - */ - private suspend fun insertChapters(chapters: List) { - handler.await(true) { - chapters.forEach { chapter -> - chaptersQueries.insert( - chapter.mangaId, - chapter.url, - chapter.name, - chapter.scanlator, - chapter.read, - chapter.bookmark, - chapter.lastPageRead, - chapter.chapterNumber, - chapter.sourceOrder, - chapter.dateFetch, - chapter.dateUpload, - ) - } - } - } - - /** - * Updates a list of chapters with known database ids - */ - private suspend fun updateKnownChapters(chapters: List) { - handler.await(true) { - chapters.forEach { chapter -> - chaptersQueries.update( - mangaId = null, - url = null, - name = null, - scanlator = null, - read = chapter.read, - bookmark = chapter.bookmark, - lastPageRead = chapter.lastPageRead, - chapterNumber = null, - sourceOrder = null, - dateFetch = null, - dateUpload = null, - chapterId = chapter.id, - ) - } - } - } - - /** - * Fetches manga information - * - * @param manga manga that needs updating - * @return Updated manga info. - */ - private suspend fun restoreNewManga(manga: Manga): Manga { - return manga.copy( - initialized = manga.description != null, - id = insertManga(manga), - ) - } - - /** - * Inserts manga and returns id - * - * @return id of [Manga], null if not found - */ - private suspend fun insertManga(manga: Manga): Long { - return handler.awaitOneExecutable(true) { - mangasQueries.insert( - source = manga.source, - url = manga.url, - artist = manga.artist, - author = manga.author, - description = manga.description, - genre = manga.genre, - title = manga.title, - status = manga.status, - thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, - lastUpdate = manga.lastUpdate, - nextUpdate = 0L, - calculateInterval = 0L, - initialized = manga.initialized, - viewerFlags = manga.viewerFlags, - chapterFlags = manga.chapterFlags, - coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, - updateStrategy = manga.updateStrategy, - ) - mangasQueries.selectLastInsertedRowId() - } - } - - private suspend fun restoreNewManga( - backupManga: Manga, - chapters: List, - categories: List, - history: List, - tracks: List, - backupCategories: List, - ): Manga { - restoreChapters(backupManga, chapters) - restoreExtras(backupManga, categories, history, tracks, backupCategories) - return backupManga - } - - private suspend fun restoreExtras( - manga: Manga, - categories: List, - history: List, - tracks: List, - backupCategories: List, - ) { - restoreCategories(manga, categories, backupCategories) - restoreHistory(history) - restoreTracking(manga, tracks) - } - - /** - * Restores the categories a manga is in. - * - * @param manga the manga whose categories have to be restored. - * @param categories the categories to restore. - */ - private suspend fun restoreCategories(manga: Manga, categories: List, backupCategories: List) { - val dbCategories = getCategories.await() - val mangaCategoriesToUpdate = mutableListOf>() - - categories.forEach { backupCategoryOrder -> - backupCategories.firstOrNull { - it.order == backupCategoryOrder.toLong() - }?.let { backupCategory -> - dbCategories.firstOrNull { dbCategory -> - dbCategory.name == backupCategory.name - }?.let { dbCategory -> - mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id)) - } - } - } - - // Update database - if (mangaCategoriesToUpdate.isNotEmpty()) { - handler.await(true) { - mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) - mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> - mangas_categoriesQueries.insert(mangaId, categoryId) - } - } - } - } - - /** - * Restore history from Json - * - * @param history list containing history to be restored - */ - private suspend fun restoreHistory(history: List) { - // List containing history to be updated - val toUpdate = mutableListOf() - for ((url, lastRead, readDuration) in history) { - var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) } - // Check if history already in database and update - if (dbHistory != null) { - dbHistory = dbHistory.copy( - last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)), - time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read, - ) - toUpdate.add( - HistoryUpdate( - chapterId = dbHistory.chapter_id, - readAt = dbHistory.last_read!!, - sessionReadDuration = dbHistory.time_read, - ), - ) - } else { - // If not in database create - handler - .awaitOneOrNull { chaptersQueries.getChapterByUrl(url) } - ?.let { - toUpdate.add( - HistoryUpdate( - chapterId = it._id, - readAt = Date(lastRead), - sessionReadDuration = readDuration, - ), - ) - } - } - } - handler.await(true) { - toUpdate.forEach { payload -> - historyQueries.upsert( - payload.chapterId, - payload.readAt, - payload.sessionReadDuration, - ) - } - } - } - - /** - * Restores the sync of a manga. - * - * @param manga the manga whose sync have to be restored. - * @param tracks the track list to restore. - */ - private suspend fun restoreTracking(manga: Manga, tracks: List) { - // Get tracks from database - val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) } - val toUpdate = mutableListOf() - val toInsert = mutableListOf() - - tracks - // Fix foreign keys with the current manga id - .map { it.copy(mangaId = manga.id) } - .forEach { track -> - var isInDatabase = false - for (dbTrack in dbTracks) { - if (track.syncId == dbTrack.sync_id) { - // The sync is already in the db, only update its fields - var temp = dbTrack - if (track.remoteId != dbTrack.remote_id) { - temp = temp.copy(remote_id = track.remoteId) - } - if (track.libraryId != dbTrack.library_id) { - temp = temp.copy(library_id = track.libraryId) - } - temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead)) - isInDatabase = true - toUpdate.add(temp) - break - } - } - if (!isInDatabase) { - // Insert new sync. Let the db assign the id - toInsert.add(track.copy(id = 0)) - } - } - - // Update database - if (toUpdate.isNotEmpty()) { - handler.await(true) { - toUpdate.forEach { track -> - manga_syncQueries.update( - track.manga_id, - track.sync_id, - track.remote_id, - track.library_id, - track.title, - track.last_chapter_read, - track.total_chapters, - track.status, - track.score, - track.remote_url, - track.start_date, - track.finish_date, - track._id, - ) - } - } - } - if (toInsert.isNotEmpty()) { - handler.await(true) { - toInsert.forEach { track -> - manga_syncQueries.insert( - track.mangaId, - track.syncId, - track.remoteId, - track.libraryId, - track.title, - track.lastChapterRead, - track.totalChapters, - track.status, - track.score, - track.remoteUrl, - track.startDate, - track.finishDate, - ) - } - } - } - } - - private fun restoreAppPreferences(preferences: List) { - restorePreferences(preferences, preferenceStore) - - LibraryUpdateJob.setupTask(context) - BackupCreateJob.setupTask(context) - - restoreProgress += 1 - showRestoreProgress( - restoreProgress, - restoreAmount, - context.stringResource(MR.strings.app_settings), - context.stringResource(MR.strings.restoring_backup), - ) - } - - private fun restoreSourcePreferences(preferences: List) { - preferences.forEach { - val sourcePrefs = AndroidPreferenceStore(context, sourcePreferences(it.sourceKey)) - restorePreferences(it.prefs, sourcePrefs) - } - - restoreProgress += 1 - showRestoreProgress( - restoreProgress, - restoreAmount, - context.stringResource(MR.strings.source_settings), - context.stringResource(MR.strings.restoring_backup), - ) - } - - private fun restorePreferences( - toRestore: List, - preferenceStore: PreferenceStore, - ) { - val prefs = preferenceStore.getAll() - toRestore.forEach { (key, value) -> - when (value) { - is IntPreferenceValue -> { - if (prefs[key] is Int?) { - preferenceStore.getInt(key).set(value.value) - } - } - is LongPreferenceValue -> { - if (prefs[key] is Long?) { - preferenceStore.getLong(key).set(value.value) - } - } - is FloatPreferenceValue -> { - if (prefs[key] is Float?) { - preferenceStore.getFloat(key).set(value.value) - } - } - is StringPreferenceValue -> { - if (prefs[key] is String?) { - preferenceStore.getString(key).set(value.value) - } - } - is BooleanPreferenceValue -> { - if (prefs[key] is Boolean?) { - preferenceStore.getBoolean(key).set(value.value) - } - } - is StringSetPreferenceValue -> { - if (prefs[key] is Set<*>?) { - preferenceStore.getStringSet(key).set(value.value) - } - } - } - } - } - - private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) { - notifier.showRestoreProgress(title, contentTitle, progress, amount) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateFlags.kt similarity index 90% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateFlags.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateFlags.kt index 7ae6edfde..25c843a0d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateFlags.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateFlags.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.create internal object BackupCreateFlags { const val BACKUP_CATEGORY = 0x1 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt index f314d9c01..4ca6b056b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt @@ -1,7 +1,9 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.create import android.content.Context +import android.content.pm.ServiceInfo import android.net.Uri +import android.os.Build import androidx.core.net.toUri import androidx.work.BackoffPolicy import androidx.work.Constraints @@ -14,6 +16,8 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkerParameters import androidx.work.workDataOf import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.isRunning @@ -68,6 +72,11 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete return ForegroundInfo( Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt index 113cae002..61b8a99c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt @@ -1,14 +1,15 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.create import android.content.Context import android.net.Uri import com.hippo.unifile.UniFile -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags.BACKUP_APP_PREFS -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags.BACKUP_CATEGORY -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags.BACKUP_CHAPTER -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags.BACKUP_HISTORY -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags.BACKUP_SOURCE_PREFS -import eu.kanade.tachiyomi.data.backup.BackupCreateFlags.BACKUP_TRACK +import eu.kanade.tachiyomi.data.backup.BackupFileValidator +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_APP_PREFS +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_CATEGORY +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_CHAPTER +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_HISTORY +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_SOURCE_PREFS +import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupChapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt index 8b47f0d8d..1108a376e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupHistory.kt @@ -2,13 +2,22 @@ package eu.kanade.tachiyomi.data.backup.models import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber +import tachiyomi.domain.history.model.History +import java.util.Date @Serializable data class BackupHistory( @ProtoNumber(1) var url: String, @ProtoNumber(2) var lastRead: Long, @ProtoNumber(3) var readDuration: Long = 0, -) +) { + fun getHistoryImpl(): History { + return History.create().copy( + readAt = Date(lastRead), + readDuration = readDuration, + ) + } +} @Deprecated("Replaced with BackupHistory. This is retained for legacy reasons.") @Serializable @@ -16,4 +25,8 @@ data class BrokenBackupHistory( @ProtoNumber(0) var url: String, @ProtoNumber(1) var lastRead: Long, @ProtoNumber(2) var readDuration: Long = 0, -) +) { + fun toBackupHistory(): BackupHistory { + return BackupHistory(url, lastRead, readDuration) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt index 8dd429c15..003b1ae19 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt @@ -4,9 +4,7 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber -import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.track.model.Track @Suppress("DEPRECATION") @Serializable @@ -63,18 +61,6 @@ data class BackupManga( ) } - fun getChaptersImpl(): List { - return chapters.map { - it.toChapterImpl() - } - } - - fun getTrackingImpl(): List { - return tracking.map { - it.getTrackingImpl() - } - } - companion object { fun copyFrom(manga: Manga): BackupManga { return BackupManga( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupSource.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupSource.kt index 7bf2d0bc3..34e4cac31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupSource.kt @@ -8,7 +8,9 @@ import kotlinx.serialization.protobuf.ProtoNumber data class BrokenBackupSource( @ProtoNumber(0) var name: String = "", @ProtoNumber(1) var sourceId: Long, -) +) { + fun toBackupSource() = BackupSource(name, sourceId) +} @Serializable data class BackupSource( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt index b45b30ca2..35d486492 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt @@ -30,7 +30,7 @@ data class BackupTracking( ) { @Suppress("DEPRECATION") - fun getTrackingImpl(): Track { + fun getTrackImpl(): Track { return Track( id = -1, mangaId = -1, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt similarity index 85% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt index e4595a4b5..9883eb023 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt @@ -1,7 +1,9 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.restore import android.content.Context +import android.content.pm.ServiceInfo import android.net.Uri +import android.os.Build import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy @@ -9,6 +11,7 @@ import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkerParameters import androidx.work.workDataOf +import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.isRunning @@ -28,13 +31,12 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet override suspend fun doWork(): Result { val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() ?: return Result.failure() - val sync = inputData.getBoolean(SYNC_KEY, false) + val isSync = inputData.getBoolean(SYNC_KEY, false) setForegroundSafely() return try { - val restorer = BackupRestorer(context, notifier) - restorer.syncFromBackup(uri, sync) + BackupRestorer(context, notifier, isSync).restore(uri) Result.success() } catch (e: Exception) { if (e is CancellationException) { @@ -54,6 +56,11 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet return ForegroundInfo( Notifications.ID_RESTORE_PROGRESS, notifier.showRestoreProgress().build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt new file mode 100644 index 000000000..a1e262844 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt @@ -0,0 +1,156 @@ +package eu.kanade.tachiyomi.data.backup.restore + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.models.BackupManga +import eu.kanade.tachiyomi.data.backup.models.BackupPreference +import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences +import eu.kanade.tachiyomi.util.BackupUtil +import eu.kanade.tachiyomi.util.system.createFileInCacheDir +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import tachiyomi.core.i18n.stringResource +import tachiyomi.i18n.MR +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class BackupRestorer( + private val context: Context, + private val notifier: BackupNotifier, + private val isSync: Boolean, + + private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), + private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), + private val mangaRestorer: MangaRestorer = MangaRestorer(), +) { + + private var restoreAmount = 0 + private var restoreProgress = 0 + private val errors = mutableListOf>() + + /** + * Mapping of source ID to source name from backup data + */ + private var sourceMapping: Map = emptyMap() + + suspend fun restore(uri: Uri) { + val startTime = System.currentTimeMillis() + + restoreFromFile(uri) + + val time = System.currentTimeMillis() - startTime + + val logFile = writeErrorLog() + + notifier.showRestoreComplete( + time, + errors.size, + logFile.parent, + logFile.name, + isSync, + ) + } + + private suspend fun restoreFromFile(uri: Uri) { + val backup = BackupUtil.decodeBackup(context, uri) + + restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs + + // Store source mapping for error messages + val backupMaps = backup.backupSources + backup.backupBrokenSources.map { it.toBackupSource() } + sourceMapping = backupMaps.associate { it.sourceId to it.name } + + coroutineScope { + restoreCategories(backup.backupCategories) + restoreAppPreferences(backup.backupPreferences) + restoreSourcePreferences(backup.backupSourcePreferences) + restoreManga(backup.backupManga, backup.backupCategories) + + // TODO: optionally trigger online library + tracker update + } + } + + private fun CoroutineScope.restoreCategories(backupCategories: List) = launch { + ensureActive() + categoriesRestorer.restoreCategories(backupCategories) + + restoreProgress += 1 + notifier.showRestoreProgress( + context.stringResource(MR.strings.categories), + restoreProgress, + restoreAmount, + isSync, + ) + } + + private fun CoroutineScope.restoreManga( + backupMangas: List, + backupCategories: List, + ) = launch { + mangaRestorer.sortByNew(backupMangas) + .forEach { + ensureActive() + + try { + mangaRestorer.restoreManga(it, backupCategories) + } catch (e: Exception) { + val sourceName = sourceMapping[it.source] ?: it.source.toString() + errors.add(Date() to "${it.title} [$sourceName]: ${e.message}") + } + + restoreProgress += 1 + notifier.showRestoreProgress(it.title, restoreProgress, restoreAmount, isSync) + } + } + + private fun CoroutineScope.restoreAppPreferences(preferences: List) = launch { + ensureActive() + preferenceRestorer.restoreAppPreferences(preferences) + + restoreProgress += 1 + notifier.showRestoreProgress( + context.stringResource(MR.strings.app_settings), + restoreProgress, + restoreAmount, + isSync, + ) + } + + private fun CoroutineScope.restoreSourcePreferences(preferences: List) = launch { + ensureActive() + preferenceRestorer.restoreSourcePreferences(preferences) + + restoreProgress += 1 + notifier.showRestoreProgress( + context.stringResource(MR.strings.source_settings), + restoreProgress, + restoreAmount, + isSync, + ) + } + + private fun writeErrorLog(): File { + try { + if (errors.isNotEmpty()) { + val file = context.createFileInCacheDir("tachiyomi_restore.txt") + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + file.bufferedWriter().use { out -> + errors.forEach { (date, message) -> + out.write("[${sdf.format(date)}] $message\n") + } + } + return file + } + } catch (e: Exception) { + // Empty + } + return File("") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/CategoriesRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/CategoriesRestorer.kt new file mode 100644 index 000000000..5557bb59f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/CategoriesRestorer.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.data.backup.restore + +import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import tachiyomi.data.DatabaseHandler +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.library.service.LibraryPreferences +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class CategoriesRestorer( + private val handler: DatabaseHandler = Injekt.get(), + private val getCategories: GetCategories = Injekt.get(), + private val libraryPreferences: LibraryPreferences = Injekt.get(), +) { + + suspend fun restoreCategories(backupCategories: List) { + if (backupCategories.isNotEmpty()) { + val dbCategories = getCategories.await() + val dbCategoriesByName = dbCategories.associateBy { it.name } + + val categories = backupCategories.map { + dbCategoriesByName[it.name] + ?: handler.awaitOneExecutable { + categoriesQueries.insert(it.name, it.order, it.flags) + categoriesQueries.selectLastInsertedRowId() + }.let { id -> it.toCategory(id) } + } + + libraryPreferences.categorizedDisplaySettings().set( + (dbCategories + categories) + .distinctBy { it.flags } + .size > 1, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/MangaRestorer.kt new file mode 100644 index 000000000..a07f846f2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/MangaRestorer.kt @@ -0,0 +1,402 @@ +package eu.kanade.tachiyomi.data.backup.restore + +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.models.BackupChapter +import eu.kanade.tachiyomi.data.backup.models.BackupHistory +import eu.kanade.tachiyomi.data.backup.models.BackupManga +import eu.kanade.tachiyomi.data.backup.models.BackupTracking +import tachiyomi.data.DatabaseHandler +import tachiyomi.data.UpdateStrategyColumnAdapter +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId +import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.manga.interactor.FetchInterval +import tachiyomi.domain.manga.interactor.GetMangaByUrlAndSourceId +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.track.interactor.GetTracks +import tachiyomi.domain.track.interactor.InsertTrack +import tachiyomi.domain.track.model.Track +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.time.ZonedDateTime +import java.util.Date +import kotlin.math.max + +class MangaRestorer( + private val handler: DatabaseHandler = Injekt.get(), + private val getCategories: GetCategories = Injekt.get(), + private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId = Injekt.get(), + private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + private val getTracks: GetTracks = Injekt.get(), + private val insertTrack: InsertTrack = Injekt.get(), + fetchInterval: FetchInterval = Injekt.get(), +) { + + private var now = ZonedDateTime.now() + private var currentFetchWindow = fetchInterval.getWindow(now) + + init { + now = ZonedDateTime.now() + currentFetchWindow = fetchInterval.getWindow(now) + } + + suspend fun sortByNew(backupMangas: List): List { + val urlsBySource = handler.awaitList { mangasQueries.getAllMangaSourceAndUrl() } + .groupBy({ it.source }, { it.url }) + + return backupMangas + .sortedWith( + compareBy { it.url in urlsBySource[it.source].orEmpty() } + .then(compareByDescending { it.lastModifiedAt }), + ) + } + + suspend fun restoreManga( + backupManga: BackupManga, + backupCategories: List, + ) { + val dbManga = findExistingManga(backupManga) + val manga = backupManga.getMangaImpl() + val restoredManga = if (dbManga == null) { + restoreNewManga(manga) + } else { + restoreExistingManga(manga, dbManga) + } + + restoreMangaDetails( + manga = restoredManga, + chapters = backupManga.chapters, + categories = backupManga.categories, + backupCategories = backupCategories, + history = backupManga.history + backupManga.brokenHistory.map { it.toBackupHistory() }, + tracks = backupManga.tracking, + ) + } + + private suspend fun findExistingManga(backupManga: BackupManga): Manga? { + return getMangaByUrlAndSourceId.await(backupManga.url, backupManga.source) + } + + private suspend fun restoreExistingManga(manga: Manga, dbManga: Manga): Manga { + return if (manga.lastModifiedAt > dbManga.lastModifiedAt) { + updateManga(dbManga.copyFrom(manga).copy(id = dbManga.id)) + } else { + updateManga(manga.copyFrom(dbManga).copy(id = dbManga.id)) + } + } + + private fun Manga.copyFrom(newer: Manga): Manga { + return this.copy( + favorite = this.favorite || newer.favorite, + author = newer.author, + artist = newer.artist, + description = newer.description, + genre = newer.genre, + thumbnailUrl = newer.thumbnailUrl, + status = newer.status, + initialized = this.initialized || newer.initialized, + ) + } + + private suspend fun updateManga(manga: Manga): Manga { + handler.await(true) { + mangasQueries.update( + source = manga.source, + url = manga.url, + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.genre?.joinToString(separator = ", "), + title = manga.title, + status = manga.status, + thumbnailUrl = manga.thumbnailUrl, + favorite = manga.favorite, + lastUpdate = manga.lastUpdate, + nextUpdate = null, + calculateInterval = null, + initialized = manga.initialized, + viewer = manga.viewerFlags, + chapterFlags = manga.chapterFlags, + coverLastModified = manga.coverLastModified, + dateAdded = manga.dateAdded, + mangaId = manga.id, + updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), + ) + } + return manga + } + + private suspend fun restoreNewManga( + manga: Manga, + ): Manga { + return manga.copy( + initialized = manga.description != null, + id = insertManga(manga), + ) + } + + private suspend fun restoreChapters(manga: Manga, backupChapters: List) { + val dbChaptersByUrl = getChaptersByMangaId.await(manga.id) + .associateBy { it.url } + + val (existingChapters, newChapters) = backupChapters + .mapNotNull { + val chapter = it.toChapterImpl().copy(mangaId = manga.id) + + val dbChapter = dbChaptersByUrl[chapter.url] + ?: // New chapter + return@mapNotNull chapter + + if (chapter.forComparison() == dbChapter.forComparison()) { + // Same state; skip + return@mapNotNull null + } + + // Update to an existing chapter + var updatedChapter = chapter + .copyFrom(dbChapter) + .copy( + id = dbChapter.id, + bookmark = chapter.bookmark || dbChapter.bookmark, + ) + if (dbChapter.read && !updatedChapter.read) { + updatedChapter = updatedChapter.copy( + read = true, + lastPageRead = dbChapter.lastPageRead, + ) + } else if (updatedChapter.lastPageRead == 0L && dbChapter.lastPageRead != 0L) { + updatedChapter = updatedChapter.copy( + lastPageRead = dbChapter.lastPageRead, + ) + } + updatedChapter + } + .partition { it.id > 0 } + + insertNewChapters(newChapters) + updateExistingChapters(existingChapters) + } + + private fun Chapter.forComparison() = + this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L, lastModifiedAt = 0L) + + private suspend fun insertNewChapters(chapters: List) { + handler.await(true) { + chapters.forEach { chapter -> + chaptersQueries.insert( + chapter.mangaId, + chapter.url, + chapter.name, + chapter.scanlator, + chapter.read, + chapter.bookmark, + chapter.lastPageRead, + chapter.chapterNumber, + chapter.sourceOrder, + chapter.dateFetch, + chapter.dateUpload, + ) + } + } + } + + private suspend fun updateExistingChapters(chapters: List) { + handler.await(true) { + chapters.forEach { chapter -> + chaptersQueries.update( + mangaId = null, + url = null, + name = null, + scanlator = null, + read = chapter.read, + bookmark = chapter.bookmark, + lastPageRead = chapter.lastPageRead, + chapterNumber = null, + sourceOrder = null, + dateFetch = null, + dateUpload = null, + chapterId = chapter.id, + ) + } + } + } + + /** + * Inserts manga and returns id + * + * @return id of [Manga], null if not found + */ + private suspend fun insertManga(manga: Manga): Long { + return handler.awaitOneExecutable(true) { + mangasQueries.insert( + source = manga.source, + url = manga.url, + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.genre, + title = manga.title, + status = manga.status, + thumbnailUrl = manga.thumbnailUrl, + favorite = manga.favorite, + lastUpdate = manga.lastUpdate, + nextUpdate = 0L, + calculateInterval = 0L, + initialized = manga.initialized, + viewerFlags = manga.viewerFlags, + chapterFlags = manga.chapterFlags, + coverLastModified = manga.coverLastModified, + dateAdded = manga.dateAdded, + updateStrategy = manga.updateStrategy, + ) + mangasQueries.selectLastInsertedRowId() + } + } + + private suspend fun restoreMangaDetails( + manga: Manga, + chapters: List, + categories: List, + backupCategories: List, + history: List, + tracks: List, + ): Manga { + restoreCategories(manga, categories, backupCategories) + restoreChapters(manga, chapters) + restoreTracking(manga, tracks) + restoreHistory(history) + updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow) + return manga + } + + /** + * Restores the categories a manga is in. + * + * @param manga the manga whose categories have to be restored. + * @param categories the categories to restore. + */ + private suspend fun restoreCategories( + manga: Manga, + categories: List, + backupCategories: List, + ) { + val dbCategories = getCategories.await() + val dbCategoriesByName = dbCategories.associateBy { it.name } + + val backupCategoriesByOrder = backupCategories.associateBy { it.order } + + val mangaCategoriesToUpdate = categories.mapNotNull { backupCategoryOrder -> + backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory -> + dbCategoriesByName[backupCategory.name]?.let { dbCategory -> + Pair(manga.id, dbCategory.id) + } + } + } + + if (mangaCategoriesToUpdate.isNotEmpty()) { + handler.await(true) { + mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) + mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> + mangas_categoriesQueries.insert(mangaId, categoryId) + } + } + } + } + + private suspend fun restoreHistory(backupHistory: List) { + val toUpdate = backupHistory.mapNotNull { history -> + val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(history.url) } + val item = history.getHistoryImpl() + + if (dbHistory == null) { + val chapter = handler.awaitOneOrNull { chaptersQueries.getChapterByUrl(history.url) } + return@mapNotNull if (chapter == null) { + // Chapter doesn't exist; skip + null + } else { + // New history entry + item.copy(chapterId = chapter._id) + } + } + + // Update history entry + item.copy( + id = dbHistory._id, + chapterId = dbHistory.chapter_id, + readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L) + .takeIf { it > 0L } + ?.let { Date(it) }, + readDuration = max(item.readDuration, dbHistory.time_read), + ) + } + + if (toUpdate.isNotEmpty()) { + handler.await(true) { + toUpdate.forEach { + historyQueries.upsert( + it.chapterId, + it.readAt, + it.readDuration, + ) + } + } + } + } + + private suspend fun restoreTracking(manga: Manga, backupTracks: List) { + val dbTrackBySyncId = getTracks.await(manga.id).associateBy { it.syncId } + + val (existingTracks, newTracks) = backupTracks + .mapNotNull { + val track = it.getTrackImpl() + val dbTrack = dbTrackBySyncId[track.syncId] + ?: // New track + return@mapNotNull track.copy( + id = 0, // Let DB assign new ID + mangaId = manga.id, + ) + + if (track.forComparison() == dbTrack.forComparison()) { + // Same state; skip + return@mapNotNull null + } + + // Update to an existing track + dbTrack.copy( + remoteId = track.remoteId, + libraryId = track.libraryId, + lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead), + ) + } + .partition { it.id > 0 } + + if (newTracks.isNotEmpty()) { + insertTrack.awaitAll(newTracks) + } + if (existingTracks.isNotEmpty()) { + handler.await(true) { + existingTracks.forEach { track -> + manga_syncQueries.update( + track.mangaId, + track.syncId, + track.remoteId, + track.libraryId, + track.title, + track.lastChapterRead, + track.totalChapters, + track.status, + track.score, + track.remoteUrl, + track.startDate, + track.finishDate, + track.id, + ) + } + } + } + } + + private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/PreferenceRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/PreferenceRestorer.kt new file mode 100644 index 000000000..69622d60b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/PreferenceRestorer.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.data.backup.restore + +import android.content.Context +import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob +import eu.kanade.tachiyomi.data.backup.models.BackupPreference +import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences +import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.source.sourcePreferences +import tachiyomi.core.preference.AndroidPreferenceStore +import tachiyomi.core.preference.PreferenceStore +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class PreferenceRestorer( + private val context: Context, + private val preferenceStore: PreferenceStore = Injekt.get(), +) { + + fun restoreAppPreferences(preferences: List) { + restorePreferences(preferences, preferenceStore) + + LibraryUpdateJob.setupTask(context) + BackupCreateJob.setupTask(context) + } + + fun restoreSourcePreferences(preferences: List) { + preferences.forEach { + val sourcePrefs = AndroidPreferenceStore(context, sourcePreferences(it.sourceKey)) + restorePreferences(it.prefs, sourcePrefs) + } + } + + private fun restorePreferences( + toRestore: List, + preferenceStore: PreferenceStore, + ) { + val prefs = preferenceStore.getAll() + toRestore.forEach { (key, value) -> + when (value) { + is IntPreferenceValue -> { + if (prefs[key] is Int?) { + preferenceStore.getInt(key).set(value.value) + } + } + is LongPreferenceValue -> { + if (prefs[key] is Long?) { + preferenceStore.getLong(key).set(value.value) + } + } + is FloatPreferenceValue -> { + if (prefs[key] is Float?) { + preferenceStore.getFloat(key).set(value.value) + } + } + is StringPreferenceValue -> { + if (prefs[key] is String?) { + preferenceStore.getString(key).set(value.value) + } + } + is BooleanPreferenceValue -> { + if (prefs[key] is Boolean?) { + preferenceStore.getBoolean(key).set(value.value) + } + } + is StringSetPreferenceValue -> { + if (prefs[key] is Set<*>?) { + preferenceStore.getStringSet(key).set(value.value) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 17930a89e..604745279 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -48,7 +47,7 @@ import tachiyomi.core.util.system.logcat import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager -import tachiyomi.domain.storage.service.StoragePreferences +import tachiyomi.domain.storage.service.StorageManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -66,7 +65,7 @@ class DownloadCache( private val provider: DownloadProvider = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(), - storagePreferences: StoragePreferences = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), ) { private val scope = CoroutineScope(Dispatchers.IO) @@ -74,7 +73,7 @@ class DownloadCache( private val _changes: Channel = Channel(Channel.UNLIMITED) val changes = _changes.receiveAsFlow() .onStart { emit(Unit) } - .shareIn(scope, SharingStarted.Eagerly, 1) + .shareIn(scope, SharingStarted.Lazily, 1) /** * The interval after which this cache should be invalidated. 1 hour shouldn't cause major @@ -94,10 +93,10 @@ class DownloadCache( .stateIn(scope, SharingStarted.WhileSubscribed(), false) private val diskCacheFile: File - get() = File(context.cacheDir, "dl_index_cache_v2") + get() = File(context.cacheDir, "dl_index_cache_v3") private val rootDownloadsDirLock = Mutex() - private var rootDownloadsDir = RootDirectory(provider.downloadsDir) + private var rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory()) init { // Attempt to read cache file @@ -115,12 +114,8 @@ class DownloadCache( } } - storagePreferences.baseStorageDirectory().changes() - .drop(1) - .onEach { - rootDownloadsDir = RootDirectory(provider.downloadsDir) - invalidateCache() - } + storageManager.changes + .onEach { invalidateCache() } .launchIn(scope) } @@ -294,6 +289,8 @@ class DownloadCache( fun invalidateCache() { lastRenew = 0L renewalJob?.cancel() + diskCacheFile.delete() + renewCache() } /** @@ -310,23 +307,26 @@ class DownloadCache( _isInitializing.emit(true) } - var sources = getSources() - // Try to wait until extensions and sources have loaded - withTimeoutOrNull(30.seconds) { - while (!extensionManager.isInitialized) { - delay(2.seconds) - } + var sources = getSources() + if (sources.isEmpty()) { + withTimeoutOrNull(30.seconds) { + while (!extensionManager.isInitialized) { + delay(2.seconds) + } - while (sources.isEmpty()) { - delay(2.seconds) - sources = getSources() + while (extensionManager.availableExtensionsFlow.value.isNotEmpty() && sources.isEmpty()) { + delay(2.seconds) + sources = getSources() + } } } val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } rootDownloadsDirLock.withLock { + rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory()) + val sourceDirs = rootDownloadsDir.dir?.listFiles().orEmpty() .filter { it.isDirectory && !it.name.isNullOrBlank() } .mapNotNull { dir -> @@ -371,10 +371,9 @@ class DownloadCache( }.also { it.invokeOnCompletion(onCancelling = true) { exception -> if (exception != null && exception !is CancellationException) { - logcat(LogPriority.ERROR, exception) { "Failed to create download cache" } + logcat(LogPriority.ERROR, exception) { "DownloadCache: failed to create cache" } } lastRenew = System.currentTimeMillis() - notifyChanges() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt index 93826afe0..6a7b4469e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt @@ -13,13 +13,17 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.system.isConnectedToWifi -import eu.kanade.tachiyomi.util.system.isOnline +import eu.kanade.tachiyomi.util.system.NetworkState +import eu.kanade.tachiyomi.util.system.activeNetworkState +import eu.kanade.tachiyomi.util.system.networkStateFlow import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.setForegroundSafely -import kotlinx.coroutines.delay +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import tachiyomi.domain.download.service.DownloadPreferences import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -50,7 +54,11 @@ class DownloadJob(context: Context, workerParams: WorkerParameters) : CoroutineW } override suspend fun doWork(): Result { - var active = checkConnectivity() && downloadManager.downloaderStart() + var networkCheck = checkNetworkState( + applicationContext.activeNetworkState(), + downloadPreferences.downloadOnlyOverWifi().get(), + ) + var active = networkCheck && downloadManager.downloaderStart() if (!active) { return Result.failure() @@ -58,29 +66,36 @@ class DownloadJob(context: Context, workerParams: WorkerParameters) : CoroutineW setForegroundSafely() + coroutineScope { + combineTransform( + applicationContext.networkStateFlow(), + downloadPreferences.downloadOnlyOverWifi().changes(), + transform = { a, b -> emit(checkNetworkState(a, b)) }, + ) + .onEach { networkCheck = it } + .launchIn(this) + } + // Keep the worker running when needed while (active) { - delay(100) - active = !isStopped && downloadManager.isRunning && checkConnectivity() + active = !isStopped && downloadManager.isRunning && networkCheck } return Result.success() } - private fun checkConnectivity(): Boolean { - return with(applicationContext) { - if (isOnline()) { - val noWifi = downloadPreferences.downloadOnlyOverWifi().get() && !isConnectedToWifi() - if (noWifi) { - downloadManager.downloaderStop( - applicationContext.getString(R.string.download_notifier_text_only_wifi), - ) - } - !noWifi - } else { - downloadManager.downloaderStop(applicationContext.getString(R.string.download_notifier_no_network)) - false + private fun checkNetworkState(state: NetworkState, requireWifi: Boolean): Boolean { + return if (state.isOnline) { + val noWifi = requireWifi && !state.isWifi + if (noWifi) { + downloadManager.downloaderStop( + applicationContext.getString(R.string.download_notifier_text_only_wifi), + ) } + !noWifi + } else { + downloadManager.downloaderStop(applicationContext.getString(R.string.download_notifier_no_network)) + false } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 8ea22b9ba..12e8b96c6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -7,7 +7,7 @@ import android.content.Intent import android.net.Uri import android.os.Build import androidx.core.net.toUri -import eu.kanade.tachiyomi.data.backup.BackupRestoreJob +import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.sync.SyncDataJob diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt index a242485c4..073f2bb0d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.data.updater import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy @@ -55,6 +57,11 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar return ForegroundInfo( Notifications.ID_APP_UPDATER, notifier.onDownloadStarted().build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt index dbc1fa50f..222ff02d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt @@ -9,6 +9,7 @@ import android.content.IntentFilter import android.content.pm.PackageInstaller import android.os.Build import androidx.core.content.ContextCompat +import androidx.core.content.IntentSanitizer import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat @@ -25,6 +26,20 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { val userAction = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT) + ?.run { + // Doesn't actually needed as the receiver is actually not exported + // But the warnings can't be suppressed without this + IntentSanitizer.Builder() + .allowAction(this.action!!) + .allowExtra(PackageInstaller.EXTRA_SESSION_ID) { id -> id == activeSession?.second } + .allowAnyComponent() + .allowPackage { + // There is no way to check the actual installer name so allow all. + true + } + .build() + .sanitizeByFiltering(this) + } if (userAction == null) { logcat(LogPriority.ERROR) { "Fatal error for $intent" } continueQueue(InstallStep.Error) @@ -71,13 +86,13 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic val intentSender = PendingIntent.getBroadcast( service, activeSession!!.second, - Intent(INSTALL_ACTION), + Intent(INSTALL_ACTION).setPackage(service.packageName), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, ).intentSender session.commit(intentSender) } } catch (e: Exception) { - logcat(LogPriority.ERROR) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } + logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } activeSession?.let { (_, sessionId) -> packageInstaller.abandonSession(sessionId) } @@ -105,7 +120,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic service, packageActionReceiver, IntentFilter(INSTALL_ACTION), - ContextCompat.RECEIVER_EXPORTED, + ContextCompat.RECEIVER_NOT_EXPORTED, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaExtensions.kt deleted file mode 100644 index 812c6110f..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaExtensions.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eu.kanade.tachiyomi.source.model - -import tachiyomi.data.Mangas -import tachiyomi.domain.manga.model.Manga - -fun Manga.copyFrom(other: Mangas): Manga { - var manga = this - other.author?.let { manga = manga.copy(author = it) } - other.artist?.let { manga = manga.copy(artist = it) } - other.description?.let { manga = manga.copy(description = it) } - other.genre?.let { manga = manga.copy(genre = it) } - other.thumbnail_url?.let { manga = manga.copy(thumbnailUrl = it) } - manga = manga.copy(status = other.status) - if (!initialized) { - manga = manga.copy(initialized = other.initialized) - } - return manga -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt index b4c1321c0..3eac8accf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt @@ -65,8 +65,7 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() { .fillMaxSize() .padding(contentPadding), ) { - val fragment = SourcePreferencesFragment.getInstance(sourceId) - add(it, fragment, null) + add(it, SourcePreferencesFragment.getInstance(sourceId), null) } } } @@ -127,26 +126,28 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { private fun populateScreen(): PreferenceScreen { val sourceId = requireArguments().getLong(SOURCE_ID) - val source = Injekt.get().get(sourceId)!! as ConfigurableSource - - val dataStore = SharedPreferencesDataStore(source.sourcePreferences()) - preferenceManager.preferenceDataStore = dataStore - + val source = Injekt.get().getOrStub(sourceId) val sourceScreen = preferenceManager.createPreferenceScreen(requireContext()) - source.setupPreferenceScreen(sourceScreen) - sourceScreen.forEach { pref -> - pref.isIconSpaceReserved = false - pref.isSingleLineTitle = false - if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) { - pref.dialogTitle = pref.title - } - // Apply incognito IME for EditTextPreference - if (pref is EditTextPreference) { - val setListener = pref.getOnBindEditTextListener() - pref.setOnBindEditTextListener { - setListener?.onBindEditText(it) - it.setIncognito(lifecycleScope) + if (source is ConfigurableSource) { + val dataStore = SharedPreferencesDataStore(source.sourcePreferences()) + preferenceManager.preferenceDataStore = dataStore + + source.setupPreferenceScreen(sourceScreen) + sourceScreen.forEach { pref -> + pref.isIconSpaceReserved = false + pref.isSingleLineTitle = false + if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) { + pref.dialogTitle = pref.title + } + + // Apply incognito IME for EditTextPreference + if (pref is EditTextPreference) { + val setListener = pref.getOnBindEditTextListener() + pref.setOnBindEditTextListener { + setListener?.onBindEditText(it) + it.setIncognito(lifecycleScope) + } } } } @@ -158,9 +159,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { private const val SOURCE_ID = "source_id" fun getInstance(sourceId: Long): SourcePreferencesFragment { - val fragment = SourcePreferencesFragment() - fragment.arguments = bundleOf(SOURCE_ID to sourceId) - return fragment + return SourcePreferencesFragment().apply { + arguments = bundleOf(SOURCE_ID to sourceId) + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkScreenModel.kt index 51a39ea0a..e21821430 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkScreenModel.kt @@ -74,7 +74,7 @@ class DeepLinkScreenModel( } private suspend fun getMangaFromSManga(sManga: SManga, sourceId: Long): Manga { - return getMangaByUrlAndSourceId.awaitManga(sManga.url, sourceId) + return getMangaByUrlAndSourceId.await(sManga.url, sourceId) ?: networkToLocalManga.await(sManga.toDomainManga(sourceId)) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index 561f34df3..ff2cb7075 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRailItem @@ -277,7 +278,12 @@ object HomeScreen : Screen() { } }, ) { - Icon(painter = tab.options.icon!!, contentDescription = tab.options.title) + Icon( + painter = tab.options.icon!!, + contentDescription = tab.options.title, + // TODO: https://issuetracker.google.com/u/0/issues/316327367 + tint = LocalContentColor.current, + ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index d6750551b..78c688c2e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -349,7 +349,7 @@ class MainActivity : BaseActivity() { val navigator = LocalNavigator.currentOrThrow LaunchedEffect(Unit) { - if (!preferences.shownOnboardingFlow().get()) { + if (!preferences.shownOnboardingFlow().get() && navigator.lastItem !is OnboardingScreen) { navigator.push(OnboardingScreen()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 7b9b18ed6..53bae43d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -132,7 +132,7 @@ class MangaScreen( ) }.takeIf { isHttpSource }, onTrackingClicked = { - if (successState.trackingCount == 0) { + if (screenModel.loggedInTrackers.isEmpty()) { navigator.push(SettingsScreen(SettingsScreen.Destination.Tracking)) } else { screenModel.showTrackDialog() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index cc38de779..a6541fe30 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -118,7 +118,7 @@ class MangaScreenModel( private val successState: State.Success? get() = state.value as? State.Success - private val loggedInTrackers by lazy { trackerManager.trackers.filter { it.isLoggedIn } } + val loggedInTrackers by lazy { trackerManager.trackers.filter { it.isLoggedIn } } val manga: Manga? get() = successState?.manga @@ -636,18 +636,18 @@ class MangaScreenModel( ) { val successState = successState ?: return - if (startNow) { - val chapterId = chapters.singleOrNull()?.id ?: return - downloadManager.startDownloadNow(chapterId) - } else { - downloadChapters(chapters) - } - - if (!isFavorited && !successState.hasPromptedToAddBefore) { - updateSuccessState { state -> - state.copy(hasPromptedToAddBefore = true) + screenModelScope.launchNonCancellable { + if (startNow) { + val chapterId = chapters.singleOrNull()?.id ?: return@launchNonCancellable + downloadManager.startDownloadNow(chapterId) + } else { + downloadChapters(chapters) } - screenModelScope.launch { + + if (!isFavorited && !successState.hasPromptedToAddBefore) { + updateSuccessState { state -> + state.copy(hasPromptedToAddBefore = true) + } val result = snackbarHostState.showSnackbar( message = context.stringResource(MR.strings.snack_add_to_library), actionLabel = context.stringResource(MR.strings.action_add), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt index 72e091954..bc211445e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt @@ -1,15 +1,16 @@ package eu.kanade.tachiyomi.ui.more +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.base.BasePreferences -import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.more.onboarding.OnboardingScreen import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.setting.SettingsScreen -import tachiyomi.domain.storage.service.StoragePreferences +import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -20,18 +21,22 @@ class OnboardingScreen : Screen() { val navigator = LocalNavigator.currentOrThrow val basePreferences = remember { Injekt.get() } - val storagePreferences = remember { Injekt.get() } - val uiPreferences = remember { Injekt.get() } + val shownOnboardingFlow by basePreferences.shownOnboardingFlow().collectAsState() - val finishOnboarding = { + val finishOnboarding: () -> Unit = { basePreferences.shownOnboardingFlow().set(true) navigator.pop() } + BackHandler( + enabled = !shownOnboardingFlow, + onBack = { + // Prevent exiting if onboarding hasn't been completed + }, + ) + OnboardingScreen( - storagePreferences = storagePreferences, - uiPreferences = uiPreferences, - onComplete = { finishOnboarding() }, + onComplete = finishOnboarding, onRestoreBackup = { finishOnboarding() navigator.push(SettingsScreen(SettingsScreen.Destination.DataAndStorage)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt index e67d7cd2f..05401603f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.util import android.content.Context import android.net.Uri -import eu.kanade.tachiyomi.data.backup.BackupCreator +import eu.kanade.tachiyomi.data.backup.create.BackupCreator import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.BackupSerializer import okio.buffer diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/NetworkStateTracker.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/NetworkStateTracker.kt new file mode 100644 index 000000000..a4a686541 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/NetworkStateTracker.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.util.system + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow + +data class NetworkState( + val isConnected: Boolean, + val isValidated: Boolean, + val isWifi: Boolean, +) { + val isOnline = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + isConnected && isValidated + } else { + isConnected + } +} + +@Suppress("DEPRECATION") +fun Context.activeNetworkState(): NetworkState { + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + return NetworkState( + isConnected = connectivityManager.activeNetworkInfo?.isConnected ?: false, + isValidated = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) ?: false, + isWifi = wifiManager.isWifiEnabled && capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false, + ) +} + +@Suppress("DEPRECATION") +fun Context.networkStateFlow() = callbackFlow { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val networkCallback = object : NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + trySend(activeNetworkState()) + } + override fun onLost(network: Network) { + trySend(activeNetworkState()) + } + } + + connectivityManager.registerDefaultNetworkCallback(networkCallback) + awaitClose { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + } else { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ConnectivityManager.CONNECTIVITY_ACTION) { + trySend(activeNetworkState()) + } + } + } + + registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)) + awaitClose { + unregisterReceiver(receiver) + } + } +} diff --git a/buildSrc/src/main/kotlin/AndroidConfig.kt b/buildSrc/src/main/kotlin/AndroidConfig.kt index 2127d0657..8a43b8c61 100644 --- a/buildSrc/src/main/kotlin/AndroidConfig.kt +++ b/buildSrc/src/main/kotlin/AndroidConfig.kt @@ -1,6 +1,6 @@ object AndroidConfig { const val compileSdk = 34 const val minSdk = 23 - const val targetSdk = 30 - const val ndk = "22.1.7171670" + const val targetSdk = 34 + const val ndk = "26.1.10909125" } diff --git a/data/src/main/java/tachiyomi/data/track/TrackMapper.kt b/data/src/main/java/tachiyomi/data/track/TrackMapper.kt new file mode 100644 index 000000000..8ac852731 --- /dev/null +++ b/data/src/main/java/tachiyomi/data/track/TrackMapper.kt @@ -0,0 +1,35 @@ +package tachiyomi.data.track + +import tachiyomi.domain.track.model.Track + +object TrackMapper { + fun mapTrack( + id: Long, + mangaId: Long, + syncId: Long, + remoteId: Long, + libraryId: Long?, + title: String, + lastChapterRead: Double, + totalChapters: Long, + status: Long, + score: Double, + remoteUrl: String, + startDate: Long, + finishDate: Long, + ): Track = Track( + id = id, + mangaId = mangaId, + syncId = syncId, + remoteId = remoteId, + libraryId = libraryId, + title = title, + lastChapterRead = lastChapterRead, + totalChapters = totalChapters, + status = status, + score = score, + remoteUrl = remoteUrl, + startDate = startDate, + finishDate = finishDate, + ) +} diff --git a/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt index 1966a013b..19a3daa02 100644 --- a/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt @@ -10,24 +10,24 @@ class TrackRepositoryImpl( ) : TrackRepository { override suspend fun getTrackById(id: Long): Track? { - return handler.awaitOneOrNull { manga_syncQueries.getTrackById(id, ::mapTrack) } + return handler.awaitOneOrNull { manga_syncQueries.getTrackById(id, TrackMapper::mapTrack) } } override suspend fun getTracksByMangaId(mangaId: Long): List { return handler.awaitList { - manga_syncQueries.getTracksByMangaId(mangaId, ::mapTrack) + manga_syncQueries.getTracksByMangaId(mangaId, TrackMapper::mapTrack) } } override fun getTracksAsFlow(): Flow> { return handler.subscribeToList { - manga_syncQueries.getTracks(::mapTrack) + manga_syncQueries.getTracks(TrackMapper::mapTrack) } } override fun getTracksByMangaIdAsFlow(mangaId: Long): Flow> { return handler.subscribeToList { - manga_syncQueries.getTracksByMangaId(mangaId, ::mapTrack) + manga_syncQueries.getTracksByMangaId(mangaId, TrackMapper::mapTrack) } } @@ -68,34 +68,4 @@ class TrackRepositoryImpl( } } } - - private fun mapTrack( - id: Long, - mangaId: Long, - syncId: Long, - remoteId: Long, - libraryId: Long?, - title: String, - lastChapterRead: Double, - totalChapters: Long, - status: Long, - score: Double, - remoteUrl: String, - startDate: Long, - finishDate: Long, - ): Track = Track( - id = id, - mangaId = mangaId, - syncId = syncId, - remoteId = remoteId, - libraryId = libraryId, - title = title, - lastChapterRead = lastChapterRead, - totalChapters = totalChapters, - status = status, - score = score, - remoteUrl = remoteUrl, - startDate = startDate, - finishDate = finishDate, - ) } diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas.sq b/data/src/main/sqldelight/tachiyomi/data/mangas.sq index 220b908ad..d944ab0b1 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas.sq @@ -70,6 +70,10 @@ getAllManga: SELECT * FROM mangas; +getAllMangaSourceAndUrl: +SELECT source, url +FROM mangas; + getMangasWithFavoriteTimestamp: SELECT * FROM mangas diff --git a/domain/src/main/java/tachiyomi/domain/history/model/History.kt b/domain/src/main/java/tachiyomi/domain/history/model/History.kt index bb2917ad9..41b58b637 100644 --- a/domain/src/main/java/tachiyomi/domain/history/model/History.kt +++ b/domain/src/main/java/tachiyomi/domain/history/model/History.kt @@ -7,4 +7,13 @@ data class History( val chapterId: Long, val readAt: Date?, val readDuration: Long, -) +) { + companion object { + fun create() = History( + id = -1L, + chapterId = -1L, + readAt = null, + readDuration = -1L, + ) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaByUrlAndSourceId.kt b/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaByUrlAndSourceId.kt index 507000d82..c245a7da0 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaByUrlAndSourceId.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaByUrlAndSourceId.kt @@ -6,7 +6,7 @@ import tachiyomi.domain.manga.repository.MangaRepository class GetMangaByUrlAndSourceId( private val mangaRepository: MangaRepository, ) { - suspend fun awaitManga(url: String, sourceId: Long): Manga? { + suspend fun await(url: String, sourceId: Long): Manga? { return mangaRepository.getMangaByUrlAndSourceId(url, sourceId) } } diff --git a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt index a1fff4269..4527fe89a 100644 --- a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt @@ -6,8 +6,14 @@ import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.util.storage.DiskUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn class StorageManager( private val context: Context, @@ -16,24 +22,33 @@ class StorageManager( private val scope = CoroutineScope(Dispatchers.IO) - private var baseDir: UniFile? = storagePreferences.baseStorageDirectory().get().let(::getBaseDir) + private var baseDir: UniFile? = getBaseDir(storagePreferences.baseStorageDirectory().get()) + + private val _changes: Channel = Channel(Channel.UNLIMITED) + val changes = _changes.receiveAsFlow() + .shareIn(scope, SharingStarted.Lazily, 1) init { storagePreferences.baseStorageDirectory().changes() - .onEach { baseDir = getBaseDir(it) } + .drop(1) + .distinctUntilChanged() + .onEach { uri -> + baseDir = getBaseDir(uri) + baseDir?.let { parent -> + parent.createDirectory(AUTOMATIC_BACKUPS_PATH) + parent.createDirectory(LOCAL_SOURCE_PATH) + parent.createDirectory(DOWNLOADS_PATH).also { + DiskUtil.createNoMediaFile(it, context) + } + } + _changes.send(Unit) + } .launchIn(scope) } - private fun getBaseDir(path: String): UniFile? { - val file = UniFile.fromUri(context, path.toUri()) - - return file.takeIf { it?.exists() == true }?.also { parent -> - parent.createDirectory(AUTOMATIC_BACKUPS_PATH) - parent.createDirectory(LOCAL_SOURCE_PATH) - parent.createDirectory(DOWNLOADS_PATH).also { - DiskUtil.createNoMediaFile(it, context) - } - } + private fun getBaseDir(uri: String): UniFile? { + return UniFile.fromUri(context, uri.toUri()) + .takeIf { it?.exists() == true } } fun getAutomaticBackupsDirectory(): UniFile? { diff --git a/gradle.properties b/gradle.properties index 282f16ede..00f048f04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,5 +23,4 @@ org.gradle.caching=true kotlin.mpp.androidSourceSetLayoutVersion=2 android.useAndroidX=true -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false \ No newline at end of file diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 09c6b0e77..52dfafde6 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -6,7 +6,7 @@ paging_version = "3.2.1" [libraries] gradle = { module = "com.android.tools.build:gradle", version.ref = "agp_version" } -annotation = "androidx.annotation:annotation:1.7.0" +annotation = "androidx.annotation:annotation:1.7.1" appcompat = "androidx.appcompat:appcompat:1.6.1" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" @@ -28,7 +28,7 @@ paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pag benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.2" test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha02" test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha02" -test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha05" +test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-beta01" [bundles] lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"] diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index 24a348d34..1fbf9f375 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -1,10 +1,10 @@ [versions] compiler = "1.5.6" -compose-bom = "2023.12.00-alpha03" +compose-bom = "2023.12.00-alpha04" accompanist = "0.33.2-alpha" [libraries] -activity = "androidx.activity:activity-compose:1.8.1" +activity = "androidx.activity:activity-compose:1.8.2" bom = { group = "dev.chrisbanes.compose", name = "compose-bom", version.ref = "compose-bom" } foundation = { module = "androidx.compose.foundation:foundation" } animation = { module = "androidx.compose.animation:animation" } diff --git a/gradle/kotlinx.versions.toml b/gradle/kotlinx.versions.toml index 913b5c012..8ff4e1c60 100644 --- a/gradle/kotlinx.versions.toml +++ b/gradle/kotlinx.versions.toml @@ -1,13 +1,13 @@ [versions] kotlin_version = "1.9.21" serialization_version = "1.6.2" -xml_serialization_version = "0.86.2" +xml_serialization_version = "0.86.3" [libraries] reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" } gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" } -immutables = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.6" } +immutables = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.7" } coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version = "1.7.3" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cf81b2d6..c5a2e9b6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ aboutlib_version = "10.9.2" leakcanary = "2.12" moko = "0.23.0" -okhttp_version = "5.0.0-alpha.11" +okhttp_version = "5.0.0-alpha.12" richtext = "0.17.0" shizuku_version = "12.2.0" sqldelight = "2.0.0" @@ -20,7 +20,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.6.0" +okio = "com.squareup.okio:okio:3.7.0" conscrypt-android = "org.conscrypt:conscrypt-android:2.5.2" @@ -53,7 +53,7 @@ natural-comparator = "com.github.gpanther:java-nat-sort:natural-comparator-1.1" richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" } richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" } -material = "com.google.android.material:material:1.10.0" +material = "com.google.android.material:material:1.11.0" flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533" photoview = "com.github.chrisbanes:PhotoView:2.3.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" @@ -94,7 +94,7 @@ voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", vers voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } -ktlint = "org.jlleitschuh.gradle:ktlint-gradle:12.0.2" +ktlint = "org.jlleitschuh.gradle:ktlint-gradle:12.0.3" google-api-services-drive = "com.google.apis:google-api-services-drive:v3-rev197-1.25.0" google-api-client-oauth = "com.google.oauth-client:google-oauth-client:1.34.1" diff --git a/i18n/src/commonMain/resources/MR/ar/strings.xml b/i18n/src/commonMain/resources/MR/ar/strings.xml index 1d5de06b5..db2cf8d70 100644 --- a/i18n/src/commonMain/resources/MR/ar/strings.xml +++ b/i18n/src/commonMain/resources/MR/ar/strings.xml @@ -306,9 +306,9 @@ الأمان و الخصوصية أدر الإشعارات صيغة التاريخ - اتبع مظهر النظام - مفعّل - غير مفعّل + النظام + داكن + فاتح تم إلغاء وضع تحسين البطارية مُسبقاً يساعد في عملية تحديث المكتبة والنسخ الإحتياطي في الخلفية إطفاء وضع تحسين البطارية @@ -768,4 +768,20 @@ اصعد مكان التخزين يُستخدَم في الاحتياط وتنزيل الفصول والمصدر المحليِّ. + حدِّد مجلَّدًا + دليل البدء + أحديث العهد بـ%s؟ طالع دليل البدء. + ابدأ + لا بد من تحديد مجلَّد + أهلًا وسهلًا! + أستخدمتَ %s قبلًا؟ + تخطَّ + التالي + أول أمرنا أن نضبط بعض الأمور، ولك أن تغيرها في الإعدادات لاحقًا. + لم يُعيَّن موضع للتخزين + حدِّد مجلَّدًا يُخزِّن فيه %1$s الفصول المنزَّلة والاحتياطات وغيرها. +\n +\nوالأحسن أن يكون المجلَّد مخصوصًا لذلك. +\n +\nالمجلَّد المحدَّد: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 7c6255941..3bfd511e8 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -184,6 +184,15 @@ Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s Select a folder A folder must be selected + Required + Optional + Install apps permission + To install source extensions. + Notification permission + Get notified for library updates and more. + Background battery usage + Avoid interruptions to long-running library updates, downloads, and backup restores. + Grant New to %s? We recommend checking out the getting started guide. Already used %s before? diff --git a/i18n/src/commonMain/resources/MR/bn/plurals.xml b/i18n/src/commonMain/resources/MR/bn/plurals.xml index 77fb973bc..f4c94162d 100644 --- a/i18n/src/commonMain/resources/MR/bn/plurals.xml +++ b/i18n/src/commonMain/resources/MR/bn/plurals.xml @@ -52,4 +52,16 @@ পরবর্তী অপঠিত অধ্যায় পরবর্তী %d টি অপঠিত অধ্যায় + + পরের %d চ্যাপ্টার + পরের %d চ্যাপ্টার + + + %1$s টি চ্যাপ্টার নেই + %1$s টি চ্যাপ্টার নেই + + + %d দিন + %d দিন + \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/bn/strings.xml b/i18n/src/commonMain/resources/MR/bn/strings.xml index 91160e927..1704a45b4 100644 --- a/i18n/src/commonMain/resources/MR/bn/strings.xml +++ b/i18n/src/commonMain/resources/MR/bn/strings.xml @@ -615,4 +615,14 @@ এখন না ডিবাগ তথ্য বাদ দেওয়া হয়েছে কারণ সিরিজের আপডেটের প্রয়োজন নেই৷ + আনলক %s + সেট ইন্টারভেল + ডাউনলোড করা ফাইল ডিলেট করুন + আর অপশন + সিলেক্টেড + নট সিলেক্টেড + স্ক্যানলেটর + নেভিগেট আপ + ডাটা অন স্টোরেজ + কাস্টমাইজড আনার ব্যবধান \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/ca/strings.xml b/i18n/src/commonMain/resources/MR/ca/strings.xml index 34a528fbb..3afbcfe28 100644 --- a/i18n/src/commonMain/resources/MR/ca/strings.xml +++ b/i18n/src/commonMain/resources/MR/ca/strings.xml @@ -301,9 +301,9 @@ Darrer capítol Mostra els capítols Cancel·la-ho tot - Desactivat - Activat - Per defecte del sistema + Clar + Fosc + Sistema Gestiona les notificacions Seguretat i privadesa Requereix desblocatge @@ -768,4 +768,20 @@ S’utilitza per a les còpies de seguretat automàtiques, les baixades de capítols i la font local. Més opcions Navega cap amunt + Selecciona una carpeta + Guia de benvinguda + No heu fet servir mai el %s? Us recomanem que reviseu la guia de benvinguda. + Comença + Cal que seleccioneu una carpeta + Et donem la benvinguda! + Ja heu fet servir %s abans? + Omet + Següent + Primer cal configurar unes quantes coses. Sempre podràs canviar aquestes opcions a la configuració. + No s’ha definit una ubicació d’emmagatzematge + Seleccioneu una carpeta on el %1$s emmagatzemarà les baixades dels capítols, les còpies de seguretat i més. +\n +\nÉs recomanable fer servir una carpeta dedicada. +\n +\nCarpeta seleccionada: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/de/strings.xml b/i18n/src/commonMain/resources/MR/de/strings.xml index 73a3164c4..1030142c2 100644 --- a/i18n/src/commonMain/resources/MR/de/strings.xml +++ b/i18n/src/commonMain/resources/MR/de/strings.xml @@ -301,9 +301,9 @@ Neuestes Kapitel Kapitel anzeigen Alle abbrechen - Aus - An - Systemeinstellung + Hell + Dunkel + System Benachrichtigungen verwalten Sicherheit und Privatsphäre Entsperren erforderlich @@ -768,4 +768,20 @@ Wird für automatische Datensicherungen, heruntergeladene Kapitel und lokale Quellen verwendet. Weitere Optionen Nach oben navigieren + Ordner auswählen + Einführungstour + Neu bei %s? Wir empfehlen dir, unseren Einstiegsleitfaden anzusehen. + Loslegen + Es muss ein Ordner ausgewählt sein + Willkommen! + %s bereits genutzt? + Überspringen + Weiter + Lass uns zuerst ein paar Dinge einrichten. Du kannst diese später in den Einstellungen jederzeit ändern. + Kein Speicherort festgelegt + Wähle einen Ordner aus, in welchem %1$s Kapitel-Downloads, Datensicherungen und mehr speichern wird. +\n +\nEin dedizierter Ordner wird empfohlen. +\n +\nAusgewählter Ordner: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/el/strings.xml b/i18n/src/commonMain/resources/MR/el/strings.xml index d6740ab9c..8b695f068 100644 --- a/i18n/src/commonMain/resources/MR/el/strings.xml +++ b/i18n/src/commonMain/resources/MR/el/strings.xml @@ -347,9 +347,9 @@ Ασφάλεια και ιδιωτικότητα Διαχείριση ειδοποιήσεων Μορφή ημερομηνίας - Ακολουθήστε το σύστημα - Ενεργοποιημένο - Απενεργοποιημένο + Σύστημα + Σκοτεινό + Φωτεινό Μετακίνηση στον πάτο Μετακίνηση στην κορυφή Ακύρωση όλων @@ -768,4 +768,20 @@ Πλοήγηση προς τα πάνω Τοποθεσία αποθήκευσης Χρησιμοποιείται για αυτόματα αντίγραφα ασφαλείας, λήψη κεφαλαίων και τοπική πηγή. + Επιλέξτε ένα φάκελο + Ξεκινήστε + Ένας φάκελος πρέπει να επιλεγεί + Καλώς ορίσατε! + Παράλειψη + Επόμενο + Οδηγός εισαγωγής + Είστε νέοι στο %s; Σας συνιστούμε να ανατρέξετε στον οδηγό έναρξης. + Έχετε ξαναχρησιμοποιήσει το %s; + Ας ρυθμίσουμε πρώτα κάποια πράγματα. Μπορείτε πάντα να τα αλλάξετε στις ρυθμίσεις αργότερα. + Δεν έχει οριστεί τοποθεσία αποθήκευσης + Επιλέξτε ένα φάκελο όπου το %1$s θα αποθηκεύει λήψεις κεφαλαίων, αντίγραφα ασφαλείας και άλλα. +\n +\nΣυνιστάται ένας αποκλειστικός φάκελος. +\n +\nΕπιλεγμένος φάκελος: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/es/strings.xml b/i18n/src/commonMain/resources/MR/es/strings.xml index 567fd18fe..b72e0d5a9 100644 --- a/i18n/src/commonMain/resources/MR/es/strings.xml +++ b/i18n/src/commonMain/resources/MR/es/strings.xml @@ -90,7 +90,7 @@ Borrarlos tras marcarlos como leídos de forma manual Borrar capítulos terminados de forma automática Servicios de seguimiento - Vaciar la caché de capítulos + Limpiar la caché de capítulos Usado: %1$s Se vació la caché. Se han eliminado %1$d archivos Se produjo un error al limpiar @@ -302,9 +302,9 @@ Por capítulo más reciente Ver capítulos Cancelar todo - No - - Según ajustes del sistema + Claro + Oscuro + Sistema Gestionar notificaciones Seguridad y privacidad Requiere desbloqueo @@ -369,7 +369,7 @@ Gris Reduce el efecto anillado en los degradados y mejora la calidad de los grises, pero puede afectar al rendimiento No se pudieron abrir los ajustes del dispositivo - Volver a descargar las portadas en la biblioteca + Actualizar las portadas de la biblioteca La sincronización de estos servicios solo funciona en un solo sentido. Cada elemento en tu biblioteca tiene un botón de seguimiento y tendrás que configurarlo a mano, uno a uno. Esta extensión no es de la lista oficial de extensiones. No oficial @@ -383,7 +383,7 @@ Migrar Pestañas Mostrar pestañas de categorías - No parece haber ninguna página + No se encontraron páginas Deshabilitar todo Habilitar todo Mostrar brevemente el modo actual al abrir el lector @@ -458,7 +458,7 @@ \nTendrás que instalar las extensiones que falten e iniciar sesión en los servicios de seguimiento para poder usarlos. Esta versión de Android ya no es compatible No se pudo copiar al portapapeles - DNS por HTTPS (DoH) + DNS sobre HTTPS (DoH) Los elementos de las categorías excluidas no se descargarán, ni siquiera si pertenecen a alguna de las categorías que sí estén incluidas. Descarga automática En horizontal @@ -476,7 +476,7 @@ Incluir: %s Ninguna Los elementos de las categorías excluidas no se actualizarán, ni siquiera si pertenecen a alguna de las categorías que sí estén incluidas. - Toca para ver los detalles del error + Toca para ver los detalles Mostrar el número de elementos Fecha de obtención del capítulo Tipo de rotación @@ -554,7 +554,7 @@ Advertencia: Las descargas grandes pueden llevar a que las fuentes se vuelvan cada vez más lentas y en casos extremos que los servidores limiten o impidan el acceso a Tachiyomi. Toca aquí para más información. Actualizar todas Actualizaciones de la aplicación - Borrar la caché de capítulos al abrir la aplicación + Limpiar la caché de capítulos al abrir la aplicación Base de datos limpia %1$d entradas que no pertenecen a la biblioteca en la base de datos No se pudo descargar el listado de extensiones @@ -619,15 +619,15 @@ Borrar categoría ¿Quieres borrar la categoría «%s»\? ErrorInterno: Mira el registro de depuración para más información - Nombre del navegador a usar («user agent») - Restablecer el nombre del navegador («user agent») + User agent predeterminado + Restablecer user agent predeterminado Quitar todo La app no soporta el formato RARv5 Aquí aparecerá el contenido más reciente de tu biblioteca El widget no está disponible cuando el bloqueo de aplicación está activo Ya se está actualizando Marea - La cadena con el agente de usuario no puede estar vacía + El valor del user agent no puede estar en blanco Solo funciona si el capítulo actual y el que va después ya están descargados. Descargar por adelantado Descarga los capítulos siguientes mientras lees @@ -654,7 +654,7 @@ Título desconocido Ubicación incorrecta: %s Ahora mismo - El nombre de agente de usuario no vale + Valor de user agent inválido Reindexando descargas Abrir un elemento al azar Parece que esta categoría está vacía @@ -768,4 +768,29 @@ Subir un nivel Ubicación del almacenamiento Se utiliza para las copias de seguridad automáticas, las descargas de capítulos y la fuente local. + Seleccionar una carpeta + Guía de incorporación + ¿Nuevo en %s? Recomendamos consultar la guía de introducción. + Comenzar + Debe seleccionarse una carpeta + Bienvenido! + ¿Ya usaste %s antes? + Saltar + Siguiente + Vamos a configurar algunas cosas primero. Siempre puedes volver a cambiarlas más tarde en la configuración. + No se ha establecido una ubicación de almacenamiento + Selecciona una carpeta donde %1$s almacenará las descargas de capítulos, copias de seguridad y más. +\n +\nSe recomienda una carpeta dedicada. +\n +\nCarpeta seleccionada: %2$s + Permiso para instalar aplicaciones + Opcional + Requerido + Permiso de notificación + Evitar interrupciones en las actualizaciones largas de la biblioteca, descargas y restauraciones de copias de seguridad. + Uso de batería en segundo plano + Para instalar las extensiones de fuentes. + Recibe notificaciones sobre actualizaciones de la biblioteca y más. + Permitir \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/fil/strings.xml b/i18n/src/commonMain/resources/MR/fil/strings.xml index 1b63f248e..ebc249a00 100644 --- a/i18n/src/commonMain/resources/MR/fil/strings.xml +++ b/i18n/src/commonMain/resources/MR/fil/strings.xml @@ -99,9 +99,9 @@ Pamahalaan ang mga abiso Seguridad at privacy Ayos ng petsa - Nakabukas - Nakasara - Sundan ang sistema + Madilim + Maliwanag + Sistema Patungkol Karagdagan Pagta-track @@ -115,7 +115,7 @@ Huling nabasang kabanata Sarado Pagkamarkahang nabasa na - Pagkatapos basahin, kusang burahin + Pagkatapos basahin, awtomatikong burahin Kapal ng gilid Pagbabasa Ipakita palagi ang paglipat-kabanata @@ -133,7 +133,7 @@ Gitna Kanan Kaliwa - Kusa + Awtomatiko Panimulang pag-zoom Matalinong pagsasalaki Orihinal na laki @@ -185,7 +185,7 @@ \nMaaaring mabasa ng isang kaduda-dudang extension ang kahit anong nakatagong credentials sa pag-login o di kaya nama\'y magsimula ng delikadong code. \n \nTinatanggap mo ang mga bantang ito sa pagtiwala sa certificate na ito. - Kaduda-dudang extension + Di-pinagkakatiwalaang extension I-uninstall Tiwala Kaduda-duda @@ -202,17 +202,17 @@ Palaging tanungin Default na kategorya Maghanap ng mga bagong cover at detalye kapag nag-a-update ng Aklatan - Kusang sariwain ang metadata + Awtomatikong i-refresh ang metadata May \"Kumpleto\" na estado Kapag naka-charge - Kondisyon sa kusang pag-update + Awtomatikong ina-update ang mga paghihigpit sa device Linggo-linggo Kada 2 araw Araw-araw Kada 12 oras Kada 6 na oras Nakapatay - Kusang pag-update + Awtomatikong pag-update Panlahatang update Pahiga Patayo @@ -385,7 +385,7 @@ Nakatutulong sa pag-update ng aklatan sa background at pag-backup I-refresh ang mga cover sa aklatan Binura na - Sigurado ka ba\? Ang mga nabasang kabanata at pag-unlad ng mga wala sa aklatan ay mawawala + Sigurado ka ba? Ang mga nabasang kabanata at progress ng mga wala sa aklatan ay mawawala Burahin ang nakaraan ng mga entry na hindi naka-save sa aklatan mo Linisin ang database Nagka-error habang nililinis @@ -508,7 +508,7 @@ I-download na May dagdag na mga restriksyon sa app ang ilang mga modelo ng phone na pumapatay sa mga serbisyo sa background. May impormasyon sa site na ito para maayos ang naturang problema. Maaaring hindi gumana nang maayos ang pag-backup/pag-restore kung nakasara ang MIUI optimization. - Nagbibigay ng mga pinahusay na mga feature para sa ilang mga source. Kusang tina-track ang mga entry kapag naidagdag ito sa iyong aklatan. + Nagbibigay ng mga pinahusay na mga feature para sa ilang mga source. Awtomatikong tina-track ang mga entry kapag naidagdag ito sa iyong aklatan. Pinahusay na tracker Hatinggabi Berdeng Mansanas @@ -519,7 +519,7 @@ Yin at Yang Tako Presas - Gawaing likuran + Aktibidad sa background Pinakamababa Mababa Mataas @@ -535,9 +535,9 @@ Gabay sa Pagsisimula Pang-tablet na UI Tumulong sa pagsalin - Kategoryang di-kasama + Mga hindi kasamang kategorya Tungkol sa app - Paki-install at buksan ang Shizuku para magamit ito bilang taga-install ng extension. + I-Install at buksan ang Shizuku para magamit ito bilang taga-install ng extension. Di tumatakbo ang Shizuku Legasiya Taga-install @@ -551,7 +551,7 @@ Dapat nagtatabi rin kayo ng mga kopya ng backup sa ibang mga lugar. Ang mga backup ay naglalaman ng sensitibong data tulad ng nakaimbak na password; mag-ingat kung ibahagi ito. Sa Wi-Fi lang Kada 3 araw - Babala: maaaring humantong sa pagbagal at/o pagharang ng mga source sa Tachiyomi ang mga malalaking maramihang pag-download. I-tap para matuto pa. + Babala: maaaring humantong sa pagbagal at/o pagharang ng mga source sa Tachiyomi ang maramihang pag-download. I-tap para matuto pa. Mga update sa app I-update lahat %1$d na entry sa database na wala sa aklatan @@ -573,7 +573,7 @@ Hindi pa nasisimulan Nilaktawan dahil may di pa nabasang mga kabanata Nilaktawan dahil wala pang nabasang mga kabanata - Kusang mag-zoom sa mga malalawak na larawan + Awtomatikong mag-zoom sa mga malalawak na larawan I-pan ang mga malalapad na larawan Matuto pa Nilaktawan @@ -640,7 +640,7 @@ Nilaktawan dahil hindi kailangan ang pag-update sa serye Maghanap… Paraan ng pagbasa, pagpapakita, nabigasyon - Kusang pag-download, i-download agad + Awtomatikong pag-download, i-download nang maaga Isahang pagsabay sa progress, pinahusay na pagsabay Tema, ayos ng petsa & oras Mano-mano at awtomatikong pag-backup, espasyo sa storage @@ -768,4 +768,20 @@ Napili Di napili Mag-navigate pataas + Pumili ng folder + Gabay sa onboarding + Bago sa %s? Inirerekomenda naming tingnan ang gabay sa pagsisimula. + Magsimula + Dapat pumili ng isang folder + Maligayang pagdating! + Gumagamit ba ng %s dati? + Laktawan + Susunod + Mag-set up muna tayo ng ilang bagay. Maaari mo ring baguhin ang mga ito anumang oras sa mga setting sa ibang pagkakataon. + Walang nakatakdang lokasyon ng storage + Pumili ng folder kung saan mag-imbak ang %1$s ng mga na-download ng kabanata, mga backup, at higit pa. +\n +\nInirerekomenda ang isang nakalaang folder. +\n +\nNapiling folder: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/ja/strings.xml b/i18n/src/commonMain/resources/MR/ja/strings.xml index c29c25c62..2a0896bed 100644 --- a/i18n/src/commonMain/resources/MR/ja/strings.xml +++ b/i18n/src/commonMain/resources/MR/ja/strings.xml @@ -296,9 +296,9 @@ 最新章の更新順 章を見る すべてキャンセル - オフ - オン - システムに従う + ライト + ダーク + システム 通知設定 セキュリティとプライバシー アンロックを必要とする @@ -768,4 +768,29 @@ 上に移動 保存場所 自動バックアップ、章のダウンロード、ローカル ソースの保存位置となります。 + フォルダを選択してください + 初回設定ガイド + %sは初めて?入門ガイドをチェックしてみしましょう。 + はじめる + フォルダを選択してください + ようこそ! + %sを使ったことはもうありましたか? + スキップ + 次へ + はじめに初回設定をしていきましょう。このあとも「設定」にていつも変更できます。 + 保存場所が設定されていません + %1$sのダウンロード、バックアップなどの保存先のフォルダを設定してください。 +\n +\nアプリ専用のフォルダの作成・使用がおすすめです。 +\n +\n選択したフォルダ:%2$s + 通知の許可 + アプリのインストールの許可 + 時間のかかるライブラリ更新、ダウンロードやバックアップの復元などへの中断を防ぎます。 + 任意 + バックグラウンドでのバッテリー使用量 + ソース拡張機能をインストールするために必要です。 + ライブラリ更新などの通知を送信します。 + 必須 + 許可 \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/ko/strings.xml b/i18n/src/commonMain/resources/MR/ko/strings.xml index bd031f966..d22abee8b 100644 --- a/i18n/src/commonMain/resources/MR/ko/strings.xml +++ b/i18n/src/commonMain/resources/MR/ko/strings.xml @@ -150,7 +150,7 @@ 쿠키 삭제됨 데이터베이스 삭제 서재에 추가되지 않은 항목의 기록을 삭제합니다 - 확실합니까\? 서재에 없는 항목의 읽은 기록이 삭제됩니다 + 확실합니까? 서재에 없는 항목의 읽기 기록이 삭제됩니다 버전 오류 보고서 전송 버그를 수정하는데 도움이 됩니다. 개인 정보는 전송되지 않습니다 @@ -309,7 +309,7 @@ 배터리 최적화 끄기 MIUI 최적화가 꺼져 있을 경우 백업/복원 기능이 정상 작동하지 않을 수 있습니다. 복원이 이미 진행중 입니다 - 설정을 적용하기 위해 앱을 재시작해야 합니다 + 앱을 재시작한 후에 적용됩니다 DNS over HTTPS (DoH) 데이터 백업이 이미 진행중입니다 @@ -448,7 +448,7 @@ 없어진 소스: 로그인 되지않은 트래커: 앱 실행 시 회차 캐시 삭제 - 데이터베이스에 없는 항목이 %1$d개 있습니다 + 서재에 없는 항목이 데이터베이스에 %1$d개 있습니다 일부 제조사는 백그라운드 서비스를 종료하는 추가적인 제한 사항이 있습니다. 자세한 사항은 웹사이트를 참조하세요. 태블릿 UI @@ -622,7 +622,7 @@ 마지막 회차를 열 수 없습니다 최근에 업데이트된 항목 보기 보류 목록 - 분할하는 동안 %d 페이지를 찾을 수 없습니다 + 분리 중 페이지 %d을 찾을 수 없습니다 RARv5 포맷은 지원되지 않습니다 앱 잠금 사용 중에는 위젯을 이용할 수 없습니다 파도 diff --git a/i18n/src/commonMain/resources/MR/nb-rNO/strings.xml b/i18n/src/commonMain/resources/MR/nb-rNO/strings.xml index 7ee0417ac..317716a2b 100644 --- a/i18n/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/i18n/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -300,8 +300,8 @@ Mer Vis kapitler Avbryt alle - Av - + Lyst + Mørkt System Håndter merknader Sikkerhet og personvern @@ -768,4 +768,20 @@ Data og lagring Ingen fil valgt Ekskluder skanningsoversettere + Velg en mappe + Introduksjonsguide + Ny til %s? Vi anbefaler å sjekke ut startveiledningen. + Kom i gang + En mappe må velges + Velkommen! + Allerede brukt %s før? + Hopp over + Neste + La oss sette opp noen ting først. Du kan alltid endre disse i innstillingene senere også. + Ingen lagringsplassering angitt + Velg en mappe der %1$s vil lagre kapittelnedlastinger, sikkerhetskopier og mer. +\n +\nEn dedikert mappe anbefales. +\n +\nValgt mappe: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/pt-rBR/strings.xml b/i18n/src/commonMain/resources/MR/pt-rBR/strings.xml index cf3133d7c..b32191324 100644 --- a/i18n/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/i18n/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -783,4 +783,14 @@ \nUma pasta dedicada é recomendada. \n \nPasta selecionada: %2$s + Uma pasta deve ser selecionada + Permissão de notificação + Permissão de instalação de aplicativos + Evite interrupções para tarefas longas como atualizações da biblioteca, downloads e restauração de backups. + Opcional + Uso de bateria em plano de fundo + Para instalar extensões de fontes. + Seja notificado para atualizações da biblioteca e mais. + Obrigatório + Conceder \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/ru/strings.xml b/i18n/src/commonMain/resources/MR/ru/strings.xml index 7c2382369..02ca35d48 100644 --- a/i18n/src/commonMain/resources/MR/ru/strings.xml +++ b/i18n/src/commonMain/resources/MR/ru/strings.xml @@ -301,8 +301,8 @@ Последняя глава Просмотреть главы Отменить всё - Выключен - Включён + Светлая + Тёмная Система Управление уведомлениями Безопасность и конфиденциальность @@ -768,4 +768,20 @@ Перейти вверх Путь хранилища Используется для автоматических резевных копии, загрузок глав и источнике на устройстве. + Выбрать папку + Руководство для начинающих + Новичок в %s? Мы настоятельно рекомендуем ознакомиться с нашим руководством. + Начать + Необходимо выбрать папку + Добро пожаловать! + Уже использовали %s раньше? + Пропустить + Следующее + Давайте настроем парочку вещей. Вы всегда можете их поменять позже в настройках. + Не указан путь хранилища + Выберите папку где %1$s будет хранить загруженные главы, резервные копии и другое. +\n +\nРекомендуется использовать выделенную папку. +\n +\nВыбранная папка: %2$s \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/sq/strings.xml b/i18n/src/commonMain/resources/MR/sq/strings.xml index 472bf5342..5d85150c0 100644 --- a/i18n/src/commonMain/resources/MR/sq/strings.xml +++ b/i18n/src/commonMain/resources/MR/sq/strings.xml @@ -26,12 +26,12 @@ Rifresko Aplikacioni i padisponueshem Shkarkim automatik, shkarko përpara - Aktiv + Errët Molle jeshile Livando Yin & Yang Yotsuba - valët e baticës + Valët e Baticës E zezë e pastër modaliteti i errët Menaxho njoftimet Gjuha e aplikacionit @@ -46,7 +46,7 @@ Shfaq në listat e burimeve dhe shtesave Sot Shfaqja - Artikuj për rresht + Përmasat e grafikut Përditësim global Joaktiv Çdo 12 orë @@ -55,11 +55,11 @@ Vetëm në Wi-Fi Vetëm në rrjet pa matje Gjatë karikimit - Kapërceni përditësimin e hyrjeve + Kapërceni përditësimin e elementeve Me kapituj të palexuar Kontrolloni për kopertinë dhe detaje të reja kur përditësoni bibliotekën Kategoria e parazgjedhur - Regjistrimet në kategoritë e përjashtuara nuk do të përditësohen edhe nëse janë gjithashtu në kategoritë e përfshira. + Elementet në kategoritë e përjashtuara nuk do të përditësohen edhe nëse janë gjithashtu në kategoritë e përfshira. Të gjitha Asnje Përfshi: %s @@ -74,7 +74,7 @@ Nuk ofrohet lidhje Wi-Fi Biblioteka juaj është bosh Udhëzues për fillimin - Nuk u gjet asnjë hyrje në këtë kategori + Nuk u gjet asnjë element në këtë kategori Nuk ke kategori. Prekni butonin plus për të krijuar një për organizimin e bibliotekës tuaj. WebView kërkohet për Tachiyomi Dështoi të anashkalojë Cloudflare @@ -96,18 +96,18 @@ Shkarkues Përditësoni aplikacionin WebView për përputhshmëri më të mirë E paracaktuar - Gjurmuar + I gjurmuar Cilësimet - e palexuar + E palexuar Hiq filtrin Sipas alfabetit Totali i kapitujve Paralajmërim - Totali i hyrjeve + Totali i elementeve Leximi i fundit - Vërtetoni për të konfirmuar ndryshimin + Vërtetohuni për të konfirmuar ndryshimin Menuja - Filtro + Filter Sipas numrit të kapitullit Kapitujt e shkarkuar Burimi lokal @@ -148,7 +148,7 @@ Biblioteka Lexues Shkarkimet - Ndjekja + Gjurmimi Burimet, zgjerimet, kërkimi global Rezervime manuale & automatike E avancuar @@ -158,8 +158,8 @@ Rreth Hidh regjistrat e përplasjeve, optimizimet e baterisë Tema - Ndiq sistemin - Joaktiv + Ndiq Sistemin + Ndrçim Daiquiri luleshtrydhe Tema e aplikacionit Dinamik @@ -184,11 +184,11 @@ Përditëso të gjitha Përditësimet në pritje Aktiv - fikur + Fikur Kategoritë - Regjistrimet e bibliotekës + Elementet e bibliotekës Kapituj - Ndjekja + Gjurmimi Historia Kontrolli i përditësimit të fundit Numër i palexuar @@ -237,7 +237,7 @@ Ekrani Modaliteti i ekranit Rrjetë kompakte - Shiko përditësimin e fundit te bibliotekes tuaj + Shiko përditësimin e fundit të elementeve të bibliotekes tuaj Ju jeni gati të hiqni \"%s\" nga biblioteka juaj Miniaplikacioni nuk ofrohet kur kyçja e aplikacionit është aktivizuar Në pritje @@ -351,9 +351,9 @@ Shkarkim automatik gjatë leximit Ruaje si arkiv CBZ Udhëzues gjurmimi - Shërbime të përmirësuara + Gjurmues të përmirësuara Nuk ka kapitull tjetër - Krijon dosje sipas titullit të hyrjeve + Krijon dosje sipas titullit të elementeve E zezë Modaliteti i parazgjedhur i leximit Në formë L @@ -385,20 +385,20 @@ Kapitulli i dytë deri tek i fundit i lexuar Kapitulli i tretë deri tek i fundit i lexuar Kapitulli i pestë deri tek i fundit i lexuar - Regjistrimet në kategoritë e përjashtuara nuk do të shkarkohen edhe nëse janë gjithashtu në kategoritë e përfshira. + Elementet në kategoritë e përjashtuara nuk do të shkarkohen edhe nëse janë gjithashtu në kategoritë e përfshira. Punon vetëm në hyrjet në bibliotekë dhe nëse kapitulli aktual plus kapitulli tjetër janë shkarkuar tashmë - Shërbimet + Gjurmuesët Përmirëson performancën e lexuesit Përditëso progresin pas leximit - Sinkronizimi i njëanshëm për të përditësuar përparimin e kapitullit në shërbimet e gjurmimit. Konfiguro gjurmimin për hyrjet individuale nga butoni i tyre i gjurmimit. - faqeshënuar + Sinkronizimi i njëanshëm për të përditësuar përparimin e kapitullit në shërbimet e gjurmimit. Konfiguro gjurmimin për elementet individuale nga butoni i tyre i gjurmimit. + Faqeshënuar Webtoon I çaktivizuar Kindle-ish - Shërbime që ofrojnë veçori të përmirësuara për burime specifike. Regjistrimet gjurmohen automatikisht kur shtohen në bibliotekën tuaj. + Ofron tipare të përmirësuara për burime specifike. Elementet gjurmohen automatikisht kur shtohen në bibliotekën tuaj. Pista Frekuenca rezervë - Gjurmuesit nuk kanë hyrë në: + I pa identifikuar ne gjurmuesit: %02d min, %02d sek Rezervimi/rivendosja mund të mos funksionojë siç duhet nëse Optimizimi MIUI është i çaktivizuar. Rivendos vargun e parazgjedhur të agjentit të përdoruesit @@ -416,7 +416,7 @@ Sipas datës së ngarkimit Shkarko Të palexuara - Ndjekja + Gjurmimi Lista e papërfunduar Në listën e pritjes Lloji @@ -444,7 +444,7 @@ Zgjidhni të dhënat për të përfshirë Kapitujt nuk mund të shkarkoheshin. Mund të provoni përsëri në seksionin e shkarkimeve Përditësimet e mëdha dëmtojnë burimet dhe mund të çojnë në përditësime më të ngadalta dhe gjithashtu rritje të përdorimit të baterisë. Trokit për të mësuar më shumë. - Hyrjet u fshinë + Elementet u fshinë Çaktivizo optimizimin e baterisë Adresa e emailit Hyni në %1$s @@ -459,7 +459,7 @@ Anuloni indeksin e shkarkimeve Pastro memorjen e kapitullit në mbylljen e aplikacionit Pastro bazën e të dhënave - A je i sigurt\? Lexoni kapitujt dhe përparimi i hyrjeve që nuk janë në bibliotekë do të humbasin + A je i sigurt? Kapitujt e lexuar dhe progresi i elementeve që nuk janë në bibliotekë do të humbasin Nuk ke burime të gozhduara Urdhër nga Data @@ -506,7 +506,7 @@ Duke krijuar rezervë Rezervimi dështoi Lejet e ruajtjes nuk janë dhënë - Nuk ka hyrje në bibliotekë për të rezervuar + Nuk ka element në bibliotekë për të rezervuar Rivendosja është tashmë në proces Rivendosja e rezervës Rivendosja e rezervimit dështoi @@ -522,7 +522,7 @@ Të dhënat Përdorur: %1$s Memoria e fshehtë u pastrua. %1$d skedarë janë fshirë - %1$d hyrje jashtë bibliotekës në bazën e të dhënave + %1$d element jashtë bibliotekës në bazën e të dhënave Pastro të dhënat e WebView Rifresko kopertinat e bibliotekës Rivendos cilësimet e lexuesit për seri @@ -543,7 +543,7 @@ Vetëm të shkarkuarat Modaliteti i fshehtë Ndalon leximin e historisë - Filtro të gjitha hyrjet në bibliotekën tuaj + Filtro të gjitha elementet në bibliotekën tuaj Dil nga %1$s\? Tani keni dalë nga llogaria Gabim i panjohur @@ -578,7 +578,7 @@ Kopertina u ruajt Gabim në ndarjen e kopertinës Jeni i sigurt që dëshironi të fshini kapitujt e zgjedhur\? - Aplikoni gjithashtu për të gjitha hyrjet në bibliotekën time + Aplikoni gjithashtu për të gjitha elementet në bibliotekën time Vendose si parësore Nuk u gjet asnjë kapitull A je i sigurt\? @@ -597,7 +597,7 @@ Data e fillimit Status Data e mbarimit - Të hiqet data\? + Hiqni datën? Kjo do të heqë datën e përfundimit të zgjedhur më parë nga %s Kategoritë u fshinë Fotografia u ruajt @@ -641,10 +641,10 @@ \n \n Do t\'ju duhet të instaloni çdo shtesë që mungon dhe më pas të identifikoheni në shërbimet e gjurmimit për t\'i përdorur ato. Skedar rezervë i pavlefshëm - Rezervimi nuk përmban asnjë hyrje në bibliotekë. + Rezervimi nuk përmban asnjë element në bibliotekë. Varg i parazgjedhur i agjentit të përdoruesit Pastro memorien e kapitullit - Fshi historikun për shënimet që nuk janë ruajtur në bibliotekën tënde + Fshi historikun për elementet që nuk janë ruajtur në bibliotekën tënde Të dhënat e WebView u pastruan Të gjitha cilësimet e lexuesit rivendosen Përdorur për herë të fundit @@ -679,19 +679,19 @@ Lexo N/A %dd - Ndjekësit - Hyrjet e ndjekura + Gjurmuesit + Elementet e gjurmuara Jo tani - Hyrjet e përfunduara + Elementet të përfunduara Koha e të lezuarit - Hyrjet + Elementet Ne përditësimin global %do %dm Kategorija është bosh Në dispozicion, por burimi nuk është i instaluar: %s Kapërceni kapitujt e kopjuar - Fshih hyrjet tashmë në bibliotekë + Fshih elementet tashmë në bibliotekë Kopjo në kujtesën e fragmenteve Ju keni një hyrje në librarni me të njëjtin emër. \n @@ -700,4 +700,13 @@ %1$s gabim: %2$s *kërkohet U kopjua në clipboard + Pika e magazinimit + Fshi shkarkimet + Piket e gjurmimit + Hiq %s gjurmimin? + Hiqe gjithashtu nga %s + Kjo do te heq gjurmimn lokal. + Gjurmuesi i identifikimit + Koha relative + \"%1$s\" në vend të \"%2$s\" \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/sv/strings.xml b/i18n/src/commonMain/resources/MR/sv/strings.xml index 5b4509d0b..f8c66ebd2 100644 --- a/i18n/src/commonMain/resources/MR/sv/strings.xml +++ b/i18n/src/commonMain/resources/MR/sv/strings.xml @@ -301,9 +301,9 @@ Senaste kapitel Visa kapitel Avbryt alla - Av - - Följ systemet + Ljus + Mörk + System Hantera aviseringar Säkerhet och integritet Kräver upplåsning @@ -768,4 +768,29 @@ Vald Inte vald Navigera upp + Välj en mapp + Introduktionsguide + Ny till %s? Vi rekommenderar att du tar en titt på komma igång guiden. + Kom igång + En mapp måste väljas + Välkommen! + Redan använt %s förut? + Hoppa över + Nästa + Låt oss ställa in några saker först. Du kan alltid ändra dessa i inställningarna senare. + Ingen lagringsplats inställd + Välj en mapp där %1$s lagrar kapitelnedladdningar, säkerhetskopior och mer. +\n +\nEn dedikerad mapp rekommenderas. +\n +\nVald mapp: %2$s + Aviseringsbehörigheter + Installera app behörigheter + Undvik avbrott i långvariga biblioteksuppdateringar, nedladdningar och säkerhetskopieringsåterställningar. + Valfritt + Bakgrundsbatterianvändning + För att installera källtillägg. + Få aviseringar om biblioteksuppdateringar och mer. + Krävs + Bevilja \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/tr/strings.xml b/i18n/src/commonMain/resources/MR/tr/strings.xml index 73abf0de2..54adba6a9 100644 --- a/i18n/src/commonMain/resources/MR/tr/strings.xml +++ b/i18n/src/commonMain/resources/MR/tr/strings.xml @@ -301,9 +301,9 @@ Son bölüm Bölümleri görüntüle Hepsini iptal et - Kapalı - Açık - Sisteme uy + Açık + Koyu + Sistem Bildirimleri yönet Güvenlik ve gizlilik Kilit açma gerektirir @@ -768,4 +768,29 @@ Depolama yeri Kendiliğinden yedeklemeler, bölüm indirmeleri ve yerel kaynak için kullanılır. Yukarı git + Klasör seç + Başlangıç rehberi + %s\'de yeni misiniz? Başlangıç rehberine göz atmanızı tavsiye ederiz. + Başlayın + Bir klasör seçilmelidir + Hoş geldiniz! + Daha önce %s kullandınız mı? + Atla + Sonraki + Önce bazı şeyleri ayarlayalım. Bunları daha sonra ayarlardan da değiştirebilirsiniz. + Kaydetme konumu ayarlanmadı + %1$s bölüm indirmelerini, yedeklemeleri ve başka şeyleri kaydedeceği bir klasör seçin. +\n +\nYalnızca buna ait bir klasör tavsiye edilir. +\n +\nSeçilen klasör: %2$s + Bildirim izni + Uygulama kurma izni + Uzun süreli kitaplık güncellemeleri, indirmeler ve yedekleme geri yüklemelerinin kesintiye uğramasını önleyin. + İsteğe bağlı + Arka planda pil kullanımı + Kaynak uzantılarını kurmak için. + Kitaplık güncellemeleri ve daha fazlası için bildirim alın. + Gerekli + Ver \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/vi/plurals.xml b/i18n/src/commonMain/resources/MR/vi/plurals.xml index 18fc85c64..df9978d19 100644 --- a/i18n/src/commonMain/resources/MR/vi/plurals.xml +++ b/i18n/src/commonMain/resources/MR/vi/plurals.xml @@ -45,4 +45,7 @@ Đang thiếu %1$s + + %d ngày + \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/vi/strings.xml b/i18n/src/commonMain/resources/MR/vi/strings.xml index 679f23b97..110a6c3bb 100644 --- a/i18n/src/commonMain/resources/MR/vi/strings.xml +++ b/i18n/src/commonMain/resources/MR/vi/strings.xml @@ -315,14 +315,14 @@ Bảo mật và quyền riêng tư Quản lý thông báo Định dạng ngày - Theo hệ thống - Bật - Tắt + Hệ thống + Tối + Sáng Thư viện Làm mới Chuyển tới trước Trở lại - Di chuyển xuống dưới + Di chuyển xuống cuối Di chuyển lên đầu Cũ nhất Mới nhất @@ -362,7 +362,7 @@ Chỉ hiện truyện đã tải Hướng dẫn di chuyển nguồn Cài đặt tìm kiếm - Sao lưu đã đang trong quá trình thực hiện + Sao lưu đang trong quá trình thực hiện Bạn có chắc không\? Tất cả lịch sử sẽ bị xoá. Truyện trong danh mục bị loại trừ sẽ không được cập nhật. Ngày kết thúc @@ -528,7 +528,7 @@ Độ nhạy cho phần tự ẩn mục chính khi kéo cuộn Hướng dẫn sử dụng khởi đầu Giao diện máy tính bảng - Hoạt động ngầm + Hoạt động nền Thấp nhất Thấp Cao @@ -635,7 +635,7 @@ Bạn sẽ xóa bỏ \"%s\" này ra khỏi thư viện của bạn Thư viện lần cuối được cập nhật:%s Tải Trước - Tải trước chỉ áp dụng cho các mục ở trong thư viện và nếu chương cuối cùng đã được tải rồi + Tải trước chỉ áp dụng cho các mục ở trong thư viện và nếu chương cuối cùng đã được tải rồi. Đa Ngôn Ngữ Bỏ qua vì loạt truyện không cần cập nhật Tìm kiếm… @@ -709,7 +709,7 @@ Vuốt chương Thao tác vuốt sang phải Thao tác vuốt sang trái - Thông tin Debug + Thông tin gỡ lỗi Không thể tạo tệp sao lưu Đặt khoảng thời gian OK @@ -727,4 +727,70 @@ Mở khoá %s Cài đặt nguồn Cài đặt ứng dụng + Vị trí kho chứa + Tạo + Không bao giờ + Chọn một thư mục + Giảm tình trạng bóng ma trên màn giấy điện tử + Hướng dẫn làm quen + Được sử dụng để sao lưu tự động, tải chương và nguồn cục bộ. + Mới với %s sao? Chúng tôi khuyên bạn nên xem hướng dẫn bắt đầu. + Bắt đầu + Áp dụng + Đặt để cập nhật mỗi + Phải chọn một thư mục + Quyền thông báo + Đoạn thời gian + Chỉnh về mặc định + Cài đặt quyền ứng dụng + Lọc danh mục + Xin chào! + Thêm tùy chọn + Lần cuối cùng tự động lưu: %s + Đã sử dụng %s từ trước rồi sao? + Tùy chỉnh đoạn thời gian + Được chọn + Không tìm thấy máy quét nào + Chưa chọn + Di chuyển bộ truyện xuống cuối + Máy quét + Bỏ qua + Hiện trắng khi đổi trang + Được cấp phép - Không có chương nào để hiển thị + Tránh gián đoạn quá trình cập nhật thư viện, tải xuống và khôi phục bản sao lưu trong thời gian dài. + Mất kết nối mạng + Kho chứa chiếm dụng + Tùy ý + Tiếp + Sử dụng pin nền + Để cài đặt nguồn mở rộng. + Đang cập nhật thư viện… (%s) + Chỉ mục tải xuống bị vô hiệu + Điều hướng trên + Trước tiên hãy thiết lập một số thứ nhé. Bạn có thể tùy ý chỉnh lại những cài đặt này lại sau. + Điểm bộ theo dõi + Chưa đặt vị trí kho chứa + Dữ liệu và kho chứa + Bạn có muốn lọc danh mục theo thứ tự bảng chữ cái? + Bỏ qua vì dự kiến hôm nay không có bản phát hành nào + Gửi thông báo khi thư viện cấp nhật và nhiều hơn thế. + Không có tập tin được chọn + Xóa bộ theo dõi %s? + Đồng thời xóa khỏi %s + Bắt buộc + Có kết quả + Ước tính mỗi + Điều này sẽ loại bỏ bộ theo dõi cục bộ. + Đăng nhập bộ theo dõi + Cho phép + Chọn thư mục nơi mà %1$s sẽ chứa chương truyện tải xuống, sao lưu, và những thứ khác. +\n +\nKhuyến khích sử dụng một thư mục chuyên dụng. +\n +\nThư mục được chọn: %2$s + Mốc thời gian liên quan + HTTP %d, kiểm tra trang web trong WebView + \"%1$s\" thay vì là \"%2$s\" + Không thể truy cập %s + Loại trừ máy quét \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/zh-rCN/strings.xml b/i18n/src/commonMain/resources/MR/zh-rCN/strings.xml index dbd2e0f09..9b522c33b 100644 --- a/i18n/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/i18n/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -762,4 +762,7 @@ 扫译者 记录平台评分 排除的扫译者 + 更多选项 + 已选择 + 未选择 \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/zh-rTW/strings.xml b/i18n/src/commonMain/resources/MR/zh-rTW/strings.xml index 7c3c75e89..f793614c7 100644 --- a/i18n/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/i18n/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -291,9 +291,9 @@ 書櫃 過舊 這個擴充套件已無法使用,其可能無法正確運作或導致本程式發生問題。建議解除安裝。 - 關閉 - 遵循系統 - 開啟 + 淺色 + 系統 + 深色 日期格式 將套用至你書櫃中的作品 僅限下載內容 @@ -643,7 +643,7 @@ 主題、日期格式 自動下載、預先下載 單向進度同步、增強式同步 - 手動與自動備份,儲存空間 + 手動與自動備份、儲存空間 上鎖應用程式、防窺畫面 傾印當機記錄、電池效能最佳化 重新啟動應用程式 @@ -750,9 +750,9 @@ 來源設定 未選擇檔案 永不 - 減少電子墨水螢幕上的殘影 + 減少電子紙顯示器上的殘影 最後一次自動備份:%s - 頁面轉換時閃白 + 翻頁時閃爍白畫面 資料與儲存空間 儲存空間使用情形 歷程平台評分 @@ -768,4 +768,29 @@ 向上瀏覽 儲存位置 供自動備份、章節下載和本機來源使用。 + 選擇資料夾 + 新手上路精靈 + 初探 %s?我們建議你查看入門指南。 + 開始使用 + 必須選擇一個資料夾 + 歡迎! + 已是 %s 的既有使用者? + 略過 + 下一步 + 讓我們先設定一些東西。稍後你隨時可至設定中變更這些選項。 + 未設定儲存位置 + 選擇供 %1$s 存放下載的章節、備份檔等內容的資料夾。 +\n +\n建議使用專屬的資料夾。 +\n +\n選擇的資料夾:%2$s + 通知權限 + 安裝應用程式權限 + 避免中斷書櫃更新、章節下載和還原備份等較為費時的作業。 + 選用 + 背景耗電量 + 用以安裝來源擴充套件。 + 用以傳送書櫃更新等通知。 + 必要 + 授予 \ No newline at end of file diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/screens/InfoScreen.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/screens/InfoScreen.kt index 46736d51e..e3b65079f 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/screens/InfoScreen.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/screens/InfoScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Newspaper +import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults @@ -38,6 +39,7 @@ fun InfoScreen( subtitleText: String, acceptText: String, onAcceptClick: () -> Unit, + canAccept: Boolean = true, rejectText: String? = null, onRejectClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, @@ -63,8 +65,9 @@ fun InfoScreen( vertical = MaterialTheme.padding.small, ), ) { - androidx.compose.material3.Button( + Button( modifier = Modifier.fillMaxWidth(), + enabled = canAccept, onClick = onAcceptClick, ) { Text(text = acceptText) diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/util/Modifier.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/util/Modifier.kt index 795c6e211..411fc9983 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/util/Modifier.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/util/Modifier.kt @@ -1,6 +1,5 @@ package tachiyomi.presentation.core.util -import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme @@ -16,6 +15,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -28,7 +28,10 @@ import tachiyomi.presentation.core.components.material.SecondaryItemAlpha fun Modifier.selectedBackground(isSelected: Boolean): Modifier = if (isSelected) { composed { val alpha = if (isSystemInDarkTheme()) 0.16f else 0.22f - Modifier.background(MaterialTheme.colorScheme.secondary.copy(alpha = alpha)) + val color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha) + Modifier.drawBehind { + drawRect(color) + } } } else { this