diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index e10c1f1cb..03411356e 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -81,6 +81,7 @@ import tachiyomi.domain.entries.anime.interactor.GetLibraryAnime import tachiyomi.domain.entries.anime.interactor.NetworkToLocalAnime import tachiyomi.domain.entries.anime.interactor.ResetAnimeViewerFlags import tachiyomi.domain.entries.anime.interactor.SetAnimeEpisodeFlags +import tachiyomi.domain.entries.anime.interactor.SetAnimeUpdateInterval import tachiyomi.domain.entries.anime.repository.AnimeRepository import tachiyomi.domain.entries.manga.interactor.GetDuplicateLibraryManga import tachiyomi.domain.entries.manga.interactor.GetLibraryManga @@ -90,6 +91,7 @@ import tachiyomi.domain.entries.manga.interactor.GetMangaWithChapters import tachiyomi.domain.entries.manga.interactor.NetworkToLocalManga import tachiyomi.domain.entries.manga.interactor.ResetMangaViewerFlags import tachiyomi.domain.entries.manga.interactor.SetMangaChapterFlags +import tachiyomi.domain.entries.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.entries.manga.repository.MangaRepository import tachiyomi.domain.history.anime.interactor.GetAnimeHistory import tachiyomi.domain.history.anime.interactor.GetNextEpisodes @@ -182,10 +184,11 @@ class DomainModule : InjektModule { addFactory { GetNextEpisodes(get(), get(), get()) } addFactory { ResetAnimeViewerFlags(get()) } addFactory { SetAnimeEpisodeFlags(get()) } + addFactory { SetAnimeUpdateInterval(get()) } addFactory { SetAnimeDefaultEpisodeFlags(get(), get(), get()) } addFactory { SetAnimeViewerFlags(get()) } addFactory { NetworkToLocalAnime(get()) } - addFactory { UpdateAnime(get()) } + addFactory { UpdateAnime(get(), get()) } addFactory { SetAnimeCategories(get()) } addSingletonFactory { MangaRepositoryImpl(get()) } @@ -197,6 +200,7 @@ class DomainModule : InjektModule { addFactory { GetNextChapters(get(), get(), get()) } addFactory { ResetMangaViewerFlags(get()) } addFactory { SetMangaChapterFlags(get()) } + addFactory { SetMangaUpdateInterval(get()) } addFactory { SetMangaDefaultChapterFlags( get(), @@ -206,7 +210,7 @@ class DomainModule : InjektModule { } addFactory { SetMangaViewerFlags(get()) } addFactory { NetworkToLocalManga(get()) } - addFactory { UpdateManga(get()) } + addFactory { UpdateManga(get(), get()) } addFactory { SetMangaCategories(get()) } addSingletonFactory { ReleaseServiceImpl(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/entries/anime/interactor/UpdateAnime.kt b/app/src/main/java/eu/kanade/domain/entries/anime/interactor/UpdateAnime.kt index de98c68e6..13e88e3d0 100644 --- a/app/src/main/java/eu/kanade/domain/entries/anime/interactor/UpdateAnime.kt +++ b/app/src/main/java/eu/kanade/domain/entries/anime/interactor/UpdateAnime.kt @@ -3,8 +3,7 @@ package eu.kanade.domain.entries.anime.interactor import eu.kanade.domain.entries.anime.model.hasCustomCover import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.data.cache.AnimeCoverCache -import tachiyomi.domain.entries.anime.interactor.getCurrentFetchRange -import tachiyomi.domain.entries.anime.interactor.updateIntervalMeta +import tachiyomi.domain.entries.anime.interactor.SetAnimeUpdateInterval import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.entries.anime.model.AnimeUpdate import tachiyomi.domain.entries.anime.repository.AnimeRepository @@ -17,6 +16,7 @@ import java.util.Date class UpdateAnime( private val animeRepository: AnimeRepository, + private val setAnimeUpdateInterval: SetAnimeUpdateInterval, ) { suspend fun await(animeUpdate: AnimeUpdate): Boolean { @@ -77,16 +77,15 @@ class UpdateAnime( ) } - suspend fun awaitUpdateIntervalMeta( + suspend fun awaitUpdateFetchInterval( anime: Anime, episodes: List, zonedDateTime: ZonedDateTime = ZonedDateTime.now(), - setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), + fetchRange: Pair = setAnimeUpdateInterval.getCurrentFetchRange(zonedDateTime), ): Boolean { - val newMeta = updateIntervalMeta(anime, episodes, zonedDateTime, setCurrentFetchRange) - - return if (newMeta != null) { - animeRepository.updateAnime(newMeta) + val updateAnime = setAnimeUpdateInterval.updateInterval(anime, episodes, zonedDateTime, fetchRange) + return if (updateAnime != null) { + animeRepository.updateAnime(updateAnime) } else { true } diff --git a/app/src/main/java/eu/kanade/domain/entries/manga/interactor/UpdateManga.kt b/app/src/main/java/eu/kanade/domain/entries/manga/interactor/UpdateManga.kt index 579f985e5..23a66f92e 100644 --- a/app/src/main/java/eu/kanade/domain/entries/manga/interactor/UpdateManga.kt +++ b/app/src/main/java/eu/kanade/domain/entries/manga/interactor/UpdateManga.kt @@ -3,8 +3,7 @@ package eu.kanade.domain.entries.manga.interactor import eu.kanade.domain.entries.manga.model.hasCustomCover import eu.kanade.tachiyomi.data.cache.MangaCoverCache import eu.kanade.tachiyomi.source.model.SManga -import tachiyomi.domain.entries.manga.interactor.getCurrentFetchRange -import tachiyomi.domain.entries.manga.interactor.updateIntervalMeta +import tachiyomi.domain.entries.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.MangaUpdate import tachiyomi.domain.entries.manga.repository.MangaRepository @@ -17,6 +16,7 @@ import java.util.Date class UpdateManga( private val mangaRepository: MangaRepository, + private val setMangaUpdateInterval: SetMangaUpdateInterval, ) { suspend fun await(mangaUpdate: MangaUpdate): Boolean { @@ -77,16 +77,15 @@ class UpdateManga( ) } - suspend fun awaitUpdateIntervalMeta( + suspend fun awaitUpdateFetchInterval( manga: Manga, chapters: List, zonedDateTime: ZonedDateTime = ZonedDateTime.now(), - setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), + fetchRange: Pair = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime), ): Boolean { - val newMeta = updateIntervalMeta(manga, chapters, zonedDateTime, setCurrentFetchRange) - - return if (newMeta != null) { - mangaRepository.updateManga(newMeta) + val updatedManga = setMangaUpdateInterval.updateInterval(manga, chapters, zonedDateTime, fetchRange) + return if (updatedManga != null) { + mangaRepository.updateManga(updatedManga) } else { true } diff --git a/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt index 42802ae70..339d6a77e 100644 --- a/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt @@ -23,6 +23,7 @@ import tachiyomi.source.local.entries.manga.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.lang.Long.max +import java.time.ZonedDateTime import java.util.Date import java.util.TreeSet @@ -48,6 +49,9 @@ class SyncChaptersWithSource( rawSourceChapters: List, manga: Manga, source: MangaSource, + manualFetch: Boolean = false, + zoneDateTime: ZonedDateTime = ZonedDateTime.now(), + fetchRange: Pair = Pair(0, 0), ): List { if (rawSourceChapters.isEmpty() && !source.isLocal()) { throw NoChaptersException() @@ -134,6 +138,14 @@ class SyncChaptersWithSource( // Return if there's nothing to add, delete or change, avoiding unnecessary db transactions. if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) { + if (manualFetch || manga.calculateInterval == 0 || manga.nextUpdate < fetchRange.first) { + updateManga.awaitUpdateFetchInterval( + manga, + dbChapters, + zoneDateTime, + fetchRange, + ) + } return emptyList() } @@ -188,6 +200,8 @@ class SyncChaptersWithSource( val chapterUpdates = toChange.map { it.toChapterUpdate() } updateChapter.awaitAll(chapterUpdates) } + val newChapters = chapterRepository.getChapterByMangaId(manga.id) + updateManga.awaitUpdateFetchInterval(manga, newChapters, zoneDateTime, fetchRange) // Set this manga as updated since chapters were changed // Note that last_update actually represents last time the chapter list changed at all diff --git a/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt b/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt index 9132080cf..1472b0ea6 100644 --- a/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt @@ -23,6 +23,7 @@ import tachiyomi.source.local.entries.anime.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.lang.Long.max +import java.time.ZonedDateTime import java.util.Date import java.util.TreeSet @@ -48,6 +49,9 @@ class SyncEpisodesWithSource( rawSourceEpisodes: List, anime: Anime, source: AnimeSource, + manualFetch: Boolean = false, + zoneDateTime: ZonedDateTime = ZonedDateTime.now(), + fetchRange: Pair = Pair(0, 0), ): List { if (rawSourceEpisodes.isEmpty() && !source.isLocal()) { throw NoEpisodesException() @@ -134,6 +138,14 @@ class SyncEpisodesWithSource( // Return if there's nothing to add, delete or change, avoiding unnecessary db transactions. if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) { + if (manualFetch || anime.calculateInterval == 0 || anime.nextUpdate < fetchRange.first) { + updateAnime.awaitUpdateFetchInterval( + anime, + dbEpisodes, + zoneDateTime, + fetchRange, + ) + } return emptyList() } @@ -188,6 +200,8 @@ class SyncEpisodesWithSource( val episodeUpdates = toChange.map { it.toEpisodeUpdate() } updateEpisode.awaitAll(episodeUpdates) } + val newChapters = episodeRepository.getEpisodeByAnimeId(anime.id) + updateAnime.awaitUpdateFetchInterval(anime, newChapters, zoneDateTime, fetchRange) // Set this anime as updated since episodes were changed // Note that last_update actually represents last time the episode list changed at all diff --git a/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt b/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt index 1a7683293..89bb61b4f 100644 --- a/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt @@ -13,18 +13,13 @@ import java.util.Date fun RelativeDateHeader( modifier: Modifier = Modifier, date: Date, - relativeTime: Int, dateFormat: DateFormat, ) { val context = LocalContext.current ListGroupHeader( modifier = modifier, text = remember { - date.toRelativeString( - context, - relativeTime, - dateFormat, - ) + date.toRelativeString(context, dateFormat) }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/entries/ItemsDialogs.kt b/app/src/main/java/eu/kanade/presentation/entries/ItemsDialogs.kt index 07336dd45..5d5db9def 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/ItemsDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/ItemsDialogs.kt @@ -1,11 +1,23 @@ package eu.kanade.presentation.entries +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import eu.kanade.tachiyomi.R +import tachiyomi.domain.entries.anime.interactor.MAX_GRACE_PERIOD +import tachiyomi.presentation.core.components.WheelTextPicker @Composable fun DeleteItemsDialog( @@ -39,3 +51,51 @@ fun DeleteItemsDialog( }, ) } + +@Composable +fun SetIntervalDialog( + interval: Int, + onDismissRequest: () -> Unit, + onValueChanged: (Int) -> Unit, +) { + var intervalValue by rememberSaveable { mutableIntStateOf(interval) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.manga_modify_calculated_interval_title)) }, + text = { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val size = DpSize(width = maxWidth / 2, height = 128.dp) + val items = (0..MAX_GRACE_PERIOD).map { + if (it == 0) { + stringResource(R.string.label_default) + } else { + it.toString() + } + } + WheelTextPicker( + size = size, + items = items, + startIndex = intervalValue, + onSelectionChanged = { intervalValue = it }, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + onValueChanged(intervalValue) + onDismissRequest() + },) { + Text(text = stringResource(R.string.action_ok)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt index 837b55c1b..b85818594 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/AnimeScreen.kt @@ -97,7 +97,7 @@ import java.util.concurrent.TimeUnit fun AnimeScreen( state: AnimeScreenModel.State.Success, snackbarHostState: SnackbarHostState, - dateRelativeTime: Int, + intervalDisplay: () -> Pair?, dateFormat: DateFormat, isTabletUi: Boolean, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, @@ -127,6 +127,7 @@ fun AnimeScreen( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, changeAnimeSkipIntro: (() -> Unit)?, @@ -160,8 +161,8 @@ fun AnimeScreen( AnimeScreenSmallImpl( state = state, snackbarHostState = snackbarHostState, - dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, + intervalDisplay = intervalDisplay, episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeEndAction = episodeSwipeEndAction, showNextEpisodeAirTime = showNextEpisodeAirTime, @@ -183,6 +184,7 @@ fun AnimeScreen( onShareClicked = onShareClicked, onDownloadActionClicked = onDownloadActionClicked, onEditCategoryClicked = onEditCategoryClicked, + onEditIntervalClicked = onEditIntervalClicked, onMigrateClicked = onMigrateClicked, changeAnimeSkipIntro = changeAnimeSkipIntro, onMultiBookmarkClicked = onMultiBookmarkClicked, @@ -199,12 +201,12 @@ fun AnimeScreen( AnimeScreenLargeImpl( state = state, snackbarHostState = snackbarHostState, - dateRelativeTime = dateRelativeTime, episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeEndAction = episodeSwipeEndAction, showNextEpisodeAirTime = showNextEpisodeAirTime, alwaysUseExternalPlayer = alwaysUseExternalPlayer, dateFormat = dateFormat, + intervalDisplay = intervalDisplay, onBackClicked = onBackClicked, onEpisodeClicked = onEpisodeClicked, onDownloadEpisode = onDownloadEpisode, @@ -222,6 +224,7 @@ fun AnimeScreen( onShareClicked = onShareClicked, onDownloadActionClicked = onDownloadActionClicked, onEditCategoryClicked = onEditCategoryClicked, + onEditIntervalClicked = onEditIntervalClicked, changeAnimeSkipIntro = changeAnimeSkipIntro, onMigrateClicked = onMigrateClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, @@ -242,8 +245,8 @@ fun AnimeScreen( private fun AnimeScreenSmallImpl( state: AnimeScreenModel.State.Success, snackbarHostState: SnackbarHostState, - dateRelativeTime: Int, dateFormat: DateFormat, + intervalDisplay: () -> Pair?, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction, showNextEpisodeAirTime: Boolean, @@ -272,6 +275,7 @@ private fun AnimeScreenSmallImpl( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, changeAnimeSkipIntro: (() -> Unit)?, onSettingsClicked: (() -> Unit)?, @@ -312,9 +316,11 @@ private fun AnimeScreenSmallImpl( } val animatedTitleAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0) 1f else 0f, + label = "titleAlpha", ) val animatedBgAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, + label = "bgAlpha", ) EntryToolbar( title = state.anime.title, @@ -426,10 +432,13 @@ private fun AnimeScreenSmallImpl( AnimeActionRow( favorite = state.anime.favorite, trackingCount = state.trackingCount, + intervalDisplay = intervalDisplay, + isUserIntervalMode = state.anime.calculateInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, ) } @@ -488,7 +497,6 @@ private fun AnimeScreenSmallImpl( sharedEpisodeItems( anime = state.anime, episodes = episodes, - dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeEndAction = episodeSwipeEndAction, @@ -508,8 +516,8 @@ private fun AnimeScreenSmallImpl( fun AnimeScreenLargeImpl( state: AnimeScreenModel.State.Success, snackbarHostState: SnackbarHostState, - dateRelativeTime: Int, dateFormat: DateFormat, + intervalDisplay: () -> Pair?, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction, showNextEpisodeAirTime: Boolean, @@ -538,6 +546,7 @@ fun AnimeScreenLargeImpl( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, changeAnimeSkipIntro: (() -> Unit)?, onSettingsClicked: (() -> Unit)?, @@ -676,10 +685,13 @@ fun AnimeScreenLargeImpl( AnimeActionRow( favorite = state.anime.favorite, trackingCount = state.trackingCount, + intervalDisplay = intervalDisplay, + isUserIntervalMode = state.anime.calculateInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, ) ExpandableAnimeDescription( @@ -745,7 +757,6 @@ fun AnimeScreenLargeImpl( sharedEpisodeItems( anime = state.anime, episodes = episodes, - dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeEndAction = episodeSwipeEndAction, @@ -816,7 +827,6 @@ private fun SharedAnimeBottomActionMenu( private fun LazyListScope.sharedEpisodeItems( anime: Anime, episodes: List, - dateRelativeTime: Int, dateFormat: DateFormat, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction, @@ -845,11 +855,7 @@ private fun LazyListScope.sharedEpisodeItems( date = episodeItem.episode.dateUpload .takeIf { it > 0L } ?.let { - Date(it).toRelativeString( - context, - dateRelativeTime, - dateFormat, - ) + Date(it).toRelativeString(context, dateFormat) }, watchProgress = episodeItem.episode.lastSecondSeen .takeIf { !episodeItem.episode.seen && it > 0L } diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeInfoHeader.kt index 1f61dd49f..6085baf9a 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeInfoHeader.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.outlined.AttachMoney import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Close @@ -166,14 +167,19 @@ fun AnimeActionRow( modifier: Modifier = Modifier, favorite: Boolean, trackingCount: Int, + intervalDisplay: () -> Pair?, + isUserIntervalMode: Boolean, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, onTrackingClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onEditCategory: (() -> Unit)?, ) { + val interval: Pair? = intervalDisplay() + val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) + Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { - val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) AnimeActionButton( title = if (favorite) { stringResource(R.string.in_library) @@ -185,6 +191,19 @@ fun AnimeActionRow( onClick = onAddToLibraryClicked, onLongClick = onEditCategory, ) + if (onEditIntervalClicked != null && interval != null) { + AnimeActionButton( + title = + if (interval.first == interval.second) { + pluralStringResource(id = R.plurals.day, count = interval.second, interval.second) + } else { + pluralStringResource(id = R.plurals.range_interval_day, count = interval.second, interval.first, interval.second) + }, + icon = Icons.Default.HourglassEmpty, + color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, + onClick = onEditIntervalClicked, + ) + } if (onTrackingClicked != null) { AnimeActionButton( title = if (trackingCount == 0) { diff --git a/app/src/main/java/eu/kanade/presentation/entries/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/entries/manga/MangaScreen.kt index a6bcd0af6..5c47be861 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/manga/MangaScreen.kt @@ -90,7 +90,7 @@ import java.util.Date fun MangaScreen( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - dateRelativeTime: Int, + intervalDisplay: () -> Pair?, dateFormat: DateFormat, isTabletUi: Boolean, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, @@ -118,6 +118,7 @@ fun MangaScreen( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, // For bottom action menu @@ -150,8 +151,8 @@ fun MangaScreen( MangaScreenSmallImpl( state = state, snackbarHostState = snackbarHostState, - dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, + intervalDisplay = intervalDisplay, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onBackClicked = onBackClicked, @@ -171,6 +172,7 @@ fun MangaScreen( onShareClicked = onShareClicked, onDownloadActionClicked = onDownloadActionClicked, onEditCategoryClicked = onEditCategoryClicked, + onEditIntervalClicked = onEditIntervalClicked, onMigrateClicked = onMigrateClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, @@ -186,10 +188,10 @@ fun MangaScreen( MangaScreenLargeImpl( state = state, snackbarHostState = snackbarHostState, - dateRelativeTime = dateRelativeTime, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, dateFormat = dateFormat, + intervalDisplay = intervalDisplay, onBackClicked = onBackClicked, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, @@ -207,6 +209,7 @@ fun MangaScreen( onShareClicked = onShareClicked, onDownloadActionClicked = onDownloadActionClicked, onEditCategoryClicked = onEditCategoryClicked, + onEditIntervalClicked = onEditIntervalClicked, onMigrateClicked = onMigrateClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, @@ -225,8 +228,8 @@ fun MangaScreen( private fun MangaScreenSmallImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - dateRelativeTime: Int, dateFormat: DateFormat, + intervalDisplay: () -> Pair?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -253,6 +256,7 @@ private fun MangaScreenSmallImpl( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, onSettingsClicked: (() -> Unit)?, @@ -293,9 +297,11 @@ private fun MangaScreenSmallImpl( } val animatedTitleAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0) 1f else 0f, + label = "titleAlpha", ) val animatedBgAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, + label = "bgAlpha", ) EntryToolbar( title = state.manga.title, @@ -400,10 +406,13 @@ private fun MangaScreenSmallImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, + intervalDisplay = intervalDisplay, + isUserIntervalMode = state.manga.calculateInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, ) } @@ -437,7 +446,6 @@ private fun MangaScreenSmallImpl( sharedChapterItems( manga = state.manga, chapters = chapters, - dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, @@ -456,8 +464,8 @@ private fun MangaScreenSmallImpl( fun MangaScreenLargeImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - dateRelativeTime: Int, dateFormat: DateFormat, + intervalDisplay: () -> Pair?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -484,6 +492,7 @@ fun MangaScreenLargeImpl( onShareClicked: (() -> Unit)?, onDownloadActionClicked: ((DownloadAction) -> Unit)?, onEditCategoryClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onMigrateClicked: (() -> Unit)?, onSettingsClicked: (() -> Unit)?, @@ -618,10 +627,13 @@ fun MangaScreenLargeImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, + intervalDisplay = intervalDisplay, + isUserIntervalMode = state.manga.calculateInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, ) ExpandableMangaDescription( @@ -662,7 +674,6 @@ fun MangaScreenLargeImpl( sharedChapterItems( manga = state.manga, chapters = chapters, - dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, @@ -725,7 +736,6 @@ private fun SharedMangaBottomActionMenu( private fun LazyListScope.sharedChapterItems( manga: Manga, chapters: List, - dateRelativeTime: Int, dateFormat: DateFormat, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, @@ -754,11 +764,7 @@ private fun LazyListScope.sharedChapterItems( date = chapterItem.chapter.dateUpload .takeIf { it > 0L } ?.let { - Date(it).toRelativeString( - context, - dateRelativeTime, - dateFormat, - ) + Date(it).toRelativeString(context, dateFormat) }, readProgress = chapterItem.chapter.lastPageRead .takeIf { !chapterItem.chapter.read && it > 0L } diff --git a/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaInfoHeader.kt index 50424ae9d..dcf0cb5a4 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaInfoHeader.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.AttachMoney import androidx.compose.material.icons.outlined.Block @@ -166,14 +167,19 @@ fun MangaActionRow( modifier: Modifier = Modifier, favorite: Boolean, trackingCount: Int, + intervalDisplay: () -> Pair?, + isUserIntervalMode: Boolean, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, onTrackingClicked: (() -> Unit)?, + onEditIntervalClicked: (() -> Unit)?, onEditCategory: (() -> Unit)?, ) { + val interval: Pair? = intervalDisplay() + val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) + Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { - val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) MangaActionButton( title = if (favorite) { stringResource(R.string.in_library) @@ -185,6 +191,19 @@ fun MangaActionRow( onClick = onAddToLibraryClicked, onLongClick = onEditCategory, ) + if (onEditIntervalClicked != null && interval != null) { + MangaActionButton( + title = + if (interval.first == interval.second) { + pluralStringResource(id = R.plurals.day, count = interval.second, interval.second) + } else { + pluralStringResource(id = R.plurals.range_interval_day, count = interval.second, interval.first, interval.second) + }, + icon = Icons.Default.HourglassEmpty, + color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, + onClick = onEditIntervalClicked, + ) + } if (onTrackingClicked != null) { MangaActionButton( title = if (trackingCount == 0) { diff --git a/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryContent.kt b/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryContent.kt index 490ede2c7..e8daba532 100644 --- a/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryContent.kt @@ -24,7 +24,6 @@ fun AnimeHistoryContent( onClickDelete: (AnimeHistoryWithRelations) -> Unit, preferences: UiPreferences = Injekt.get(), ) { - val relativeTime: Int = remember { preferences.relativeTime().get() } val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } FastScrollLazyColumn( @@ -45,7 +44,6 @@ fun AnimeHistoryContent( RelativeDateHeader( modifier = Modifier.animateItemPlacement(), date = item.date, - relativeTime = relativeTime, dateFormat = dateFormat, ) } diff --git a/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryContent.kt b/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryContent.kt index b606684ea..2bf47c83a 100644 --- a/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryContent.kt @@ -22,7 +22,6 @@ fun MangaHistoryContent( onClickDelete: (MangaHistoryWithRelations) -> Unit, preferences: UiPreferences = Injekt.get(), ) { - val relativeTime: Int = remember { preferences.relativeTime().get() } val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } FastScrollLazyColumn( @@ -43,7 +42,6 @@ fun MangaHistoryContent( RelativeDateHeader( modifier = Modifier.animateItemPlacement(), date = item.date, - relativeTime = relativeTime, dateFormat = dateFormat, ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt index 4730cf226..bed35bc53 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt @@ -51,7 +51,6 @@ object SettingsAppearanceScreen : SearchableSettings { return listOf( getThemeGroup(context = context, uiPreferences = uiPreferences), getDisplayGroup(context = context, uiPreferences = uiPreferences), - getTimestampGroup(uiPreferences = uiPreferences), ) } @@ -124,6 +123,7 @@ object SettingsAppearanceScreen : SearchableSettings { ): Preference.PreferenceGroup { val langs = remember { getLangs(context) } var currentLanguage by remember { mutableStateOf(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "") } + val now = remember { Date().time } LaunchedEffect(currentLanguage) { val locale = if (currentLanguage.isEmpty()) { @@ -186,25 +186,6 @@ object SettingsAppearanceScreen : SearchableSettings { true }, ), - ), - ) - } - - @Composable - private fun getTimestampGroup(uiPreferences: UiPreferences): Preference.PreferenceGroup { - val now = remember { Date().time } - return Preference.PreferenceGroup( - title = stringResource(R.string.pref_category_timestamps), - preferenceItems = listOf( - Preference.PreferenceItem.ListPreference( - pref = uiPreferences.relativeTime(), - title = stringResource(R.string.pref_relative_format), - entries = mapOf( - 0 to stringResource(R.string.off), - 2 to stringResource(R.string.pref_relative_time_short), - 7 to stringResource(R.string.pref_relative_time_long), - ), - ), Preference.PreferenceItem.ListPreference( pref = uiPreferences.dateFormat(), title = stringResource(R.string.pref_date_format), @@ -217,7 +198,6 @@ object SettingsAppearanceScreen : SearchableSettings { ), ) } - private fun getLangs(context: Context): Map { val langs = mutableListOf>() val parser = context.resources.getXml(R.xml.locales_config) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 370b0ab0d..15f1de43e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -292,20 +292,17 @@ object SettingsLibraryScreen : SearchableSettings { true }, ), - // TODO: remove isDevFlavor checks once functionality is available Preference.PreferenceItem.MultiSelectListPreference( pref = libraryUpdateDeviceRestrictionPref, enabled = libraryUpdateInterval > 0, title = stringResource(R.string.pref_library_update_restriction), subtitle = stringResource(R.string.restrictions), - entries = buildMap { - put(ENTRY_HAS_UNVIEWED, stringResource(R.string.pref_update_only_completely_read)) - put(ENTRY_NON_VIEWED, stringResource(R.string.pref_update_only_started)) - put(ENTRY_NON_COMPLETED, stringResource(R.string.pref_update_only_non_completed)) - if (isDevFlavor) { - put(ENTRY_OUTSIDE_RELEASE_PERIOD, stringResource(R.string.pref_update_only_in_release_period)) - } - }, + entries = mapOf( + ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read), + ENTRY_NON_VIEWED to stringResource(R.string.pref_update_only_started), + ENTRY_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed), + ENTRY_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period), + ), onValueChanged = { // Post to event looper to allow the preference to be updated. ContextCompat.getMainExecutor(context).execute { diff --git a/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt index 2eb2b37ab..9d39940bd 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt @@ -40,7 +40,6 @@ fun AnimeUpdateScreen( snackbarHostState: SnackbarHostState, contentPadding: PaddingValues, lastUpdated: Long, - relativeTime: Int, onClickCover: (AnimeUpdatesItem) -> Unit, onSelectAll: (Boolean) -> Unit, onInvertSelection: () -> Unit, @@ -101,7 +100,7 @@ fun AnimeUpdateScreen( animeUpdatesLastUpdatedItem(lastUpdated) } animeUpdatesUiItems( - uiModels = state.getUiModel(context, relativeTime), + uiModels = state.getUiModel(context), selectionMode = state.selectionMode, onUpdateSelected = onUpdateSelected, onClickCover = onClickCover, diff --git a/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt index 47e5b2419..c79498a27 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt @@ -37,7 +37,6 @@ fun MangaUpdateScreen( snackbarHostState: SnackbarHostState, contentPadding: PaddingValues, lastUpdated: Long, - relativeTime: Int, onClickCover: (MangaUpdatesItem) -> Unit, onSelectAll: (Boolean) -> Unit, onInvertSelection: () -> Unit, @@ -98,7 +97,7 @@ fun MangaUpdateScreen( } mangaUpdatesUiItems( - uiModels = state.getUiModel(context, relativeTime), + uiModels = state.getUiModel(context), selectionMode = state.selectionMode, onUpdateSelected = onUpdateSelected, onClickCover = onClickCover, diff --git a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt index 439809bbd..8e8fb56e7 100644 --- a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt +++ b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt @@ -72,7 +72,7 @@ fun WebViewScreenContent( super.onPageFinished(view, url) scope.launch { val html = view.getHtml() - showCloudflareHelp = "Checking if the site connection is secure" in html + showCloudflareHelp = "window._cf_chl_opt" in html } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt index 53f979c73..166b0c98a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt @@ -5,6 +5,8 @@ import android.content.Intent import android.content.SharedPreferences import android.net.Uri import androidx.preference.PreferenceManager +import eu.kanade.domain.entries.anime.interactor.UpdateAnime +import eu.kanade.domain.entries.manga.interactor.UpdateManga import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.models.BackupAnime import eu.kanade.tachiyomi.data.backup.models.BackupAnimeHistory @@ -28,14 +30,21 @@ import eu.kanade.tachiyomi.util.system.createFileInCacheDir import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import tachiyomi.core.util.system.logcat +import tachiyomi.domain.entries.anime.interactor.SetAnimeUpdateInterval import tachiyomi.domain.entries.anime.model.Anime +import tachiyomi.domain.entries.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.items.chapter.model.Chapter +import tachiyomi.domain.items.chapter.repository.ChapterRepository import tachiyomi.domain.items.episode.model.Episode +import tachiyomi.domain.items.episode.repository.EpisodeRepository import tachiyomi.domain.track.anime.model.AnimeTrack import tachiyomi.domain.track.manga.model.MangaTrack +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 @@ -43,6 +52,17 @@ class BackupRestorer( private val context: Context, private val notifier: BackupNotifier, ) { + private val updateManga: UpdateManga = Injekt.get() + private val chapterRepository: ChapterRepository = Injekt.get() + private val setMangaUpdateInterval: SetMangaUpdateInterval = Injekt.get() + + private val updateAnime: UpdateAnime = Injekt.get() + private val episodeRepository: EpisodeRepository = Injekt.get() + private val setAnimeUpdateInterval: SetAnimeUpdateInterval = Injekt.get() + + private var zonedDateTime = ZonedDateTime.now() + private var currentMangaRange = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime) + private var currentAnimeRange = setAnimeUpdateInterval.getCurrentFetchRange(zonedDateTime) private var backupManager = BackupManager(context) @@ -120,6 +140,10 @@ class BackupRestorer( val backupAnimeMaps = backup.backupBrokenAnimeSources.map { BackupAnimeSource(it.name, it.sourceId) } + backup.backupAnimeSources animeSourceMapping = backupAnimeMaps.associate { it.sourceId to it.name } + zonedDateTime = ZonedDateTime.now() + currentMangaRange = setMangaUpdateInterval.getCurrentFetchRange(zonedDateTime) + currentAnimeRange = setAnimeUpdateInterval.getCurrentFetchRange(zonedDateTime) + return coroutineScope { // Restore individual manga backup.backupManga.forEach { @@ -182,7 +206,7 @@ class BackupRestorer( try { val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source) - if (dbManga == null) { + val restoredManga = if (dbManga == null) { // Manga not in database restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) } else { @@ -192,6 +216,8 @@ class BackupRestorer( // Fetch rest of manga information restoreNewManga(updateManga, chapters, categories, history, tracks, backupCategories) } + val updatedChapters = chapterRepository.getChapterByMangaId(restoredManga.id) + updateManga.awaitUpdateFetchInterval(restoredManga, updatedChapters, zonedDateTime, currentMangaRange) } catch (e: Exception) { val sourceName = sourceMapping[manga.source] ?: manga.source.toString() errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") @@ -219,10 +245,11 @@ class BackupRestorer( history: List, tracks: List, backupCategories: List, - ) { + ): Manga { val fetchedManga = backupManager.restoreNewManga(manga) backupManager.restoreChapters(fetchedManga, chapters) restoreExtras(fetchedManga, categories, history, tracks, backupCategories) + return fetchedManga } private suspend fun restoreNewManga( @@ -232,9 +259,10 @@ class BackupRestorer( history: List, tracks: List, backupCategories: List, - ) { + ): Manga { backupManager.restoreChapters(backupManga, chapters) restoreExtras(backupManga, categories, history, tracks, backupCategories) + return backupManga } private suspend fun restoreExtras(manga: Manga, categories: List, history: List, tracks: List, backupCategories: List) { @@ -253,7 +281,7 @@ class BackupRestorer( try { val dbAnime = backupManager.getAnimeFromDatabase(anime.url, anime.source) - if (dbAnime == null) { + val restoredAnime = if (dbAnime == null) { // Anime not in database restoreExistingAnime(anime, episodes, categories, history, tracks, backupCategories) } else { @@ -263,6 +291,8 @@ class BackupRestorer( // Fetch rest of anime information restoreNewAnime(updateAnime, episodes, categories, history, tracks, backupCategories) } + val updatedEpisodes = episodeRepository.getEpisodeByAnimeId(restoredAnime.id) + updateAnime.awaitUpdateFetchInterval(restoredAnime, updatedEpisodes, zonedDateTime, currentAnimeRange) } catch (e: Exception) { val sourceName = sourceMapping[anime.source] ?: anime.source.toString() errors.add(Date() to "${anime.title} [$sourceName]: ${e.message}") @@ -290,10 +320,11 @@ class BackupRestorer( history: List, tracks: List, backupCategories: List, - ) { + ): Anime { val fetchedAnime = backupManager.restoreNewAnime(anime) backupManager.restoreEpisodes(fetchedAnime, episodes) restoreExtras(fetchedAnime, categories, history, tracks, backupCategories) + return fetchedAnime } private suspend fun restoreNewAnime( @@ -303,9 +334,10 @@ class BackupRestorer( history: List, tracks: List, backupCategories: List, - ) { + ): Anime { backupManager.restoreEpisodes(backupAnime, episodes) restoreExtras(backupAnime, categories, history, tracks, backupCategories) + return backupAnime } private suspend fun restoreExtras(anime: Anime, categories: List, history: List, tracks: List, backupCategories: List) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt index fbaae4285..32cd4b8a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt @@ -56,6 +56,7 @@ import tachiyomi.domain.category.model.Category import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.entries.anime.interactor.GetAnime import tachiyomi.domain.entries.anime.interactor.GetLibraryAnime +import tachiyomi.domain.entries.anime.interactor.SetAnimeUpdateInterval import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.entries.anime.model.toAnimeUpdate import tachiyomi.domain.items.episode.interactor.GetEpisodeByAnimeId @@ -70,6 +71,7 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_HAS_UNVIEWED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_VIEWED +import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_OUTSIDE_RELEASE_PERIOD import tachiyomi.domain.source.anime.model.AnimeSourceNotInstalledException import tachiyomi.domain.source.anime.service.AnimeSourceManager import tachiyomi.domain.track.anime.interactor.GetAnimeTracks @@ -77,6 +79,7 @@ import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File +import java.time.ZonedDateTime import java.util.Date import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit @@ -101,6 +104,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa private val getTracks: GetAnimeTracks = Injekt.get() private val insertTrack: InsertAnimeTrack = Injekt.get() private val syncEpisodesWithTrackServiceTwoWay: SyncEpisodesWithTrackServiceTwoWay = Injekt.get() + private val setAnimeUpdateInterval: SetAnimeUpdateInterval = Injekt.get() private val notifier = AnimeLibraryUpdateNotifier(context) @@ -227,6 +231,10 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa val hasDownloads = AtomicBoolean(false) val restrictions = libraryPreferences.libraryUpdateItemRestriction().get() + val now = ZonedDateTime.now() + val fetchRange = setAnimeUpdateInterval.getCurrentFetchRange(now) + val higherLimit = fetchRange.second + coroutineScope { animeToUpdate.groupBy { it.anime.source }.values .map { animeInSource -> @@ -247,6 +255,9 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa anime, ) { when { + ENTRY_OUTSIDE_RELEASE_PERIOD in restrictions && anime.nextUpdate > higherLimit -> + skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_in_release_period)) + ENTRY_NON_COMPLETED in restrictions && anime.status.toInt() == SAnime.COMPLETED -> skippedUpdates.add(anime to context.getString(R.string.skipped_reason_completed)) @@ -261,7 +272,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa else -> { try { - val newEpisodes = updateAnime(anime) + val newEpisodes = updateAnime(anime, now, fetchRange) .sortedByDescending { it.sourceOrder } if (newEpisodes.isNotEmpty()) { @@ -333,7 +344,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa * @param anime the anime to update. * @return a pair of the inserted and removed episodes. */ - private suspend fun updateAnime(anime: Anime): List { + private suspend fun updateAnime(anime: Anime, zoneDateTime: ZonedDateTime, fetchRange: Pair): List { val source = sourceManager.getOrStub(anime.source) // Update anime metadata if needed @@ -348,7 +359,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa // to get latest data so it doesn't get overwritten later on val dbAnime = getAnime.await(anime.id)?.takeIf { it.favorite } ?: return emptyList() - return syncEpisodesWithSource.await(episodes, dbAnime, source) + return syncEpisodesWithSource.await(episodes, dbAnime, source, false, zoneDateTime, fetchRange) } private suspend fun updateCovers() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt index 8ed428605..a0ab7df5e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt @@ -56,6 +56,7 @@ import tachiyomi.domain.category.model.Category import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.entries.manga.interactor.GetLibraryManga import tachiyomi.domain.entries.manga.interactor.GetManga +import tachiyomi.domain.entries.manga.interactor.SetMangaUpdateInterval import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.toMangaUpdate import tachiyomi.domain.items.chapter.interactor.GetChapterByMangaId @@ -70,6 +71,7 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_HAS_UNVIEWED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_VIEWED +import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_OUTSIDE_RELEASE_PERIOD import tachiyomi.domain.source.manga.model.SourceNotInstalledException import tachiyomi.domain.source.manga.service.MangaSourceManager import tachiyomi.domain.track.manga.interactor.GetMangaTracks @@ -77,6 +79,7 @@ import tachiyomi.domain.track.manga.interactor.InsertMangaTrack import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File +import java.time.ZonedDateTime import java.util.Date import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit @@ -101,6 +104,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa private val getTracks: GetMangaTracks = Injekt.get() private val insertTrack: InsertMangaTrack = Injekt.get() private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get() + private val setMangaUpdateInterval: SetMangaUpdateInterval = Injekt.get() private val notifier = MangaLibraryUpdateNotifier(context) @@ -227,6 +231,10 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa val hasDownloads = AtomicBoolean(false) val restrictions = libraryPreferences.libraryUpdateItemRestriction().get() + val now = ZonedDateTime.now() + val fetchRange = setMangaUpdateInterval.getCurrentFetchRange(now) + val higherLimit = fetchRange.second + coroutineScope { mangaToUpdate.groupBy { it.manga.source }.values .map { mangaInSource -> @@ -247,6 +255,9 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa manga, ) { when { + ENTRY_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate > higherLimit -> + skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_in_release_period)) + ENTRY_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED -> skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed)) @@ -261,7 +272,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa else -> { try { - val newChapters = updateManga(manga) + val newChapters = updateManga(manga, now, fetchRange) .sortedByDescending { it.sourceOrder } if (newChapters.isNotEmpty()) { @@ -333,7 +344,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa * @param manga the manga to update. * @return a pair of the inserted and removed chapters. */ - private suspend fun updateManga(manga: Manga): List { + private suspend fun updateManga(manga: Manga, zoneDateTime: ZonedDateTime, fetchRange: Pair): List { val source = sourceManager.getOrStub(manga.source) // Update manga metadata if needed @@ -348,7 +359,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa // to get latest data so it doesn't get overwritten later on val dbManga = getManga.await(manga.id)?.takeIf { it.favorite } ?: return emptyList() - return syncChaptersWithSource.await(chapters, dbManga, source) + return syncChaptersWithSource.await(chapters, dbManga, source, false, zoneDateTime, fetchRange) } private suspend fun updateCovers() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt index 0fbdb780b..5315395e0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt @@ -27,6 +27,7 @@ import eu.kanade.presentation.category.ChangeCategoryDialog import eu.kanade.presentation.components.NavigatorAdaptiveSheet import eu.kanade.presentation.entries.DeleteItemsDialog import eu.kanade.presentation.entries.EditCoverAction +import eu.kanade.presentation.entries.SetIntervalDialog import eu.kanade.presentation.entries.anime.AnimeScreen import eu.kanade.presentation.entries.anime.DuplicateAnimeDialog import eu.kanade.presentation.entries.anime.EpisodeOptionsDialogScreen @@ -104,8 +105,8 @@ class AnimeScreen( AnimeScreen( state = successState, snackbarHostState = screenModel.snackbarHostState, - dateRelativeTime = screenModel.relativeTime, dateFormat = screenModel.dateFormat, + intervalDisplay = screenModel::intervalDisplay, isTabletUi = isTabletUi(), episodeSwipeStartAction = screenModel.episodeSwipeStartAction, episodeSwipeEndAction = screenModel.episodeSwipeEndAction, @@ -139,7 +140,8 @@ class AnimeScreen( onCoverClicked = screenModel::showCoverDialog, onShareClicked = { shareAnime(context, screenModel.anime, screenModel.source) }.takeIf { isAnimeHttpSource }, onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, - onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.anime.favorite }, + onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.anime.favorite }, + onEditIntervalClicked = screenModel::showSetAnimeIntervalDialog.takeIf { screenModel.isIntervalEnabled && successState.anime.favorite }, onMigrateClicked = { navigator.push(MigrateAnimeSearchScreen(successState.anime.id)) }.takeIf { successState.anime.favorite }, changeAnimeSkipIntro = screenModel::showAnimeSkipIntroDialog.takeIf { successState.anime.favorite }, onMultiBookmarkClicked = screenModel::bookmarkEpisodes, @@ -233,6 +235,13 @@ class AnimeScreen( LoadingScreen(Modifier.systemBarsPadding()) } } + is AnimeScreenModel.Dialog.SetAnimeInterval -> { + SetIntervalDialog( + interval = if (dialog.anime.calculateInterval < 0) -dialog.anime.calculateInterval else 0, + onDismissRequest = onDismissRequest, + onValueChanged = { screenModel.setFetchRangeInterval(dialog.anime, it) }, + ) + } AnimeScreenModel.Dialog.ChangeAnimeSkipIntro -> { fun updateSkipIntroLength(newLength: Long) { scope.launchIO { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt index 8508bbaac..efe837e58 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.coroutineScope -import eu.kanade.core.preference.asState import eu.kanade.core.util.addOrRemove import eu.kanade.domain.entries.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.entries.anime.interactor.UpdateAnime @@ -65,6 +64,7 @@ import tachiyomi.domain.entries.anime.interactor.GetAnimeWithEpisodes import tachiyomi.domain.entries.anime.interactor.GetDuplicateLibraryAnime import tachiyomi.domain.entries.anime.interactor.SetAnimeEpisodeFlags import tachiyomi.domain.entries.anime.model.Anime +import tachiyomi.domain.entries.anime.repository.AnimeRepository import tachiyomi.domain.entries.applyFilter import tachiyomi.domain.items.episode.interactor.SetAnimeDefaultEpisodeFlags import tachiyomi.domain.items.episode.interactor.UpdateEpisode @@ -79,6 +79,7 @@ import tachiyomi.source.local.entries.anime.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Calendar +import kotlin.math.absoluteValue class AnimeScreenModel( val context: Context, @@ -103,6 +104,7 @@ class AnimeScreenModel( private val getCategories: GetAnimeCategories = Injekt.get(), private val getTracks: GetAnimeTracks = Injekt.get(), private val setAnimeCategories: SetAnimeCategories = Injekt.get(), + private val animeRepository: AnimeRepository = Injekt.get(), internal val setAnimeViewerFlags: SetAnimeViewerFlags = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), ) : StateScreenModel(State.Loading) { @@ -131,9 +133,12 @@ class AnimeScreenModel( val alwaysUseExternalPlayer = playerPreferences.alwaysUseExternalPlayer().get() val useExternalDownloader = downloadPreferences.useExternalDownloader().get() - val relativeTime by uiPreferences.relativeTime().asState(coroutineScope) val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) + val isIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get() + private val leadDay = libraryPreferences.leadingAnimeExpectedDays().get() + private val followDay = libraryPreferences.followingAnimeExpectedDays().get() + private val selectedPositions: Array = arrayOf(-1, -1) // first and last selected index in list private val selectedEpisodeIds: HashSet = HashSet() @@ -320,7 +325,7 @@ class AnimeScreenModel( // Choose a category else -> { isFromChangeCategory = true - promptChangeCategories() + showChangeCategoryDialog() } } @@ -350,7 +355,7 @@ class AnimeScreenModel( } } - fun promptChangeCategories() { + fun showChangeCategoryDialog() { val anime = successState?.anime ?: return coroutineScope.launch { val categories = getCategories() @@ -366,6 +371,37 @@ class AnimeScreenModel( } } + fun showSetAnimeIntervalDialog() { + val anime = successState?.anime ?: return + updateSuccessState { + it.copy(dialog = Dialog.SetAnimeInterval(anime)) + } + } + + // TODO: this should be in the state/composables + fun intervalDisplay(): Pair? { + val anime = successState?.anime ?: return null + val effInterval = anime.calculateInterval + return 1.coerceAtLeast(effInterval.absoluteValue - leadDay) to (effInterval.absoluteValue + followDay) + } + + fun setFetchRangeInterval(anime: Anime, newInterval: Int) { + val interval = when (newInterval) { + // reset interval 0 default to trigger recalculation + // only reset if interval is custom, which is negative + 0 -> if (anime.calculateInterval < 0) 0 else anime.calculateInterval + else -> -newInterval + } + coroutineScope.launchIO { + updateAnime.awaitUpdateFetchInterval( + anime.copy(calculateInterval = interval), + successState?.episodes?.map { it.episode }.orEmpty(), + ) + val newAnime = animeRepository.getAnimeById(animeId) + updateSuccessState { it.copy(anime = newAnime) } + } + } + /** * Returns true if the anime has any downloads. */ @@ -519,6 +555,7 @@ class AnimeScreenModel( episodes, state.anime, state.source, + manualFetch, ) if (manualFetch) { @@ -536,6 +573,8 @@ class AnimeScreenModel( coroutineScope.launch { snackbarHostState.showSnackbar(message = message) } + val newAnime = animeRepository.getAnimeById(animeId) + updateSuccessState { it.copy(anime = newAnime, isRefreshingData = false) } } } @@ -972,6 +1011,7 @@ class AnimeScreenModel( data class ChangeCategory(val anime: Anime, val initialSelection: List>) : Dialog data class DeleteEpisodes(val episodes: List) : Dialog data class DuplicateAnime(val anime: Anime, val duplicate: Anime) : Dialog + data class SetAnimeInterval(val anime: Anime) : Dialog data class ShowQualities(val episode: Episode, val anime: Anime, val source: AnimeSource) : Dialog data object ChangeAnimeSkipIntro : Dialog data object SettingsSheet : Dialog @@ -1009,7 +1049,7 @@ class AnimeScreenModel( sealed interface State { @Immutable - object Loading : State + data object Loading : State @Immutable data class Success( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreen.kt index f80fb4fd5..ad76a7e7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreen.kt @@ -26,6 +26,7 @@ import eu.kanade.presentation.category.ChangeCategoryDialog import eu.kanade.presentation.components.NavigatorAdaptiveSheet import eu.kanade.presentation.entries.DeleteItemsDialog import eu.kanade.presentation.entries.EditCoverAction +import eu.kanade.presentation.entries.SetIntervalDialog import eu.kanade.presentation.entries.manga.ChapterSettingsDialog import eu.kanade.presentation.entries.manga.DuplicateMangaDialog import eu.kanade.presentation.entries.manga.MangaScreen @@ -99,8 +100,8 @@ class MangaScreen( MangaScreen( state = successState, snackbarHostState = screenModel.snackbarHostState, - dateRelativeTime = screenModel.relativeTime, dateFormat = screenModel.dateFormat, + intervalDisplay = screenModel::intervalDisplay, isTabletUi = isTabletUi(), chapterSwipeStartAction = screenModel.chapterSwipeStartAction, chapterSwipeEndAction = screenModel.chapterSwipeEndAction, @@ -122,7 +123,8 @@ class MangaScreen( onCoverClicked = screenModel::showCoverDialog, onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, - onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite }, + onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.manga.favorite }, + onEditIntervalClicked = screenModel::showSetMangaIntervalDialog.takeIf { screenModel.isIntervalEnabled && successState.manga.favorite }, onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) }.takeIf { successState.manga.favorite }, onMultiBookmarkClicked = screenModel::bookmarkChapters, onMultiMarkAsReadClicked = screenModel::markChaptersRead, @@ -215,6 +217,13 @@ class MangaScreen( LoadingScreen(Modifier.systemBarsPadding()) } } + is MangaScreenModel.Dialog.SetMangaInterval -> { + SetIntervalDialog( + interval = if (dialog.manga.calculateInterval < 0) -dialog.manga.calculateInterval else 0, + onDismissRequest = onDismissRequest, + onValueChanged = { screenModel.setFetchRangeInterval(dialog.manga, it) }, + ) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt index 4cbb17fd0..68712fbba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt @@ -63,6 +63,7 @@ import tachiyomi.domain.entries.manga.interactor.GetDuplicateLibraryManga import tachiyomi.domain.entries.manga.interactor.GetMangaWithChapters import tachiyomi.domain.entries.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.entries.manga.model.Manga +import tachiyomi.domain.entries.manga.repository.MangaRepository import tachiyomi.domain.items.chapter.interactor.SetMangaDefaultChapterFlags import tachiyomi.domain.items.chapter.interactor.UpdateChapter import tachiyomi.domain.items.chapter.model.Chapter @@ -75,6 +76,7 @@ import tachiyomi.domain.track.manga.interactor.GetMangaTracks import tachiyomi.source.local.entries.manga.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.math.absoluteValue class MangaScreenModel( val context: Context, @@ -99,6 +101,7 @@ class MangaScreenModel( private val getCategories: GetMangaCategories = Injekt.get(), private val getTracks: GetMangaTracks = Injekt.get(), private val setMangaCategories: SetMangaCategories = Injekt.get(), + private val mangaRepository: MangaRepository = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), ) : StateScreenModel(State.Loading) { @@ -125,10 +128,13 @@ class MangaScreenModel( val chapterSwipeStartAction = libraryPreferences.swipeChapterEndAction().get() val chapterSwipeEndAction = libraryPreferences.swipeChapterStartAction().get() - val relativeTime by uiPreferences.relativeTime().asState(coroutineScope) val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope) + val isIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get() + private val leadDay = libraryPreferences.leadingMangaExpectedDays().get() + private val followDay = libraryPreferences.followingMangaExpectedDays().get() + private val selectedPositions: Array = arrayOf(-1, -1) // first and last selected index in list private val selectedChapterIds: HashSet = HashSet() @@ -316,7 +322,7 @@ class MangaScreenModel( // Choose a category else -> { isFromChangeCategory = true - promptChangeCategories() + showChangeCategoryDialog() } } @@ -346,7 +352,7 @@ class MangaScreenModel( } } - fun promptChangeCategories() { + fun showChangeCategoryDialog() { val manga = successState?.manga ?: return coroutineScope.launch { val categories = getCategories() @@ -362,6 +368,37 @@ class MangaScreenModel( } } + fun showSetMangaIntervalDialog() { + val manga = successState?.manga ?: return + updateSuccessState { + it.copy(dialog = Dialog.SetMangaInterval(manga)) + } + } + + // TODO: this should be in the state/composables + fun intervalDisplay(): Pair? { + val manga = successState?.manga ?: return null + val effInterval = manga.calculateInterval + return 1.coerceAtLeast(effInterval.absoluteValue - leadDay) to (effInterval.absoluteValue + followDay) + } + + fun setFetchRangeInterval(manga: Manga, newInterval: Int) { + val interval = when (newInterval) { + // reset interval 0 default to trigger recalculation + // only reset if interval is custom, which is negative + 0 -> if (manga.calculateInterval < 0) 0 else manga.calculateInterval + else -> -newInterval + } + coroutineScope.launchIO { + updateManga.awaitUpdateFetchInterval( + manga.copy(calculateInterval = interval), + successState?.chapters?.map { it.chapter }.orEmpty(), + ) + val newManga = mangaRepository.getMangaById(mangaId) + updateSuccessState { it.copy(manga = newManga) } + } + } + /** * Returns true if the manga has any downloads. */ @@ -515,6 +552,7 @@ class MangaScreenModel( chapters, state.manga, state.source, + manualFetch, ) if (manualFetch) { @@ -532,6 +570,8 @@ class MangaScreenModel( coroutineScope.launch { snackbarHostState.showSnackbar(message = message) } + val newManga = mangaRepository.getMangaById(mangaId) + updateSuccessState { it.copy(manga = newManga, isRefreshingData = false) } } } @@ -956,6 +996,7 @@ class MangaScreenModel( data class ChangeCategory(val manga: Manga, val initialSelection: List>) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog + data class SetMangaInterval(val manga: Manga) : Dialog data object SettingsSheet : Dialog data object TrackSheet : Dialog data object FullCover : Dialog @@ -983,7 +1024,7 @@ class MangaScreenModel( sealed interface State { @Immutable - object Loading : State + data object Loading : State @Immutable data class Success( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/dialogs/EpisodeListDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/dialogs/EpisodeListDialog.kt index dea2eb100..b3d6fb737 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/dialogs/EpisodeListDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/settings/dialogs/EpisodeListDialog.kt @@ -92,11 +92,7 @@ fun EpisodeListDialog( val date = episode.date_upload .takeIf { it > 0L } ?.let { - Date(it).toRelativeString( - context, - relativeTime, - dateFormat, - ) + Date(it).toRelativeString(context, dateFormat) } ?: "" EpisodeListItem( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt index 914fce7f5..af97c9a2d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt @@ -61,14 +61,12 @@ class AnimeUpdatesScreenModel( private val libraryPreferences: LibraryPreferences = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), downloadPreferences: DownloadPreferences = Injekt.get(), - uiPreferences: UiPreferences = Injekt.get(), ) : StateScreenModel(State()) { private val _events: Channel = Channel(Int.MAX_VALUE) val events: Flow = _events.receiveAsFlow() val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope) - val relativeTime by uiPreferences.relativeTime().asState(coroutineScope) val useExternalDownloader = downloadPreferences.useExternalDownloader().get() @@ -382,12 +380,12 @@ class AnimeUpdatesScreenModel( data class State( val isLoading: Boolean = true, val items: List = emptyList(), - val dialog: AnimeUpdatesScreenModel.Dialog? = null, + val dialog: Dialog? = null, ) { val selected = items.filter { it.selected } val selectionMode = selected.isNotEmpty() - fun getUiModel(context: Context, relativeTime: Int): List { + fun getUiModel(context: Context): List { val dateFormat by mutableStateOf(UiPreferences.dateFormat(Injekt.get().dateFormat().get())) return items @@ -397,11 +395,7 @@ class AnimeUpdatesScreenModel( val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0) when { beforeDate.time != afterDate.time && afterDate.time != 0L -> { - val text = afterDate.toRelativeString( - context = context, - range = relativeTime, - dateFormat = dateFormat, - ) + val text = afterDate.toRelativeString(context, dateFormat) AnimeUpdatesUiModel.Header(text) } // Return null to avoid adding a separator between two items. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesTab.kt index ff77c7be4..bfcd915f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesTab.kt @@ -59,7 +59,6 @@ fun Screen.animeUpdatesTab( snackbarHostState = screenModel.snackbarHostState, contentPadding = contentPadding, lastUpdated = screenModel.lastUpdated, - relativeTime = screenModel.relativeTime, onClickCover = { item -> navigator.push(AnimeScreen(item.update.animeId)) }, onSelectAll = screenModel::toggleAllSelection, onInvertSelection = screenModel::invertSelection, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt index 843fe39db..86eec8f1a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt @@ -59,14 +59,12 @@ class MangaUpdatesScreenModel( private val getChapter: GetChapter = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), - uiPreferences: UiPreferences = Injekt.get(), ) : StateScreenModel(State()) { private val _events: Channel = Channel(Int.MAX_VALUE) val events: Flow = _events.receiveAsFlow() val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope) - val relativeTime by uiPreferences.relativeTime().asState(coroutineScope) // First and last selected index in list private val selectedPositions: Array = arrayOf(-1, -1) @@ -370,12 +368,12 @@ class MangaUpdatesScreenModel( data class State( val isLoading: Boolean = true, val items: List = emptyList(), - val dialog: MangaUpdatesScreenModel.Dialog? = null, + val dialog: Dialog? = null, ) { val selected = items.filter { it.selected } val selectionMode = selected.isNotEmpty() - fun getUiModel(context: Context, relativeTime: Int): List { + fun getUiModel(context: Context): List { val dateFormat by mutableStateOf(UiPreferences.dateFormat(Injekt.get().dateFormat().get())) return items @@ -385,11 +383,7 @@ class MangaUpdatesScreenModel( val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0) when { beforeDate.time != afterDate.time && afterDate.time != 0L -> { - val text = afterDate.toRelativeString( - context = context, - range = relativeTime, - dateFormat = dateFormat, - ) + val text = afterDate.toRelativeString(context, dateFormat) MangaUpdatesUiModel.Header(text) } // Return null to avoid adding a separator between two items. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesTab.kt index a458a0ffd..07289f866 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesTab.kt @@ -46,7 +46,6 @@ fun Screen.mangaUpdatesTab( snackbarHostState = screenModel.snackbarHostState, contentPadding = contentPadding, lastUpdated = screenModel.lastUpdated, - relativeTime = screenModel.relativeTime, onClickCover = { item -> navigator.push(MangaScreen(item.update.mangaId)) }, onSelectAll = screenModel::toggleAllSelection, onInvertSelection = screenModel::invertSelection, diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt index c7c29561b..f0612506c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt @@ -114,19 +114,15 @@ private const val MILLISECONDS_IN_DAY = 86_400_000L fun Date.toRelativeString( context: Context, - range: Int = 7, dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT), ): String { - if (range == 0) { - return dateFormat.format(this) - } val now = Date() val difference = now.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY) - this.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY) val days = difference.floorDiv(MILLISECONDS_IN_DAY).toInt() return when { difference < 0 -> context.getString(R.string.recently) difference < MILLISECONDS_IN_DAY -> context.getString(R.string.relative_time_today) - difference < MILLISECONDS_IN_DAY.times(range) -> context.resources.getQuantityString( + difference < MILLISECONDS_IN_DAY.times(7) -> context.resources.getQuantityString( R.plurals.relative_time, days, days, diff --git a/domain/src/main/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateInterval.kt b/domain/src/main/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateInterval.kt index 03c3a4b25..1a23d4288 100644 --- a/domain/src/main/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateInterval.kt +++ b/domain/src/main/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateInterval.kt @@ -13,111 +13,115 @@ import kotlin.math.absoluteValue const val MAX_GRACE_PERIOD = 28 -fun updateIntervalMeta( - anime: Anime, - episodes: List, - zonedDateTime: ZonedDateTime = ZonedDateTime.now(), - setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), -): AnimeUpdate? { - val currentFetchRange = if (setCurrentFetchRange.first == 0L && setCurrentFetchRange.second == 0L) { - getCurrentFetchRange(ZonedDateTime.now()) - } else { - setCurrentFetchRange - } - val interval = anime.calculateInterval.takeIf { it < 0 } ?: calculateInterval(episodes, zonedDateTime) - val nextUpdate = calculateNextUpdate(anime, interval, zonedDateTime, currentFetchRange) +class SetAnimeUpdateInterval( + private val libraryPreferences: LibraryPreferences = Injekt.get(), +) { - return if (anime.nextUpdate == nextUpdate && anime.calculateInterval == interval) { - null - } else { AnimeUpdate(id = anime.id, nextUpdate = nextUpdate, calculateInterval = interval) } -} - -fun calculateInterval(episodes: List, zonedDateTime: ZonedDateTime): Int { - val sortedEpisodes = episodes - .sortedWith(compareByDescending { it.dateUpload }.thenByDescending { it.dateFetch }) - .take(50) - - val uploadDates = sortedEpisodes - .filter { it.dateUpload > 0L } - .map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) - .toLocalDate() - .atStartOfDay() + fun updateInterval( + anime: Anime, + episodes: List, + zonedDateTime: ZonedDateTime, + fetchRange: Pair, + ): AnimeUpdate? { + val currentFetchRange = if (fetchRange.first == 0L && fetchRange.second == 0L) { + getCurrentFetchRange(ZonedDateTime.now()) + } else { + fetchRange } - .distinct() - val fetchDates = sortedEpisodes - .map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) - .toLocalDate() - .atStartOfDay() + val interval = anime.calculateInterval.takeIf { it < 0 } ?: calculateInterval(episodes, zonedDateTime) + val nextUpdate = calculateNextUpdate(anime, interval, zonedDateTime, currentFetchRange) + + return if (anime.nextUpdate == nextUpdate && anime.calculateInterval == interval) { + null + } else { + AnimeUpdate(id = anime.id, nextUpdate = nextUpdate, calculateInterval = interval) } - .distinct() + } - val newInterval = when { - // Enough upload date from source - uploadDates.size >= 3 -> { - val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) - val uploadPeriod = uploadDates.indexOf(uploadDates.last()) - (uploadDelta).floorDiv(uploadPeriod).toInt() + fun getCurrentFetchRange(timeToCal: ZonedDateTime): Pair { + // lead range and the following range depend on if updateOnlyExpectedPeriod set. + var followRange = 0 + var leadRange = 0 + if (LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get()) { + followRange = libraryPreferences.followingAnimeExpectedDays().get() + leadRange = libraryPreferences.leadingAnimeExpectedDays().get() } - // Enough fetch date from client - fetchDates.size >= 3 -> { - val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) - val uploadPeriod = fetchDates.indexOf(fetchDates.last()) - (fetchDelta).floorDiv(uploadPeriod).toInt() + val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) + // revert math of (next_update + follow < now) become (next_update < now - follow) + // so (now - follow) become lower limit + val lowerRange = startToday.minusDays(followRange.toLong()) + val higherRange = startToday.plusDays(leadRange.toLong()) + return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) + } + + internal fun calculateInterval(episodes: List, zonedDateTime: ZonedDateTime): Int { + val sortedEpisodes = episodes + .sortedWith(compareByDescending { it.dateUpload }.thenByDescending { it.dateFetch }) + .take(50) + + val uploadDates = sortedEpisodes + .filter { it.dateUpload > 0L } + .map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) + .toLocalDate() + .atStartOfDay() + } + .distinct() + val fetchDates = sortedEpisodes + .map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) + .toLocalDate() + .atStartOfDay() + } + .distinct() + + val interval = when { + // Enough upload date from source + uploadDates.size >= 3 -> { + val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) + val uploadPeriod = uploadDates.indexOf(uploadDates.last()) + uploadDelta.floorDiv(uploadPeriod).toInt() + } + // Enough fetch date from client + fetchDates.size >= 3 -> { + val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) + val uploadPeriod = fetchDates.indexOf(fetchDates.last()) + fetchDelta.floorDiv(uploadPeriod).toInt() + } + // Default to 7 days + else -> 7 } - // Default to 7 days - else -> 7 + // Min 1, max 28 days + return interval.coerceIn(1, MAX_GRACE_PERIOD) } - // min 1, max 28 days - return newInterval.coerceIn(1, MAX_GRACE_PERIOD) -} -private fun calculateNextUpdate( - anime: Anime, - interval: Int, - zonedDateTime: ZonedDateTime, - currentFetchRange: Pair, -): Long { - return if (anime.nextUpdate !in currentFetchRange.first.rangeTo(currentFetchRange.second + 1) || - anime.calculateInterval == 0 - ) { - val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(anime.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() - val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() - val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) - latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 - } else { - anime.nextUpdate + private fun calculateNextUpdate( + anime: Anime, + interval: Int, + zonedDateTime: ZonedDateTime, + fetchRange: Pair, + ): Long { + return if ( + anime.nextUpdate !in fetchRange.first.rangeTo(fetchRange.second + 1) || + anime.calculateInterval == 0 + ) { + val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(anime.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() + val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() + val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) + latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 + } else { + anime.nextUpdate + } + } + + private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { + if (delta >= maxValue) return maxValue + val cycle = timeSinceLatest.floorDiv(delta) + 1 + // double delta again if missed more than 9 check in new delta + return if (cycle > doubleWhenOver) { + doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) + } else { + delta + } } } - -private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { - if (delta >= maxValue) return maxValue - val cycle = timeSinceLatest.floorDiv(delta) + 1 - // double delta again if missed more than 9 check in new delta - return if (cycle > doubleWhenOver) { - doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) - } else { - delta - } -} - -fun getCurrentFetchRange( - timeToCal: ZonedDateTime, -): Pair { - val preferences: LibraryPreferences = Injekt.get() - - // lead range and the following range depend on if updateOnlyExpectedPeriod set. - var followRange = 0 - var leadRange = 0 - if (LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in preferences.libraryUpdateItemRestriction().get()) { - followRange = preferences.followingAnimeExpectedDays().get() - leadRange = preferences.leadingAnimeExpectedDays().get() - } - val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) - // revert math of (next_update + follow < now) become (next_update < now - follow) - // so (now - follow) become lower limit - val lowerRange = startToday.minusDays(followRange.toLong()) - val higherRange = startToday.plusDays(leadRange.toLong()) - return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) -} diff --git a/domain/src/main/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateInterval.kt b/domain/src/main/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateInterval.kt index 32f10e7d6..b72fa2cea 100644 --- a/domain/src/main/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateInterval.kt +++ b/domain/src/main/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateInterval.kt @@ -13,111 +13,115 @@ import kotlin.math.absoluteValue const val MAX_GRACE_PERIOD = 28 -fun updateIntervalMeta( - manga: Manga, - chapters: List, - zonedDateTime: ZonedDateTime = ZonedDateTime.now(), - setCurrentFetchRange: Pair = getCurrentFetchRange(zonedDateTime), -): MangaUpdate? { - val currentFetchRange = if (setCurrentFetchRange.first == 0L && setCurrentFetchRange.second == 0L) { - getCurrentFetchRange(ZonedDateTime.now()) - } else { - setCurrentFetchRange - } - val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime) - val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange) +class SetMangaUpdateInterval( + private val libraryPreferences: LibraryPreferences = Injekt.get(), +) { - return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) { - null - } else { MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) } -} - -fun calculateInterval(chapters: List, zonedDateTime: ZonedDateTime): Int { - val sortedChapters = chapters - .sortedWith(compareByDescending { it.dateUpload }.thenByDescending { it.dateFetch }) - .take(50) - - val uploadDates = sortedChapters - .filter { it.dateUpload > 0L } - .map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) - .toLocalDate() - .atStartOfDay() + fun updateInterval( + manga: Manga, + chapters: List, + zonedDateTime: ZonedDateTime, + fetchRange: Pair, + ): MangaUpdate? { + val currentFetchRange = if (fetchRange.first == 0L && fetchRange.second == 0L) { + getCurrentFetchRange(ZonedDateTime.now()) + } else { + fetchRange } - .distinct() - val fetchDates = sortedChapters - .map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) - .toLocalDate() - .atStartOfDay() + val interval = manga.calculateInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime) + val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentFetchRange) + + return if (manga.nextUpdate == nextUpdate && manga.calculateInterval == interval) { + null + } else { + MangaUpdate(id = manga.id, nextUpdate = nextUpdate, calculateInterval = interval) } - .distinct() + } - val newInterval = when { - // Enough upload date from source - uploadDates.size >= 3 -> { - val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) - val uploadPeriod = uploadDates.indexOf(uploadDates.last()) - (uploadDelta).floorDiv(uploadPeriod).toInt() + fun getCurrentFetchRange(timeToCal: ZonedDateTime): Pair { + // lead range and the following range depend on if updateOnlyExpectedPeriod set. + var followRange = 0 + var leadRange = 0 + if (LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get()) { + followRange = libraryPreferences.followingAnimeExpectedDays().get() + leadRange = libraryPreferences.leadingAnimeExpectedDays().get() } - // Enough fetch date from client - fetchDates.size >= 3 -> { - val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) - val uploadPeriod = fetchDates.indexOf(fetchDates.last()) - (fetchDelta).floorDiv(uploadPeriod).toInt() + val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) + // revert math of (next_update + follow < now) become (next_update < now - follow) + // so (now - follow) become lower limit + val lowerRange = startToday.minusDays(followRange.toLong()) + val higherRange = startToday.plusDays(leadRange.toLong()) + return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) + } + + internal fun calculateInterval(chapters: List, zonedDateTime: ZonedDateTime): Int { + val sortedChapters = chapters + .sortedWith(compareByDescending { it.dateUpload }.thenByDescending { it.dateFetch }) + .take(50) + + val uploadDates = sortedChapters + .filter { it.dateUpload > 0L } + .map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) + .toLocalDate() + .atStartOfDay() + } + .distinct() + val fetchDates = sortedChapters + .map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) + .toLocalDate() + .atStartOfDay() + } + .distinct() + + val interval = when { + // Enough upload date from source + uploadDates.size >= 3 -> { + val uploadDelta = uploadDates.last().until(uploadDates.first(), ChronoUnit.DAYS) + val uploadPeriod = uploadDates.indexOf(uploadDates.last()) + uploadDelta.floorDiv(uploadPeriod).toInt() + } + // Enough fetch date from client + fetchDates.size >= 3 -> { + val fetchDelta = fetchDates.last().until(fetchDates.first(), ChronoUnit.DAYS) + val uploadPeriod = fetchDates.indexOf(fetchDates.last()) + fetchDelta.floorDiv(uploadPeriod).toInt() + } + // Default to 7 days + else -> 7 } - // Default to 7 days - else -> 7 + // Min 1, max 28 days + return interval.coerceIn(1, MAX_GRACE_PERIOD) } - // min 1, max 28 days - return newInterval.coerceIn(1, MAX_GRACE_PERIOD) -} -private fun calculateNextUpdate( - manga: Manga, - interval: Int, - zonedDateTime: ZonedDateTime, - currentFetchRange: Pair, -): Long { - return if (manga.nextUpdate !in currentFetchRange.first.rangeTo(currentFetchRange.second + 1) || - manga.calculateInterval == 0 - ) { - val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() - val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() - val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) - latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 - } else { - manga.nextUpdate + private fun calculateNextUpdate( + manga: Manga, + interval: Int, + zonedDateTime: ZonedDateTime, + fetchRange: Pair, + ): Long { + return if ( + manga.nextUpdate !in fetchRange.first.rangeTo(fetchRange.second + 1) || + manga.calculateInterval == 0 + ) { + val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), zonedDateTime.zone).toLocalDate().atStartOfDay() + val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, zonedDateTime).toInt() + val cycle = timeSinceLatest.floorDiv(interval.absoluteValue.takeIf { interval < 0 } ?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10, maxValue = 28)) + latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(zonedDateTime.offset) * 1000 + } else { + manga.nextUpdate + } + } + + private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { + if (delta >= maxValue) return maxValue + val cycle = timeSinceLatest.floorDiv(delta) + 1 + // double delta again if missed more than 9 check in new delta + return if (cycle > doubleWhenOver) { + doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) + } else { + delta + } } } - -private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int, maxValue: Int): Int { - if (delta >= maxValue) return maxValue - val cycle = timeSinceLatest.floorDiv(delta) + 1 - // double delta again if missed more than 9 check in new delta - return if (cycle > doubleWhenOver) { - doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue) - } else { - delta - } -} - -fun getCurrentFetchRange( - timeToCal: ZonedDateTime, -): Pair { - val preferences: LibraryPreferences = Injekt.get() - - // lead range and the following range depend on if updateOnlyExpectedPeriod set. - var followRange = 0 - var leadRange = 0 - if (LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in preferences.libraryUpdateItemRestriction().get()) { - followRange = preferences.followingMangaExpectedDays().get() - leadRange = preferences.leadingMangaExpectedDays().get() - } - val startToday = timeToCal.toLocalDate().atStartOfDay(timeToCal.zone) - // revert math of (next_update + follow < now) become (next_update < now - follow) - // so (now - follow) become lower limit - val lowerRange = startToday.minusDays(followRange.toLong()) - val higherRange = startToday.plusDays(leadRange.toLong()) - return Pair(lowerRange.toEpochSecond() * 1000, higherRange.toEpochSecond() * 1000 - 1) -} diff --git a/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateIntervalTest.kt b/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateIntervalTest.kt new file mode 100644 index 000000000..14c99aa60 --- /dev/null +++ b/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeUpdateIntervalTest.kt @@ -0,0 +1,135 @@ +package tachiyomi.domain.entries.anime.interactor + +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import tachiyomi.domain.items.episode.model.Episode +import java.time.Duration +import java.time.ZonedDateTime + +@Execution(ExecutionMode.CONCURRENT) +class SetAnimeUpdateIntervalTest { + private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z") + private var episode = Episode.create().copy( + dateFetch = testTime.toEpochSecond() * 1000, + dateUpload = testTime.toEpochSecond() * 1000, + ) + + private val setAnimeUpdateInterval = SetAnimeUpdateInterval(mockk()) + + private fun episodeAddTime(episode: Episode, duration: Duration): Episode { + val newTime = testTime.plus(duration).toEpochSecond() * 1000 + return episode.copy(dateFetch = newTime, dateUpload = newTime) + } + + // default 7 when less than 3 distinct day + @Test + fun `calculateInterval returns 7 when 1 episodes in 1 day`() { + val episodes = mutableListOf() + (1..1).forEach { + val duration = Duration.ofHours(10) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 7 + } + + @Test + fun `calculateInterval returns 7 when 5 episodes in 1 day`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(10) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 7 + } + + @Test + fun `calculateInterval returns 7 when 7 episodes in 48 hours, 2 day`() { + val episodes = mutableListOf() + (1..2).forEach { + val duration = Duration.ofHours(24L) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + (1..5).forEach { + val duration = Duration.ofHours(48L) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 7 + } + + // Default 1 if interval less than 1 + @Test + fun `calculateInterval returns 1 when 5 episodes in 75 hours, 3 days`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(15L * it) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 1 + } + + // Normal interval calculation + @Test + fun `calculateInterval returns 1 when 5 episodes in 120 hours, 5 days`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(24L * it) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 1 + } + + @Test + fun `calculateInterval returns 2 when 5 episodes in 240 hours, 10 days`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(48L * it) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 2 + } + + // If interval is decimal, floor to closest integer + @Test + fun `calculateInterval returns 1 when 5 episodes in 125 hours, 5 days`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(25L * it) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 1 + } + + @Test + fun `calculateInterval returns 1 when 5 episodes in 215 hours, 5 days`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(43L * it) + val newEpisode = episodeAddTime(episode, duration) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 1 + } + + // Use fetch time if upload time not available + @Test + fun `calculateInterval returns 1 when 5 episodes in 125 hours, 5 days of dateFetch`() { + val episodes = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(25L * it) + val newEpisode = episodeAddTime(episode, duration).copy(dateUpload = 0L) + episodes.add(newEpisode) + } + setAnimeUpdateInterval.calculateInterval(episodes, testTime) shouldBe 1 + } +} diff --git a/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateIntervalTest.kt b/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateIntervalTest.kt new file mode 100644 index 000000000..6b24e9bb1 --- /dev/null +++ b/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaUpdateIntervalTest.kt @@ -0,0 +1,135 @@ +package tachiyomi.domain.entries.manga.interactor + +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import tachiyomi.domain.items.chapter.model.Chapter +import java.time.Duration +import java.time.ZonedDateTime + +@Execution(ExecutionMode.CONCURRENT) +class SetMangaUpdateIntervalTest { + private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z") + private var chapter = Chapter.create().copy( + dateFetch = testTime.toEpochSecond() * 1000, + dateUpload = testTime.toEpochSecond() * 1000, + ) + + private val setMangaUpdateInterval = SetMangaUpdateInterval(mockk()) + + private fun chapterAddTime(chapter: Chapter, duration: Duration): Chapter { + val newTime = testTime.plus(duration).toEpochSecond() * 1000 + return chapter.copy(dateFetch = newTime, dateUpload = newTime) + } + + // default 7 when less than 3 distinct day + @Test + fun `calculateInterval returns 7 when 1 chapters in 1 day`() { + val chapters = mutableListOf() + (1..1).forEach { + val duration = Duration.ofHours(10) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 7 + } + + @Test + fun `calculateInterval returns 7 when 5 chapters in 1 day`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(10) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 7 + } + + @Test + fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() { + val chapters = mutableListOf() + (1..2).forEach { + val duration = Duration.ofHours(24L) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + (1..5).forEach { + val duration = Duration.ofHours(48L) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 7 + } + + // Default 1 if interval less than 1 + @Test + fun `calculateInterval returns 1 when 5 chapters in 75 hours, 3 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(15L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + // Normal interval calculation + @Test + fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(24L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + @Test + fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(48L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 2 + } + + // If interval is decimal, floor to closest integer + @Test + fun `calculateInterval returns 1 when 5 chapters in 125 hours, 5 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(25L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + @Test + fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(43L * it) + val newChapter = chapterAddTime(chapter, duration) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } + + // Use fetch time if upload time not available + @Test + fun `calculateInterval returns 1 when 5 chapters in 125 hours, 5 days of dateFetch`() { + val chapters = mutableListOf() + (1..5).forEach { + val duration = Duration.ofHours(25L * it) + val newChapter = chapterAddTime(chapter, duration).copy(dateUpload = 0L) + chapters.add(newChapter) + } + setMangaUpdateInterval.calculateInterval(chapters, testTime) shouldBe 1 + } +} diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 9b5237fcf..570674708 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -204,10 +204,6 @@ Matrix Tidal Wave Pure black dark mode - Timestamps - Relative timestamps - Short (Today, Yesterday) - Long (Short+, n days ago) Date format Manage notifications @@ -628,6 +624,10 @@ 1 day %d days + + %1$d - %2$d day + %1$d - %2$d days + @@ -669,8 +669,7 @@ Chapter %1$s Estimate every Set to update every - Modify interval - Customize Interval + Customize interval Downloading (%1$d/%2$d) Source title Chapter number 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 e35a32760..17520abed 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 @@ -78,6 +78,7 @@ fun AdaptiveSheet( val alpha by animateFloatAsState( targetValue = targetAlpha, animationSpec = sheetAnimationSpec, + label = "alpha", ) val internalOnDismissRequest: () -> Unit = { scope.launch {