From 440b624903b874e25ea254966a7ac8a2a25cdbbc Mon Sep 17 00:00:00 2001 From: KaiserBh Date: Fri, 29 Dec 2023 02:06:43 +1100 Subject: [PATCH 1/6] refactor: Ktlint and replace enum.values with Enum.entries. Signed-off-by: KaiserBh --- .../main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index 414218cc7..5ab0b21fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -22,7 +22,6 @@ import tachiyomi.data.Chapters import tachiyomi.data.DatabaseHandler import tachiyomi.data.manga.MangaMapper.mapManga import tachiyomi.domain.category.interactor.GetCategories -import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.sync.SyncPreferences @@ -61,7 +60,7 @@ class SyncManager( ; companion object { - fun fromInt(value: Int) = values().firstOrNull { it.value == value } ?: NONE + fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: NONE } } @@ -190,7 +189,9 @@ class SyncManager( val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() } val localCategories = getCategories.await(localManga.id).map { it.order } - return localManga != remoteManga || areChaptersDifferent(localChapters, backupManga.chapters) || localCategories != backupManga.categories + return localManga != remoteManga || + areChaptersDifferent(localChapters, backupManga.chapters) || + localCategories != backupManga.categories } /** From 9c120e623193271971448fb03665a73dff4f85cb Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Thu, 28 Dec 2023 23:01:01 +0700 Subject: [PATCH 2/6] Implement predictive back animation (#10273) For home screen tabs, Navigator screens and most dialogs --- .../kanade/presentation/manga/MangaScreen.kt | 10 +- .../manga/components/MangaCoverDialog.kt | 33 +- .../eu/kanade/presentation/util/Navigator.kt | 354 +++++++++++++++++- .../eu/kanade/tachiyomi/ui/home/HomeScreen.kt | 49 ++- .../kanade/tachiyomi/ui/main/MainActivity.kt | 10 +- .../tachiyomi/ui/setting/SettingsScreen.kt | 2 + .../tachiyomi/util/view/ViewExtensions.kt | 21 ++ .../core/components/AdaptiveSheet.kt | 63 +++- 8 files changed, 510 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index c3ce971e6..e8d871370 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -282,7 +282,10 @@ private fun MangaScreenSmallImpl( onBackClicked() } } - BackHandler(onBack = internalOnBackPressed) + BackHandler( + enabled = isAnySelected, + onBack = { onAllChapterSelected(false) }, + ) Scaffold( topBar = { @@ -540,7 +543,10 @@ fun MangaScreenLargeImpl( onBackClicked() } } - BackHandler(onBack = internalOnBackPressed) + BackHandler( + enabled = isAnySelected, + onBack = { onAllChapterSelected(false) }, + ) Scaffold( topBar = { diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index b3c06a979..3b5d7e558 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -3,6 +3,10 @@ package eu.kanade.presentation.manga.components import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -25,15 +29,18 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -48,11 +55,13 @@ import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import kotlinx.collections.immutable.persistentListOf +import soup.compose.material.motion.MotionConstants import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication +import kotlin.coroutines.cancellation.CancellationException @Composable fun MangaCoverDialog( @@ -151,10 +160,32 @@ fun MangaCoverDialog( val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } + var scale by remember { mutableFloatStateOf(1f) } + PredictiveBackHandler { progress -> + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.8f, LinearOutSlowInEasing.transform(backEvent.progress)) + } + onDismissRequest() + } catch (e: CancellationException) { + animate( + initialValue = scale, + targetValue = 1f, + animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), + ) { value, _ -> + scale = value + } + } + } + Box( modifier = Modifier .fillMaxSize() - .clickableNoIndication(onClick = onDismissRequest), + .clickableNoIndication(onClick = onDismissRequest) + .graphicsLayer { + scaleX = scale + scaleY = scale + }, ) { AndroidView( factory = { diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt index 86311d8e0..8083bc0af 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,13 +1,54 @@ package eu.kanade.presentation.util import android.annotation.SuppressLint +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.screen.Screen @@ -16,14 +57,25 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.ScreenTransitionContent +import eu.kanade.tachiyomi.util.view.getWindowRadius import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.rememberSlideDistance +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.PI +import kotlin.math.sin /** * For invoking back press to the parent activity @@ -57,17 +109,299 @@ interface AssistContentScreen { } @Composable -fun DefaultNavigatorScreenTransition(navigator: Navigator) { - val slideDistance = rememberSlideDistance() - ScreenTransition( - navigator = navigator, - transition = { - materialSharedAxisX( - forward = navigator.lastEvent != StackEvent.Pop, - slideDistance = slideDistance, +fun DefaultNavigatorScreenTransition( + navigator: Navigator, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val view = LocalView.current + val handler = remember { + OnBackHandler( + scope = scope, + windowCornerRadius = view.getWindowRadius(), + onBackPressed = navigator::pop, + ) + } + PredictiveBackHandler(enabled = navigator.canPop) { progress -> + progress + .onStart { handler.reset() } + .onCompletion { e -> + if (e == null) { + handler.onBackConfirmed() + } else { + handler.onBackCancelled() + } + } + .collect(handler::onBackEvent) + } + + Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) { + val currentSceneEntry = navigator.lastItem + val showPrev by remember { + derivedStateOf { handler.scale < 1f || handler.translationY != 0f } + } + val visibleItems = remember(currentSceneEntry, showPrev) { + if (showPrev) { + val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2) + listOfNotNull(currentSceneEntry, prevSceneEntry) + } else { + listOfNotNull(currentSceneEntry) + } + } + + val slideDistance = rememberSlideDistance() + + val screenContent = remember { + movableContentOf { screen -> + navigator.saveableState("transition", screen) { + screen.Content() + } + } + } + + visibleItems.forEachIndexed { index, backStackEntry -> + val isPrev = index == 1 && visibleItems.size > 1 + if (!isPrev) { + AnimatedContent( + targetState = backStackEntry, + transitionSpec = { + val forward = navigator.lastEvent != StackEvent.Pop + if (!forward && !handler.isReady) { + // Pop screen without animation when predictive back is in use + EnterTransition.None togetherWith ExitTransition.None + } else { + materialSharedAxisX( + forward = forward, + slideDistance = slideDistance, + ) + } + }, + modifier = Modifier + .zIndex(1f) + .graphicsLayer { + this.alpha = handler.alpha + this.transformOrigin = TransformOrigin( + pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f, + pivotFractionY = 0.5f, + ) + this.scaleX = handler.scale + this.scaleY = handler.scale + this.translationY = handler.translationY + this.clip = true + this.shape = if (showPrev) { + RoundedCornerShape(handler.windowCornerRadius.toFloat()) + } else { + RectangleShape + } + } + .then( + if (showPrev) { + Modifier.pointerInput(Unit) { + // Animated content should not be interactive + } + } else { + Modifier + }, + ), + content = { + if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) { + // Avoid drawing previous screen + return@AnimatedContent + } + screenContent(it) + }, + ) + } else { + Box( + modifier = Modifier + .zIndex(0f) + .drawWithCache { + val bounds = Rect(Offset.Zero, size) + val matrix = ColorMatrix().apply { + // Reduce saturation and brightness + setToSaturation(lerp(1f, 0.95f, handler.alpha)) + set(0, 4, lerp(0f, -25f, handler.alpha)) + set(1, 4, lerp(0f, -25f, handler.alpha)) + set(2, 4, lerp(0f, -25f, handler.alpha)) + } + val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) } + onDrawWithContent { + drawIntoCanvas { + it.saveLayer(bounds, paint) + drawContent() + it.restore() + } + } + } + .graphicsLayer { + val blurRadius = 5.dp.toPx() * handler.alpha + renderEffect = if (blurRadius > 0f) { + BlurEffect(blurRadius, blurRadius) + } else { + null + } + } + .pointerInput(Unit) { + // bg content should not be interactive + }, + content = { screenContent(backStackEntry) }, + ) + } + } + + LaunchedEffect(currentSceneEntry) { + // Reset *after* the screen is popped successfully + // so that the correct transition is applied + handler.setReady() + } + } +} + +@Stable +private class OnBackHandler( + private val scope: CoroutineScope, + val windowCornerRadius: Int, + private val onBackPressed: () -> Unit, +) { + + var isReady = true + private set + + var alpha by mutableFloatStateOf(1f) + private set + + var scale by mutableFloatStateOf(1f) + private set + + var translationY by mutableFloatStateOf(0f) + private set + + var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT) + private set + + private var containerSize = Size.Zero + private var startPointY = Float.NaN + + var isPredictiveBack by mutableStateOf(false) + private set + + private var animationJob: Job? = null + set(value) { + isReady = false + field = value + } + + fun updateContainerSize(size: Size) { + containerSize = size + } + + fun setReady() { + reset() + animationJob?.cancel() + animationJob = null + isReady = true + isPredictiveBack = false + } + + fun reset() { + startPointY = Float.NaN + } + + fun onBackEvent(backEvent: BackEventCompat) { + if (!isReady) return + isPredictiveBack = true + swipeEdge = backEvent.swipeEdge + + val progress = LinearOutSlowInEasing.transform(backEvent.progress) + scale = lerp(1f, 0.85f, progress) + + if (startPointY.isNaN()) { + startPointY = backEvent.touchY + } + val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height + val translateYDistance = containerSize.height / 20 + translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress + } + + fun onBackConfirmed() { + if (!isReady) return + if (isPredictiveBack) { + // Continue predictive animation and pop the screen + val animationSpec = tween( + durationMillis = MotionConstants.DefaultMotionDuration, + easing = FastOutSlowInEasing, ) - }, - ) + animationJob = scope.launch { + try { + listOf( + async { + animate( + initialValue = alpha, + targetValue = 0f, + animationSpec = animationSpec, + ) { value, _ -> + alpha = value + } + }, + async { + animate( + initialValue = scale, + targetValue = scale - 0.05f, + animationSpec = animationSpec, + ) { value, _ -> + scale = value + } + }, + ).awaitAll() + } catch (e: CancellationException) { + // no-op + } finally { + onBackPressed() + alpha = 1f + translationY = 0f + scale = 1f + } + } + } else { + // Pop right away and use default transition + onBackPressed() + } + } + + fun onBackCancelled() { + // Reset states + isPredictiveBack = false + animationJob = scope.launch { + listOf( + async { + animate( + initialValue = scale, + targetValue = 1f, + ) { value, _ -> + scale = value + } + }, + async { + animate( + initialValue = alpha, + targetValue = 1f, + ) { value, _ -> + alpha = value + } + }, + async { + animate( + initialValue = translationY, + targetValue = 0f, + ) { value, _ -> + translationY = value + } + }, + ).awaitAll() + + isReady = true + } + } } @Composable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index ff2cb7075..7e61985d5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.ui.home -import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith @@ -23,13 +26,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.lerp import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator @@ -49,6 +59,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughOut import tachiyomi.domain.library.service.LibraryPreferences @@ -59,6 +70,7 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.pluralStringResource import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.coroutines.cancellation.CancellationException object HomeScreen : Screen() { @@ -80,6 +92,8 @@ object HomeScreen : Screen() { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow + var scale by remember { mutableFloatStateOf(1f) } + TabNavigator( tab = LibraryTab, key = TabNavigatorKey, @@ -118,6 +132,11 @@ object HomeScreen : Screen() { ) { contentPadding -> Box( modifier = Modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 1f) + } .padding(contentPadding) .consumeWindowInsets(contentPadding), ) { @@ -138,10 +157,30 @@ object HomeScreen : Screen() { } val goToLibraryTab = { tabNavigator.current = LibraryTab } - BackHandler( - enabled = tabNavigator.current != LibraryTab, - onBack = goToLibraryTab, - ) + + var handlingBack by remember { mutableStateOf(false) } + PredictiveBackHandler(enabled = handlingBack || tabNavigator.current != LibraryTab) { progress -> + handlingBack = true + val currentTab = tabNavigator.current + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.92f, LinearOutSlowInEasing.transform(backEvent.progress)) + tabNavigator.current = if (backEvent.progress > 0.25f) tabs[0] else currentTab + } + goToLibraryTab() + } catch (e: CancellationException) { + tabNavigator.current = currentTab + } finally { + animate( + initialValue = scale, + targetValue = 1f, + animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), + ) { value, _ -> + scale = value + } + handlingBack = false + } + } LaunchedEffect(Unit) { launch { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 78c688c2e..7184388a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -11,7 +11,6 @@ import android.os.Bundle import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -223,14 +222,13 @@ class MainActivity : BaseActivity() { contentWindowInsets = scaffoldInsets, ) { contentPadding -> // Consume insets already used by app state banners - Box( + // Shows current screen + DefaultNavigatorScreenTransition( + navigator = navigator, modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding), - ) { - // Shows current screen - DefaultNavigatorScreenTransition(navigator = navigator) - } + ) } // Pop source-related screens when incognito mode is turned off diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt index 3d396ace9..3ba5f6dec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt @@ -40,6 +40,7 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsMainScreen }, + onBackPressed = null, content = { val pop: () -> Unit = { if (it.canPop) { @@ -61,6 +62,7 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsAppearanceScreen }, + onBackPressed = null, ) { val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) TwoPanelBox( diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index 60d357a53..7e8651111 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -4,9 +4,11 @@ package eu.kanade.tachiyomi.util.view import android.content.res.Resources import android.graphics.Rect +import android.os.Build import android.view.Gravity import android.view.Menu import android.view.MenuItem +import android.view.RoundedCorner import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -95,3 +97,22 @@ fun View?.isVisibleOnScreen(): Boolean { Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels) return actualPosition.intersect(screen) } + +/** + * Returns window radius (in pixel) applied to this view + */ +fun View.getWindowRadius(): Int { + val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val windowInsets = rootWindowInsets + listOfNotNull( + windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT), + windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT), + windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT), + windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT), + ) + .minOfOrNull { it.radius } + } else { + null + } + return rad ?: 0 +} diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt index d36e2593f..7e770fb43 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt @@ -1,7 +1,11 @@ package tachiyomi.presentation.core.components -import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.AnchoredDraggableState @@ -26,6 +30,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -34,8 +39,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -45,14 +53,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt -private val sheetAnimationSpec = tween(durationMillis = 350) - @Composable fun AdaptiveSheet( isTabletUi: Boolean, @@ -91,6 +99,11 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier + .predictiveBackAnimation( + enabled = remember { derivedStateOf { alpha > 0f } }.value, + transformOrigin = TransformOrigin.Center, + onBack = internalOnDismissRequest, + ) .requiredWidthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -103,7 +116,6 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { - BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest) content() }, ) @@ -145,6 +157,11 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier + .predictiveBackAnimation( + enabled = anchoredDraggableState.targetValue == 0, + transformOrigin = TransformOrigin(0.5f, 1f), + onBack = internalOnDismissRequest, + ) .widthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -184,10 +201,6 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { - BackHandler( - enabled = anchoredDraggableState.targetValue == 0, - onBack = internalOnDismissRequest, - ) content() }, ) @@ -257,3 +270,37 @@ private fun AnchoredDraggableState.preUpPostDownNestedScrollConnection() @JvmName("offsetToFloat") private fun Offset.toFloat(): Float = this.y } + +private fun Modifier.predictiveBackAnimation( + enabled: Boolean, + transformOrigin: TransformOrigin, + onBack: () -> Unit, +) = composed { + var scale by remember { mutableFloatStateOf(1f) } + PredictiveBackHandler(enabled = enabled) { progress -> + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.85f, LinearOutSlowInEasing.transform(backEvent.progress)) + } + // Completion + onBack() + } catch (e: CancellationException) { + // Cancellation + } finally { + animate( + initialValue = scale, + targetValue = 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + ) { value, _ -> + scale = value + } + } + } + Modifier.graphicsLayer { + this.scaleX = scale + this.scaleY = scale + this.transformOrigin = transformOrigin + } +} + +private val sheetAnimationSpec = tween(durationMillis = 350) From 080192a1a5cfeb6b68fcb043f70b6a53c8b0a938 Mon Sep 17 00:00:00 2001 From: KaiserBh Date: Fri, 29 Dec 2023 04:25:17 +1100 Subject: [PATCH 3/6] revert: comparison clean up, simple equality check makes the restore longer. I think when library get updated so will last_modified_at field since one of the field in the row changes and the trigger get triggered, so it's best to ignore it if the only difference is last_modified_at. Signed-off-by: KaiserBh --- .../kanade/tachiyomi/data/sync/SyncManager.kt | 58 ++++++------------- 1 file changed, 18 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index 5ab0b21fa..46ce46592 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -168,49 +168,28 @@ class SyncManager( return handler.awaitList { mangasQueries.getAllManga(::mapManga) } } - /** - * Determines if there are differences between the local manga details and categories and their corresponding - * remote backup versions. This is used to identify changes or updates that might have occurred. - * - * @param localManga The manga object from the local database of type [Manga]. - * @param backupManga The manga object from the remote backup of type [BackupManga]. - * - * @return Boolean indicating whether there are differences. Returns true if any of the following is true: - * - The local manga details differ from the remote backup manga details. - * - The chapters of the local manga differ from the chapters of the remote backup manga. - * - The categories of the local manga differ from the categories of the remote backup manga. - * - * This function uses a combination of direct property comparisons and delegated comparison functions - * to assess differences across manga details, chapters, and categories. It relies on proper conversion - * of remote manga and chapters to the local format for accurate comparison. - */ - private suspend fun isMangaDifferent(localManga: Manga, backupManga: BackupManga): Boolean { - val remoteManga = backupManga.getMangaImpl() + private suspend fun isMangaDifferent(localManga: Manga, remoteManga: BackupManga): Boolean { val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() } val localCategories = getCategories.await(localManga.id).map { it.order } - return localManga != remoteManga || - areChaptersDifferent(localChapters, backupManga.chapters) || - localCategories != backupManga.categories + return localManga.source != remoteManga.source || + localManga.url != remoteManga.url || + localManga.title != remoteManga.title || + localManga.artist != remoteManga.artist || + localManga.author != remoteManga.author || + localManga.description != remoteManga.description || + localManga.genre != remoteManga.genre || + localManga.status.toInt() != remoteManga.status || + localManga.thumbnailUrl != remoteManga.thumbnailUrl || + localManga.dateAdded != remoteManga.dateAdded || + localManga.chapterFlags.toInt() != remoteManga.chapterFlags || + localManga.favorite != remoteManga.favorite || + localManga.viewerFlags.toInt() != remoteManga.viewer_flags || + localManga.updateStrategy != remoteManga.updateStrategy || + areChaptersDifferent(localChapters, remoteManga.chapters) || + localCategories != remoteManga.categories } - /** - * Checks if there are any differences between a list of local chapters and a list of backup chapters. - * This function is used to determine if updates or changes have occurred between the two sets of chapters. - * - * @param localChapters The list of chapters from the local source, of type [Chapters]. - * @param remoteChapters The list of chapters from the remote backup source, of type [BackupChapter]. - * - * @return Boolean indicating whether there are differences. Returns true if any of the following is true: - * - The count of local and remote chapters differs. - * - Any corresponding chapters (matched by URL) have differing attributes including name, scanlator, - * read status, bookmark status, last page read, chapter number, source order, fetch date, upload date, - * or last modified date. - * - * Each chapter is compared based on a set of fields that define its content and state. If any of these fields - * differ between the local chapter and its corresponding remote chapter, it is considered a difference. - * - */ private fun areChaptersDifferent(localChapters: List, remoteChapters: List): Boolean { // Early return if the sizes are different if (localChapters.size != remoteChapters.size) { @@ -236,8 +215,7 @@ class SyncManager( localChapter.chapter_number != remoteChapter.chapterNumber || localChapter.source_order != remoteChapter.sourceOrder || localChapter.date_fetch != remoteChapter.dateFetch || - localChapter.date_upload != remoteChapter.dateUpload || - localChapter.last_modified_at != remoteChapter.lastModifiedAt + localChapter.date_upload != remoteChapter.dateUpload } } From 5dbeda6b65287929b5da1dfa4388d38674026dc1 Mon Sep 17 00:00:00 2001 From: KaiserBh Date: Fri, 29 Dec 2023 05:43:05 +1100 Subject: [PATCH 4/6] Revert "chore: ktlint" This reverts commit 6677c90a39e866be1814db0e125107f9a6bb187a. --- .../more/settings/screen/SettingsDataScreen.kt | 5 +++++ .../eu/kanade/tachiyomi/data/sync/SyncManager.kt | 15 +++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index ca12e19a2..8249713ae 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -41,6 +41,9 @@ import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.sync.SyncManager import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.system.DeviceUtil +import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf @@ -281,6 +284,7 @@ object SettingsDataScreen : SearchableSettings { ) + getSyncServicePreferences(syncPreferences, syncService) } + @Composable private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List { val syncServiceType = SyncManager.SyncService.fromInt(syncService) @@ -501,4 +505,5 @@ object SettingsDataScreen : SearchableSettings { }, ) } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index 46ce46592..8ad1b1426 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -130,16 +130,11 @@ class SyncManager( val backupUri = writeSyncDataToCache(context, newSyncData) logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" } if (backupUri != null) { - BackupRestoreJob.start( - context, - backupUri, - sync = true, - options = RestoreOptions( - appSettings = true, - sourceSettings = true, - library = true, - ), - ) + BackupRestoreJob.start(context, backupUri, sync = true, options = RestoreOptions( + appSettings = true, + sourceSettings = true, + library = true, + )) } else { logcat(LogPriority.ERROR) { "Failed to write sync data to file" } } From 646ec0c947799cf4a364821b635d25eb5b819757 Mon Sep 17 00:00:00 2001 From: KaiserBh Date: Fri, 29 Dec 2023 05:57:27 +1100 Subject: [PATCH 5/6] refactor Signed-off-by: KaiserBh --- .../data/sync/service/SyncService.kt | 127 +++++++++--------- 1 file changed, 62 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt index 6e63fcd8f..0b6ec7404 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -80,94 +80,91 @@ abstract class SyncService( } /** - * Merges two lists of SyncManga objects, prioritizing the manga with the most recent lastModifiedAt value. - * If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. + * Merges two lists of BackupManga objects, selecting the most recent manga based on the lastModifiedAt value. + * If lastModifiedAt is null for a manga, it treats that manga as the oldest possible for comparison purposes. + * This function is designed to reconcile local and remote manga lists, ensuring the most up-to-date manga is retained. * - * @param localMangaList The list of local SyncManga objects. - * @param remoteMangaList The list of remote SyncManga objects. - * @return The merged list of SyncManga objects. + * @param localMangaList The list of local BackupManga objects or null. + * @param remoteMangaList The list of remote BackupManga objects or null. + * @return A list of BackupManga objects, each representing the most recent version of the manga from either local or remote sources. */ private fun mergeMangaLists( localMangaList: List?, - remoteMangaList: List?, + remoteMangaList: List? ): List { - if (localMangaList == null) return remoteMangaList ?: emptyList() - if (remoteMangaList == null) return localMangaList + // Convert null lists to empty to simplify logic + val localMangaListSafe = localMangaList.orEmpty() + val remoteMangaListSafe = remoteMangaList.orEmpty() - val localMangaMap = localMangaList.associateBy { Pair(it.source, it.url) } - val remoteMangaMap = remoteMangaList.associateBy { Pair(it.source, it.url) } + // Associate both local and remote manga by their unique keys (source and url) + val localMangaMap = localMangaListSafe.associateBy { Pair(it.source, it.url) } + val remoteMangaMap = remoteMangaListSafe.associateBy { Pair(it.source, it.url) } - val mergedMangaMap = mutableMapOf, BackupManga>() + // Prepare to merge both sets of manga + return (localMangaMap.keys + remoteMangaMap.keys).mapNotNull { key -> + val local = localMangaMap[key] + val remote = remoteMangaMap[key] - localMangaMap.forEach { (key, localManga) -> - val remoteManga = remoteMangaMap[key] - if (remoteManga != null) { - val localMangaLastModifiedAtInstant = Instant.ofEpochMilli(localManga.lastModifiedAt) - val remoteMangaLastModifiedAtInstant = Instant.ofEpochMilli(remoteManga.lastModifiedAt) - val mergedChapters = mergeChapters(localManga.chapters, remoteManga.chapters) - // Keep the more recent manga - if (localMangaLastModifiedAtInstant.isAfter(remoteMangaLastModifiedAtInstant)) { - mergedMangaMap[key] = localManga.copy(chapters = mergedChapters) - } else { - mergedMangaMap[key] = remoteManga.copy(chapters = mergedChapters) + when { + local != null && remote == null -> local + local == null && remote != null -> remote + local != null && remote != null -> { + // Compare last modified times and merge chapters + val localTime = Instant.ofEpochMilli(local.lastModifiedAt) + val remoteTime = Instant.ofEpochMilli(remote.lastModifiedAt) + val mergedChapters = mergeChapters(local.chapters, remote.chapters) + + if (localTime >= remoteTime) local.copy(chapters = mergedChapters) + else remote.copy(chapters = mergedChapters) } - } else { - // If there is no corresponding remote manga, keep the local one - mergedMangaMap[key] = localManga + else -> null // This case occurs if both are null, which shouldn't happen but is handled for completeness. } } - - // Add any remote manga that doesn't exist locally - remoteMangaMap.forEach { (key, remoteManga) -> - if (!mergedMangaMap.containsKey(key)) { - mergedMangaMap[key] = remoteManga - } - } - - return mergedMangaMap.values.toList() } - /** - * Merges two lists of SyncChapter objects, prioritizing the chapter with the most recent lastModifiedAt value. - * If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. - * - * @param localChapters The list of local SyncChapter objects. - * @param remoteChapters The list of remote SyncChapter objects. - * @return The merged list of SyncChapter objects. - */ + +/** + * Merges two lists of BackupChapter objects, selecting the most recent chapter based on the lastModifiedAt value. + * If lastModifiedAt is null for a chapter, it treats that chapter as the oldest possible for comparison purposes. + * This function is designed to reconcile local and remote chapter lists, ensuring the most up-to-date chapter is retained. + * + * @param localChapters The list of local BackupChapter objects. + * @param remoteChapters The list of remote BackupChapter objects. + * @return A list of BackupChapter objects, each representing the most recent version of the chapter from either local or remote sources. + * + * - This function is used in scenarios where local and remote chapter lists need to be synchronized. + * - It iterates over the union of the URLs from both local and remote chapters. + * - For each URL, it compares the corresponding local and remote chapters based on the lastModifiedAt value. + * - If only one source (local or remote) has the chapter for a URL, that chapter is used. + * - If both sources have the chapter, the one with the more recent lastModifiedAt value is chosen. + * - If lastModifiedAt is null or missing, the chapter is considered the oldest for safety, ensuring that any chapter with a valid timestamp is preferred. + * - The resulting list contains the most recent chapters from the combined set of local and remote chapters. + */ private fun mergeChapters( localChapters: List, - remoteChapters: List, + remoteChapters: List ): List { + // Associate chapters by URL for both local and remote val localChapterMap = localChapters.associateBy { it.url } val remoteChapterMap = remoteChapters.associateBy { it.url } - val mergedChapterMap = mutableMapOf() - localChapterMap.forEach { (url, localChapter) -> + // Merge both chapter maps + return (localChapterMap.keys + remoteChapterMap.keys).mapNotNull { url -> + // Determine the most recent chapter by comparing lastModifiedAt, considering null as Instant.MIN + val localChapter = localChapterMap[url] val remoteChapter = remoteChapterMap[url] - if (remoteChapter != null) { - val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } - val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } - val mergedChapter = - if (localInstant > remoteInstant) { - localChapter - } else { - remoteChapter - } - mergedChapterMap[url] = mergedChapter - } else { - mergedChapterMap[url] = localChapter + when { + localChapter != null && remoteChapter == null -> localChapter + localChapter == null && remoteChapter != null -> remoteChapter + localChapter != null && remoteChapter != null -> { + val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } ?: Instant.MIN + val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } ?: Instant.MIN + if (localInstant >= remoteInstant) localChapter else remoteChapter + } + else -> null } } - - remoteChapterMap.forEach { (url, remoteChapter) -> - if (!mergedChapterMap.containsKey(url)) { - mergedChapterMap[url] = remoteChapter - } - } - - return mergedChapterMap.values.toList() } /** From 8cd77740540df60ea593a21aecb74699926a57c4 Mon Sep 17 00:00:00 2001 From: KaiserBh Date: Fri, 29 Dec 2023 05:58:47 +1100 Subject: [PATCH 6/6] chore: lint Signed-off-by: KaiserBh --- .../settings/screen/SettingsDataScreen.kt | 5 --- .../kanade/tachiyomi/data/sync/SyncManager.kt | 15 ++++--- .../data/sync/service/SyncService.kt | 44 ++++++++++--------- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index 8249713ae..ca12e19a2 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -41,9 +41,6 @@ import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.sync.SyncManager import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService -import eu.kanade.tachiyomi.util.storage.DiskUtil -import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf @@ -284,7 +281,6 @@ object SettingsDataScreen : SearchableSettings { ) + getSyncServicePreferences(syncPreferences, syncService) } - @Composable private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List { val syncServiceType = SyncManager.SyncService.fromInt(syncService) @@ -505,5 +501,4 @@ object SettingsDataScreen : SearchableSettings { }, ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index 8ad1b1426..46ce46592 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -130,11 +130,16 @@ class SyncManager( val backupUri = writeSyncDataToCache(context, newSyncData) logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" } if (backupUri != null) { - BackupRestoreJob.start(context, backupUri, sync = true, options = RestoreOptions( - appSettings = true, - sourceSettings = true, - library = true, - )) + BackupRestoreJob.start( + context, + backupUri, + sync = true, + options = RestoreOptions( + appSettings = true, + sourceSettings = true, + library = true, + ), + ) } else { logcat(LogPriority.ERROR) { "Failed to write sync data to file" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt index 0b6ec7404..b4e2e062e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -90,7 +90,7 @@ abstract class SyncService( */ private fun mergeMangaLists( localMangaList: List?, - remoteMangaList: List? + remoteMangaList: List?, ): List { // Convert null lists to empty to simplify logic val localMangaListSafe = localMangaList.orEmpty() @@ -114,35 +114,37 @@ abstract class SyncService( val remoteTime = Instant.ofEpochMilli(remote.lastModifiedAt) val mergedChapters = mergeChapters(local.chapters, remote.chapters) - if (localTime >= remoteTime) local.copy(chapters = mergedChapters) - else remote.copy(chapters = mergedChapters) + if (localTime >= remoteTime) { + local.copy(chapters = mergedChapters) + } else { + remote.copy(chapters = mergedChapters) + } } else -> null // This case occurs if both are null, which shouldn't happen but is handled for completeness. } } } - /** - * Merges two lists of BackupChapter objects, selecting the most recent chapter based on the lastModifiedAt value. - * If lastModifiedAt is null for a chapter, it treats that chapter as the oldest possible for comparison purposes. - * This function is designed to reconcile local and remote chapter lists, ensuring the most up-to-date chapter is retained. - * - * @param localChapters The list of local BackupChapter objects. - * @param remoteChapters The list of remote BackupChapter objects. - * @return A list of BackupChapter objects, each representing the most recent version of the chapter from either local or remote sources. - * - * - This function is used in scenarios where local and remote chapter lists need to be synchronized. - * - It iterates over the union of the URLs from both local and remote chapters. - * - For each URL, it compares the corresponding local and remote chapters based on the lastModifiedAt value. - * - If only one source (local or remote) has the chapter for a URL, that chapter is used. - * - If both sources have the chapter, the one with the more recent lastModifiedAt value is chosen. - * - If lastModifiedAt is null or missing, the chapter is considered the oldest for safety, ensuring that any chapter with a valid timestamp is preferred. - * - The resulting list contains the most recent chapters from the combined set of local and remote chapters. - */ + * Merges two lists of BackupChapter objects, selecting the most recent chapter based on the lastModifiedAt value. + * If lastModifiedAt is null for a chapter, it treats that chapter as the oldest possible for comparison purposes. + * This function is designed to reconcile local and remote chapter lists, ensuring the most up-to-date chapter is retained. + * + * @param localChapters The list of local BackupChapter objects. + * @param remoteChapters The list of remote BackupChapter objects. + * @return A list of BackupChapter objects, each representing the most recent version of the chapter from either local or remote sources. + * + * - This function is used in scenarios where local and remote chapter lists need to be synchronized. + * - It iterates over the union of the URLs from both local and remote chapters. + * - For each URL, it compares the corresponding local and remote chapters based on the lastModifiedAt value. + * - If only one source (local or remote) has the chapter for a URL, that chapter is used. + * - If both sources have the chapter, the one with the more recent lastModifiedAt value is chosen. + * - If lastModifiedAt is null or missing, the chapter is considered the oldest for safety, ensuring that any chapter with a valid timestamp is preferred. + * - The resulting list contains the most recent chapters from the combined set of local and remote chapters. + */ private fun mergeChapters( localChapters: List, - remoteChapters: List + remoteChapters: List, ): List { // Associate chapters by URL for both local and remote val localChapterMap = localChapters.associateBy { it.url }