mirror of
https://github.com/mihonapp/mihon.git
synced 2025-10-22 11:08:54 +02:00
chore: merge upstream.
This commit is contained in:
@@ -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"
|
||||
|
@@ -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(
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -124,7 +124,7 @@ fun MangaInfoBox(
|
||||
)
|
||||
}
|
||||
.blur(4.dp)
|
||||
.alpha(.2f),
|
||||
.alpha(0.2f),
|
||||
)
|
||||
|
||||
// Manga & source info
|
||||
|
@@ -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 = {
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,11 @@
|
||||
package eu.kanade.presentation.more.onboarding
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
internal interface OnboardingStep {
|
||||
|
||||
val isComplete: Boolean
|
||||
|
||||
@Composable
|
||||
fun Content()
|
||||
}
|
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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
|
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@@ -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
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -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(
|
||||
|
@@ -30,7 +30,7 @@ data class BackupTracking(
|
||||
) {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun getTrackingImpl(): Track {
|
||||
fun getTrackImpl(): Track {
|
||||
return Track(
|
||||
id = -1,
|
||||
mangaId = -1,
|
||||
|
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@@ -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("")
|
||||
}
|
||||
}
|
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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())
|
||||
}
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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),
|
||||
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user