chore: merge upstream.

This commit is contained in:
KaiserBh
2023-12-20 02:46:21 +11:00
81 changed files with 1871 additions and 1160 deletions

View File

@@ -160,7 +160,8 @@
<service
android:name=".extension.util.ExtensionInstallService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="shortService" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

View File

@@ -62,15 +62,15 @@ private const val GridSelectedCoverAlpha = 0.76f
*/
@Composable
fun MangaCompactGridItem(
coverData: tachiyomi.domain.manga.model.MangaCover,
onClick: () -> 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(

View File

@@ -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()
}

View File

@@ -124,7 +124,7 @@ fun MangaInfoBox(
)
}
.blur(4.dp)
.alpha(.2f),
.alpha(0.2f),
)
// Manga & source info

View File

@@ -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 = {

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -0,0 +1,11 @@
package eu.kanade.presentation.more.onboarding
import androidx.compose.runtime.Composable
internal interface OnboardingStep {
val isComplete: Boolean
@Composable
fun Content()
}

View File

@@ -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<PowerManager>()!!
.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),
)
}
}

View File

@@ -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<String>,
) {
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<StoragePreferences>().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() }
}
}
}

View File

@@ -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) },
)
}
}
}

View File

@@ -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<DownloadCache>().invalidateCache()
}
}
}

View File

@@ -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

View File

@@ -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),
)
}
}

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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<Long, String> = emptyMap()
private val errors = mutableListOf<Pair<Date, String>>()
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<BackupCategory>) {
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<BackupCategory>, 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<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
): Manga {
val fetchedManga = restoreNewManga(manga)
restoreChapters(fetchedManga, chapters)
restoreExtras(fetchedManga, categories, history, tracks, backupCategories)
return fetchedManga
}
private suspend fun restoreChapters(manga: Manga, chapters: List<Chapter>) {
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<Chapter>) {
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<Chapter>) {
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<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
): Manga {
restoreChapters(backupManga, chapters)
restoreExtras(backupManga, categories, history, tracks, backupCategories)
return backupManga
}
private suspend fun restoreExtras(
manga: Manga,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
) {
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<Int>, backupCategories: List<BackupCategory>) {
val dbCategories = getCategories.await()
val mangaCategoriesToUpdate = mutableListOf<Pair<Long, Long>>()
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<BackupHistory>) {
// List containing history to be updated
val toUpdate = mutableListOf<HistoryUpdate>()
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<Track>) {
// Get tracks from database
val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) }
val toUpdate = mutableListOf<Manga_sync>()
val toInsert = mutableListOf<Track>()
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<BackupPreference>) {
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<BackupSourcePreferences>) {
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<BackupPreference>,
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)
}
}

View File

@@ -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

View File

@@ -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
},
)
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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<Chapter> {
return chapters.map {
it.toChapterImpl()
}
}
fun getTrackingImpl(): List<Track> {
return tracking.map {
it.getTrackingImpl()
}
}
companion object {
fun copyFrom(manga: Manga): BackupManga {
return BackupManga(

View File

@@ -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(

View File

@@ -30,7 +30,7 @@ data class BackupTracking(
) {
@Suppress("DEPRECATION")
fun getTrackingImpl(): Track {
fun getTrackImpl(): Track {
return Track(
id = -1,
mangaId = -1,

View File

@@ -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
},
)
}

View File

@@ -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<Pair<Date, String>>()
/**
* Mapping of source ID to source name from backup data
*/
private var sourceMapping: Map<Long, String> = 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<BackupCategory>) = launch {
ensureActive()
categoriesRestorer.restoreCategories(backupCategories)
restoreProgress += 1
notifier.showRestoreProgress(
context.stringResource(MR.strings.categories),
restoreProgress,
restoreAmount,
isSync,
)
}
private fun CoroutineScope.restoreManga(
backupMangas: List<BackupManga>,
backupCategories: List<BackupCategory>,
) = 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<BackupPreference>) = launch {
ensureActive()
preferenceRestorer.restoreAppPreferences(preferences)
restoreProgress += 1
notifier.showRestoreProgress(
context.stringResource(MR.strings.app_settings),
restoreProgress,
restoreAmount,
isSync,
)
}
private fun CoroutineScope.restoreSourcePreferences(preferences: List<BackupSourcePreferences>) = 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("")
}
}

View File

@@ -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<BackupCategory>) {
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,
)
}
}
}

View File

@@ -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<BackupManga>): List<BackupManga> {
val urlsBySource = handler.awaitList { mangasQueries.getAllMangaSourceAndUrl() }
.groupBy({ it.source }, { it.url })
return backupMangas
.sortedWith(
compareBy<BackupManga> { it.url in urlsBySource[it.source].orEmpty() }
.then(compareByDescending { it.lastModifiedAt }),
)
}
suspend fun restoreManga(
backupManga: BackupManga,
backupCategories: List<BackupCategory>,
) {
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<BackupChapter>) {
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<Chapter>) {
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<Chapter>) {
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<BackupChapter>,
categories: List<Long>,
backupCategories: List<BackupCategory>,
history: List<BackupHistory>,
tracks: List<BackupTracking>,
): 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<Long>,
backupCategories: List<BackupCategory>,
) {
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<BackupHistory>) {
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<BackupTracking>) {
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)
}

View File

@@ -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<BackupPreference>) {
restorePreferences(preferences, preferenceStore)
LibraryUpdateJob.setupTask(context)
BackupCreateJob.setupTask(context)
}
fun restoreSourcePreferences(preferences: List<BackupSourcePreferences>) {
preferences.forEach {
val sourcePrefs = AndroidPreferenceStore(context, sourcePreferences(it.sourceKey))
restorePreferences(it.prefs, sourcePrefs)
}
}
private fun restorePreferences(
toRestore: List<BackupPreference>,
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)
}
}
}
}
}
}

View File

@@ -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<Unit> = 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()
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
},
)
}

View File

@@ -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>(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,
)
}
}

View File

@@ -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
}

View File

@@ -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<SourceManager>().get(sourceId)!! as ConfigurableSource
val dataStore = SharedPreferencesDataStore(source.sourcePreferences())
preferenceManager.preferenceDataStore = dataStore
val source = Injekt.get<SourceManager>().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)
}
}
}
}

View File

@@ -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))
}

View File

@@ -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,
)
}
}

View File

@@ -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())
}
}

View File

@@ -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()

View File

@@ -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),

View File

@@ -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<BasePreferences>() }
val storagePreferences = remember { Injekt.get<StoragePreferences>() }
val uiPreferences = remember { Injekt.get<UiPreferences>() }
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))

View File

@@ -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

View File

@@ -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)
}
}
}