Last commit merged: 7146913c71
This commit is contained in:
LuftVerbot 2023-11-19 00:36:58 +01:00
parent e29dc62837
commit fa7b8427a2
86 changed files with 2504 additions and 2210 deletions

View file

@ -85,6 +85,7 @@ android {
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release")
isDebuggable = false
isProfileable = true
versionNameSuffix = "-benchmark"
applicationIdSuffix = ".benchmark"
}

View file

@ -11,6 +11,7 @@
-keep,allowoptimization class kotlin.** { public protected *; }
-keep,allowoptimization class kotlinx.coroutines.** { public protected *; }
-keep,allowoptimization class kotlinx.serialization.** { public protected *; }
-keep,allowoptimization class kotlin.time.** { public protected *; }
-keep,allowoptimization class okhttp3.** { public protected *; }
-keep,allowoptimization class okio.** { public protected *; }
-keep,allowoptimization class rx.** { public protected *; }

View file

@ -41,11 +41,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.Tachiyomi">
<!-- enable profiling by macrobenchmark -->
<profileable
android:shell="true"
tools:targetApi="q" />
<activity
android:name=".ui.main.MainActivity"
android:launchMode="singleTop"
@ -173,8 +168,8 @@
android:exported="false" />
<activity
android:name=".ui.setting.track.AnilistLoginActivity"
android:label="Anilist"
android:name=".ui.setting.track.TrackLoginActivity"
android:label="@string/track_activity_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -182,69 +177,21 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="anilist-auth"
android:scheme="tachiyomi" />
<data android:host="anilist-auth"/>
<data android:host="bangumi-auth"/>
<data android:host="myanimelist-auth"/>
<data android:host="shikimori-auth"/>
<data android:scheme="tachiyomi"/>
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.MyAnimeListLoginActivity"
android:label="MyAnimeList"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="myanimelist-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.ShikimoriLoginActivity"
android:label="Shikimori"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="shikimori-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.BangumiLoginActivity"
android:label="Bangumi"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="bangumi-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.SimklLoginActivity"
android:label="Simkl"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="simkl-auth"
android:scheme="aniyomi" />
<data android:host="simkl-auth"/>
<data android:scheme="aniyomi"/>
</intent-filter>
</activity>
@ -292,10 +239,12 @@
android:name=".data.updater.AppUpdateService"
android:exported="false" />
<service android:name=".extension.manga.util.MangaExtensionInstallService"
<service
android:name=".extension.manga.util.MangaExtensionInstallService"
android:exported="false" />
<service android:name=".extension.anime.util.AnimeExtensionInstallService"
<service
android:name=".extension.anime.util.AnimeExtensionInstallService"
android:exported="false" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,6 @@ import tachiyomi.domain.entries.anime.interactor.SetAnimeFetchInterval
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.anime.model.AnimeUpdate
import tachiyomi.domain.entries.anime.repository.AnimeRepository
import tachiyomi.domain.items.episode.model.Episode
import tachiyomi.source.local.entries.anime.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -79,16 +78,12 @@ class UpdateAnime(
suspend fun awaitUpdateFetchInterval(
anime: Anime,
episodes: List<Episode>,
zonedDateTime: ZonedDateTime = ZonedDateTime.now(),
fetchRange: Pair<Long, Long> = setAnimeFetchInterval.getCurrent(zonedDateTime),
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setAnimeFetchInterval.getWindow(dateTime),
): Boolean {
val updateAnime = setAnimeFetchInterval.update(anime, episodes, zonedDateTime, fetchRange)
return if (updateAnime != null) {
animeRepository.updateAnime(updateAnime)
} else {
true
}
return setAnimeFetchInterval.toAnimeUpdateOrNull(anime, dateTime, window)
?.let { animeRepository.updateAnime(it) }
?: false
}
suspend fun awaitUpdateLastUpdate(animeId: Long): Boolean {

View file

@ -7,7 +7,6 @@ import tachiyomi.domain.entries.manga.interactor.SetMangaFetchInterval
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.entries.manga.model.MangaUpdate
import tachiyomi.domain.entries.manga.repository.MangaRepository
import tachiyomi.domain.items.chapter.model.Chapter
import tachiyomi.source.local.entries.manga.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -79,16 +78,12 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval(
manga: Manga,
chapters: List<Chapter>,
zonedDateTime: ZonedDateTime = ZonedDateTime.now(),
fetchRange: Pair<Long, Long> = setMangaFetchInterval.getCurrent(zonedDateTime),
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setMangaFetchInterval.getWindow(dateTime),
): Boolean {
val updatedManga = setMangaFetchInterval.update(manga, chapters, zonedDateTime, fetchRange)
return if (updatedManga != null) {
mangaRepository.updateManga(updatedManga)
} else {
true
}
return setMangaFetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
?.let { mangaRepository.updateManga(it) }
?: false
}
suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean {

View file

@ -50,13 +50,14 @@ class SyncChaptersWithSource(
manga: Manga,
source: MangaSource,
manualFetch: Boolean = false,
zoneDateTime: ZonedDateTime = ZonedDateTime.now(),
fetchRange: Pair<Long, Long> = Pair(0, 0),
fetchWindow: Pair<Long, Long> = Pair(0, 0),
): List<Chapter> {
if (rawSourceChapters.isEmpty() && !source.isLocal()) {
throw NoChaptersException()
}
val now = ZonedDateTime.now()
val sourceChapters = rawSourceChapters
.distinctBy { it.url }
.mapIndexed { i, sChapter ->
@ -138,12 +139,11 @@ 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.fetchInterval == 0 || manga.nextUpdate < fetchRange.first) {
if (manualFetch || manga.fetchInterval == 0 || manga.nextUpdate < fetchWindow.first) {
updateManga.awaitUpdateFetchInterval(
manga,
dbChapters,
zoneDateTime,
fetchRange,
now,
fetchWindow,
)
}
return emptyList()
@ -200,8 +200,7 @@ class SyncChaptersWithSource(
val chapterUpdates = toChange.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
}
val newChapters = chapterRepository.getChapterByMangaId(manga.id)
updateManga.awaitUpdateFetchInterval(manga, newChapters, zoneDateTime, fetchRange)
updateManga.awaitUpdateFetchInterval(manga, now, fetchWindow)
// Set this manga as updated since chapters were changed
// Note that last_update actually represents last time the chapter list changed at all

View file

@ -50,13 +50,14 @@ class SyncEpisodesWithSource(
anime: Anime,
source: AnimeSource,
manualFetch: Boolean = false,
zoneDateTime: ZonedDateTime = ZonedDateTime.now(),
fetchRange: Pair<Long, Long> = Pair(0, 0),
fetchWindow: Pair<Long, Long> = Pair(0, 0),
): List<Episode> {
if (rawSourceEpisodes.isEmpty() && !source.isLocal()) {
throw NoEpisodesException()
}
val now = ZonedDateTime.now()
val sourceEpisodes = rawSourceEpisodes
.distinctBy { it.url }
.mapIndexed { i, sEpisode ->
@ -138,12 +139,11 @@ 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.fetchInterval == 0 || anime.nextUpdate < fetchRange.first) {
if (manualFetch || anime.fetchInterval == 0 || anime.nextUpdate < fetchWindow.first) {
updateAnime.awaitUpdateFetchInterval(
anime,
dbEpisodes,
zoneDateTime,
fetchRange,
now,
fetchWindow,
)
}
return emptyList()
@ -200,8 +200,7 @@ class SyncEpisodesWithSource(
val episodeUpdates = toChange.map { it.toEpisodeUpdate() }
updateEpisode.awaitAll(episodeUpdates)
}
val newChapters = episodeRepository.getEpisodeByAnimeId(anime.id)
updateAnime.awaitUpdateFetchInterval(anime, newChapters, zoneDateTime, fetchRange)
updateAnime.awaitUpdateFetchInterval(anime, now, fetchWindow)
// Set this anime as updated since episodes were changed
// Note that last_update actually represents last time the episode list changed at all

View file

@ -16,7 +16,6 @@ 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
@ -58,7 +57,7 @@ fun SetIntervalDialog(
onDismissRequest: () -> Unit,
onValueChanged: (Int) -> Unit,
) {
var intervalValue by rememberSaveable { mutableIntStateOf(interval) }
var selectedInterval by rememberSaveable { mutableIntStateOf(if (interval < 0) -interval else 0) }
AlertDialog(
onDismissRequest = onDismissRequest,
@ -69,7 +68,7 @@ fun SetIntervalDialog(
contentAlignment = Alignment.Center,
) {
val size = DpSize(width = maxWidth / 2, height = 128.dp)
val items = (0..MAX_GRACE_PERIOD).map {
val items = (0..28).map {
if (it == 0) {
stringResource(R.string.label_default)
} else {
@ -79,8 +78,8 @@ fun SetIntervalDialog(
WheelTextPicker(
size = size,
items = items,
startIndex = intervalValue,
onSelectionChanged = { intervalValue = it },
startIndex = selectedInterval,
onSelectionChanged = { selectedInterval = it },
)
}
},
@ -91,7 +90,7 @@ fun SetIntervalDialog(
},
confirmButton = {
TextButton(onClick = {
onValueChanged(intervalValue)
onValueChanged(selectedInterval)
onDismissRequest()
},) {
Text(text = stringResource(R.string.action_ok))

View file

@ -74,7 +74,6 @@ import eu.kanade.tachiyomi.source.anime.getNameForAnimeInfo
import eu.kanade.tachiyomi.ui.browse.anime.extension.details.SourcePreferencesScreen
import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreenModel
import eu.kanade.tachiyomi.ui.entries.anime.EpisodeItem
import eu.kanade.tachiyomi.ui.entries.anime.FetchAnimeInterval
import eu.kanade.tachiyomi.util.lang.toRelativeString
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.coroutines.delay
@ -98,7 +97,7 @@ import java.util.concurrent.TimeUnit
fun AnimeScreen(
state: AnimeScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: FetchAnimeInterval?,
fetchInterval: Int?,
dateFormat: DateFormat,
isTabletUi: Boolean,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
@ -247,7 +246,7 @@ private fun AnimeScreenSmallImpl(
state: AnimeScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateFormat: DateFormat,
fetchInterval: FetchAnimeInterval?,
fetchInterval: Int?,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction,
showNextEpisodeAirTime: Boolean,
@ -518,7 +517,7 @@ fun AnimeScreenLargeImpl(
state: AnimeScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateFormat: DateFormat,
fetchInterval: FetchAnimeInterval?,
fetchInterval: Int?,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction,
showNextEpisodeAirTime: Boolean,

View file

@ -80,13 +80,13 @@ import eu.kanade.presentation.entries.DotSeparatorText
import eu.kanade.presentation.entries.ItemCover
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.ui.entries.anime.FetchAnimeInterval
import eu.kanade.tachiyomi.util.system.copyToClipboard
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.presentation.core.components.material.TextButton
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.util.clickableNoIndication
import tachiyomi.presentation.core.util.secondaryItemAlpha
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@ -168,7 +168,7 @@ fun AnimeActionRow(
modifier: Modifier = Modifier,
favorite: Boolean,
trackingCount: Int,
fetchInterval: FetchAnimeInterval?,
fetchInterval: Int?,
isUserIntervalMode: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
@ -192,14 +192,8 @@ fun AnimeActionRow(
onLongClick = onEditCategory,
)
if (onEditIntervalClicked != null && fetchInterval != null) {
val intervalPair = 1.coerceAtLeast(fetchInterval.interval - fetchInterval.leadDays) to (fetchInterval.interval + fetchInterval.followDays)
AnimeActionButton(
title =
if (intervalPair.first == intervalPair.second) {
pluralStringResource(id = R.plurals.day, count = intervalPair.second, intervalPair.second)
} else {
pluralStringResource(id = R.plurals.range_interval_day, count = intervalPair.second, intervalPair.first, intervalPair.second)
},
title = pluralStringResource(id = R.plurals.day, count = fetchInterval.absoluteValue, fetchInterval.absoluteValue),
icon = Icons.Default.HourglassEmpty,
color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onEditIntervalClicked,

View file

@ -68,7 +68,6 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.manga.getNameForMangaInfo
import eu.kanade.tachiyomi.ui.browse.manga.extension.details.MangaSourcePreferencesScreen
import eu.kanade.tachiyomi.ui.entries.manga.ChapterItem
import eu.kanade.tachiyomi.ui.entries.manga.FetchMangaInterval
import eu.kanade.tachiyomi.ui.entries.manga.MangaScreenModel
import eu.kanade.tachiyomi.util.lang.toRelativeString
import eu.kanade.tachiyomi.util.system.copyToClipboard
@ -91,7 +90,7 @@ import java.util.Date
fun MangaScreen(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: FetchMangaInterval?,
fetchInterval: Int?,
dateFormat: DateFormat,
isTabletUi: Boolean,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -230,7 +229,7 @@ private fun MangaScreenSmallImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateFormat: DateFormat,
fetchInterval: FetchMangaInterval?,
fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
onBackClicked: () -> Unit,
@ -466,7 +465,7 @@ fun MangaScreenLargeImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateFormat: DateFormat,
fetchInterval: FetchMangaInterval?,
fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
onBackClicked: () -> Unit,

View file

@ -80,13 +80,13 @@ import eu.kanade.presentation.entries.DotSeparatorText
import eu.kanade.presentation.entries.ItemCover
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.entries.manga.FetchMangaInterval
import eu.kanade.tachiyomi.util.system.copyToClipboard
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.presentation.core.components.material.TextButton
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.util.clickableNoIndication
import tachiyomi.presentation.core.util.secondaryItemAlpha
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@ -168,7 +168,7 @@ fun MangaActionRow(
modifier: Modifier = Modifier,
favorite: Boolean,
trackingCount: Int,
fetchInterval: FetchMangaInterval?,
fetchInterval: Int?,
isUserIntervalMode: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
@ -192,14 +192,8 @@ fun MangaActionRow(
onLongClick = onEditCategory,
)
if (onEditIntervalClicked != null && fetchInterval != null) {
val intervalPair = 1.coerceAtLeast(fetchInterval.interval - fetchInterval.leadDays) to (fetchInterval.interval + fetchInterval.followDays)
MangaActionButton(
title =
if (intervalPair.first == intervalPair.second) {
pluralStringResource(id = R.plurals.day, count = intervalPair.second, intervalPair.second)
} else {
pluralStringResource(id = R.plurals.range_interval_day, count = intervalPair.second, intervalPair.first, intervalPair.second)
},
title = pluralStringResource(id = R.plurals.day, count = fetchInterval.absoluteValue, fetchInterval.absoluteValue),
icon = Icons.Default.HourglassEmpty,
color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onEditIntervalClicked,

View file

@ -1,33 +1,18 @@
package eu.kanade.presentation.more.settings.screen
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastMap
import androidx.core.content.ContextCompat
import cafe.adriel.voyager.navigator.LocalNavigator
@ -52,7 +37,6 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_HAS_U
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.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -181,13 +165,10 @@ object SettingsLibraryScreen : SearchableSettings {
val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval()
val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState()
val libraryUpdateDeviceRestrictionPref = libraryPreferences.libraryUpdateDeviceRestriction()
val libraryUpdateMangaRestrictionPref = libraryPreferences.libraryUpdateItemRestriction()
val animelibUpdateCategoriesPref = libraryPreferences.animeLibraryUpdateCategories()
val animelibUpdateCategoriesExcludePref =
libraryPreferences.animeLibraryUpdateCategoriesExclude()
val libraryUpdateAnimeRestriction by libraryUpdateMangaRestrictionPref.collectAsState()
val includedAnime by animelibUpdateCategoriesPref.collectAsState()
val excludedAnime by animelibUpdateCategoriesExcludePref.collectAsState()
@ -211,27 +192,10 @@ object SettingsLibraryScreen : SearchableSettings {
},
)
}
val leadAnimeRange by libraryPreferences.leadingAnimeExpectedDays().collectAsState()
val followAnimeRange by libraryPreferences.followingAnimeExpectedDays().collectAsState()
var showFetchAnimeRangesDialog by rememberSaveable { mutableStateOf(false) }
if (showFetchAnimeRangesDialog) {
LibraryExpectedRangeDialog(
initialLead = leadAnimeRange,
initialFollow = followAnimeRange,
onDismissRequest = { showFetchAnimeRangesDialog = false },
onValueChanged = { leadValue, followValue ->
libraryPreferences.leadingAnimeExpectedDays().set(leadValue)
libraryPreferences.followingAnimeExpectedDays().set(followValue)
showFetchAnimeRangesDialog = false
},
)
}
val libraryUpdateCategoriesPref = libraryPreferences.mangaLibraryUpdateCategories()
val libraryUpdateCategoriesExcludePref =
libraryPreferences.mangaLibraryUpdateCategoriesExclude()
val libraryUpdateMangaRestriction by libraryUpdateMangaRestrictionPref.collectAsState()
val includedManga by libraryUpdateCategoriesPref.collectAsState()
val excludedManga by libraryUpdateCategoriesExcludePref.collectAsState()
@ -255,25 +219,10 @@ object SettingsLibraryScreen : SearchableSettings {
},
)
}
val leadMangaRange by libraryPreferences.leadingMangaExpectedDays().collectAsState()
val followMangaRange by libraryPreferences.followingMangaExpectedDays().collectAsState()
var showFetchMangaRangesDialog by rememberSaveable { mutableStateOf(false) }
if (showFetchMangaRangesDialog) {
LibraryExpectedRangeDialog(
initialLead = leadMangaRange,
initialFollow = followMangaRange,
onDismissRequest = { showFetchMangaRangesDialog = false },
onValueChanged = { leadValue, followValue ->
libraryPreferences.leadingMangaExpectedDays().set(leadValue)
libraryPreferences.followingMangaExpectedDays().set(followValue)
showFetchMangaRangesDialog = false
},
)
}
return Preference.PreferenceGroup(
title = stringResource(R.string.pref_category_library_update),
preferenceItems = listOfNotNull(
preferenceItems = listOf(
Preference.PreferenceItem.ListPreference(
pref = libraryUpdateIntervalPref,
title = stringResource(R.string.pref_library_update_interval),
@ -292,7 +241,7 @@ object SettingsLibraryScreen : SearchableSettings {
},
),
Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryUpdateDeviceRestrictionPref,
pref = libraryPreferences.libraryUpdateDeviceRestriction(),
enabled = libraryUpdateInterval > 0,
title = stringResource(R.string.pref_library_update_restriction),
subtitle = stringResource(R.string.restrictions),
@ -341,7 +290,7 @@ object SettingsLibraryScreen : SearchableSettings {
subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary),
),
Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryUpdateMangaRestrictionPref,
pref = libraryPreferences.libraryUpdateItemRestriction(),
title = stringResource(R.string.pref_library_update_manga_restriction),
entries = mapOf(
ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read),
@ -350,29 +299,6 @@ object SettingsLibraryScreen : SearchableSettings {
ENTRY_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period),
),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_update_release_grace_period),
subtitle = listOf(
pluralStringResource(R.plurals.pref_update_release_leading_days, leadMangaRange, leadMangaRange),
pluralStringResource(R.plurals.pref_update_release_following_days, followMangaRange, followMangaRange),
).joinToString(),
onClick = { showFetchMangaRangesDialog = true },
).takeIf { ENTRY_OUTSIDE_RELEASE_PERIOD in libraryUpdateMangaRestriction },
Preference.PreferenceItem.InfoPreference(
title = stringResource(R.string.pref_update_release_grace_period_info),
).takeIf { ENTRY_OUTSIDE_RELEASE_PERIOD in libraryUpdateMangaRestriction },
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_update_anime_release_grace_period),
subtitle = listOf(
pluralStringResource(R.plurals.pref_update_release_leading_days, leadAnimeRange, leadAnimeRange),
pluralStringResource(R.plurals.pref_update_release_following_days, followAnimeRange, followAnimeRange),
).joinToString(),
onClick = { showFetchAnimeRangesDialog = true },
).takeIf { ENTRY_OUTSIDE_RELEASE_PERIOD in libraryUpdateAnimeRestriction },
Preference.PreferenceItem.InfoPreference(
title = stringResource(R.string.pref_update_release_grace_period_info),
).takeIf { ENTRY_OUTSIDE_RELEASE_PERIOD in libraryUpdateAnimeRestriction },
Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.newShowUpdatesCount(),
title = stringResource(R.string.pref_library_update_show_tab_badge),
@ -442,79 +368,4 @@ object SettingsLibraryScreen : SearchableSettings {
),
)
}
@Composable
private fun LibraryExpectedRangeDialog(
initialLead: Int,
initialFollow: Int,
onDismissRequest: () -> Unit,
onValueChanged: (portrait: Int, landscape: Int) -> Unit,
) {
var leadValue by rememberSaveable { mutableIntStateOf(initialLead) }
var followValue by rememberSaveable { mutableIntStateOf(initialFollow) }
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(R.string.pref_update_release_grace_period)) },
text = {
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.weight(1f),
text = pluralStringResource(R.plurals.pref_update_release_leading_days, leadValue, leadValue),
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
)
Text(
modifier = Modifier.weight(1f),
text = pluralStringResource(R.plurals.pref_update_release_following_days, followValue, followValue),
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
)
}
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val size = DpSize(width = maxWidth / 2, height = 128.dp)
val items = (0..28).map(Int::toString)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
WheelTextPicker(
size = size,
items = items,
startIndex = leadValue,
onSelectionChanged = {
leadValue = it
},
)
WheelTextPicker(
size = size,
items = items,
startIndex = followValue,
onSelectionChanged = {
followValue = it
},
)
}
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
confirmButton = {
TextButton(onClick = { onValueChanged(leadValue, followValue) }) {
Text(text = stringResource(R.string.action_ok))
}
},
)
}
}

View file

@ -0,0 +1,76 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
@Composable
fun BottomReaderBar(
readingMode: ReadingModeType,
onClickReadingMode: () -> Unit,
orientationMode: OrientationType,
onClickOrientationMode: () -> Unit,
cropEnabled: Boolean,
onClickCropBorder: () -> Unit,
onClickSettings: () -> Unit,
) {
// Match with toolbar background color set in ReaderActivity
val backgroundColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onClickReadingMode) {
Icon(
painter = painterResource(readingMode.iconRes),
contentDescription = stringResource(R.string.viewer),
)
}
IconButton(onClick = onClickCropBorder) {
Icon(
painter = painterResource(if (cropEnabled) R.drawable.ic_crop_24dp else R.drawable.ic_crop_off_24dp),
contentDescription = stringResource(R.string.pref_crop_borders),
)
}
IconButton(onClick = onClickOrientationMode) {
Icon(
painter = painterResource(orientationMode.iconRes),
contentDescription = stringResource(R.string.pref_rotation_type),
)
}
IconButton(onClick = onClickSettings) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.action_settings),
)
}
}
}

View file

@ -53,6 +53,15 @@ fun ChapterNavigator(
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
val haptic = LocalHapticFeedback.current
// Match with toolbar background color set in ReaderActivity
val backgroundColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
val buttonColor = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor,
disabledContainerColor = backgroundColor,
)
// We explicitly handle direction based on the reader viewer rather than the system direction
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(
@ -61,14 +70,6 @@ fun ChapterNavigator(
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
// Match with toolbar background color set in ReaderActivity
val backgroundColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
val buttonColor = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor,
disabledContainerColor = backgroundColor,
)
FilledIconButton(
enabled = if (isRtl) enabledNext else enabledPrevious,
onClick = if (isRtl) onNextChapter else onPreviousChapter,

View file

@ -0,0 +1,56 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.entries.manga.model.orientationType
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.material.padding
private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it }
@Composable
fun OrientationModeSelectDialog(
onDismissRequest: () -> Unit,
screenModel: ReaderSettingsScreenModel,
onChange: (Int) -> Unit,
) {
val manga by screenModel.mangaFlow.collectAsState()
val orientationType = remember(manga) { OrientationType.fromPreference(manga?.orientationType?.toInt()) }
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) {
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
SettingsChipRow(R.string.rotation_type) {
orientationTypeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == orientationType,
onClick = {
screenModel.onChangeOrientation(it)
onChange(stringRes)
},
label = { Text(stringResource(stringRes)) },
)
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.ui.reader
package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row

View file

@ -0,0 +1,56 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.entries.manga.model.readingModeType
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.material.padding
private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it }
@Composable
fun ReadingModeSelectDialog(
onDismissRequest: () -> Unit,
screenModel: ReaderSettingsScreenModel,
onChange: (Int) -> Unit,
) {
val manga by screenModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingModeType.fromPreference(manga?.readingModeType?.toInt()) }
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) {
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
SettingsChipRow(R.string.pref_category_reading_mode) {
readingModeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == readingMode,
onClick = {
screenModel.onChangeReadingMode(it)
onChange(stringRes)
},
label = { Text(stringResource(stringRes)) },
)
}
}
}
}
}

View file

@ -42,11 +42,12 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.preferences.fullscreen(),
)
// TODO: hide if there's no cutout
CheckboxItem(
label = stringResource(R.string.pref_cutout_short),
pref = screenModel.preferences.cutoutShort(),
)
if (screenModel.hasDisplayCutout) {
CheckboxItem(
label = stringResource(R.string.pref_cutout_short),
pref = screenModel.preferences.cutoutShort(),
)
}
CheckboxItem(
label = stringResource(R.string.pref_keep_screen_on),

View file

@ -60,9 +60,9 @@ class BackupRestorer(
private val episodeRepository: EpisodeRepository = Injekt.get()
private val setAnimeFetchInterval: SetAnimeFetchInterval = Injekt.get()
private var zonedDateTime = ZonedDateTime.now()
private var currentMangaFetchInterval = setMangaFetchInterval.getCurrent(zonedDateTime)
private var currentAnimeFetchInterval = setAnimeFetchInterval.getCurrent(zonedDateTime)
private var now = ZonedDateTime.now()
private var currentMangaFetchWindow = setMangaFetchInterval.getWindow(now)
private var currentAnimeFetchWindow = setAnimeFetchInterval.getWindow(now)
private var backupManager = BackupManager(context)
@ -140,9 +140,9 @@ 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()
currentMangaFetchInterval = setMangaFetchInterval.getCurrent(zonedDateTime)
currentAnimeFetchInterval = setAnimeFetchInterval.getCurrent(zonedDateTime)
now = ZonedDateTime.now()
currentMangaFetchWindow = setMangaFetchInterval.getWindow(now)
currentAnimeFetchWindow = setAnimeFetchInterval.getWindow(now)
return coroutineScope {
// Restore individual manga
@ -216,8 +216,7 @@ 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, currentMangaFetchInterval)
updateManga.awaitUpdateFetchInterval(restoredManga, now, currentMangaFetchWindow)
} catch (e: Exception) {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
@ -291,8 +290,7 @@ 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, currentAnimeFetchInterval)
updateAnime.awaitUpdateFetchInterval(restoredAnime, now, currentAnimeFetchWindow)
} catch (e: Exception) {
val sourceName = sourceMapping[anime.source] ?: anime.source.toString()
errors.add(Date() to "${anime.title} [$sourceName]: ${e.message}")

View file

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.entries.anime.model.Anime

View file

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber

View file

@ -30,7 +30,7 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.shouldDownloadNewEpisodes
import eu.kanade.tachiyomi.util.storage.getUriCompat
@ -230,10 +230,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
val now = ZonedDateTime.now()
val fetchInterval = setAnimeFetchInterval.getCurrent(now)
val higherLimit = fetchInterval.second
val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
animeToUpdate.groupBy { it.anime.source }.values
@ -255,8 +252,8 @@ 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))
anime.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_always_update))
ENTRY_NON_COMPLETED in restrictions && anime.status.toInt() == SAnime.COMPLETED ->
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_completed))
@ -267,12 +264,12 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
ENTRY_NON_VIEWED in restrictions && libraryAnime.totalEpisodes > 0L && !libraryAnime.hasStarted ->
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_started))
anime.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_always_update))
ENTRY_OUTSIDE_RELEASE_PERIOD in restrictions && anime.nextUpdate > fetchWindow.second ->
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_in_release_period))
else -> {
try {
val newEpisodes = updateAnime(anime, now, fetchInterval)
val newEpisodes = updateAnime(anime, fetchWindow)
.sortedByDescending { it.sourceOrder }
if (newEpisodes.isNotEmpty()) {
@ -328,6 +325,13 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
)
}
if (skippedUpdates.isNotEmpty()) {
// TODO: surface skipped reasons to user
logcat {
skippedUpdates
.groupBy { it.second }
.map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" }
.joinToString()
}
notifier.showUpdateSkippedNotification(skippedUpdates.size)
}
}
@ -344,7 +348,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, zoneDateTime: ZonedDateTime, fetchRange: Pair<Long, Long>): List<Episode> {
private suspend fun updateAnime(anime: Anime, fetchWindow: Pair<Long, Long>): List<Episode> {
val source = sourceManager.getOrStub(anime.source)
// Update anime metadata if needed
@ -359,7 +363,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, false, zoneDateTime, fetchRange)
return syncEpisodesWithSource.await(episodes, dbAnime, source, false, fetchWindow)
}
private suspend fun updateCovers() {

View file

@ -28,9 +28,9 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.track.EnhancedMangaTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.model.UpdateStrategy
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.storage.getUriCompat
@ -230,10 +230,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
val now = ZonedDateTime.now()
val fetchInterval = setMangaFetchInterval.getCurrent(now)
val higherLimit = fetchInterval.second
val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values
@ -255,8 +252,8 @@ 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))
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update))
ENTRY_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed))
@ -267,12 +264,12 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
ENTRY_NON_VIEWED in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_started))
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update))
ENTRY_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate > fetchWindow.second ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_in_release_period))
else -> {
try {
val newChapters = updateManga(manga, now, fetchInterval)
val newChapters = updateManga(manga, fetchWindow)
.sortedByDescending { it.sourceOrder }
if (newChapters.isNotEmpty()) {
@ -328,6 +325,13 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
)
}
if (skippedUpdates.isNotEmpty()) {
// TODO: surface skipped reasons to user
logcat {
skippedUpdates
.groupBy { it.second }
.map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" }
.joinToString()
}
notifier.showUpdateSkippedNotification(skippedUpdates.size)
}
}
@ -344,7 +348,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, zoneDateTime: ZonedDateTime, fetchRange: Pair<Long, Long>): List<Chapter> {
private suspend fun updateManga(manga: Manga, fetchWindow: Pair<Long, Long>): List<Chapter> {
val source = sourceManager.getOrStub(manga.source)
// Update manga metadata if needed
@ -359,7 +363,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, false, zoneDateTime, fetchRange)
return syncChaptersWithSource.await(chapters, dbManga, source, false, fetchWindow)
}
private suspend fun updateCovers() {

View file

@ -29,7 +29,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import java.util.Calendar
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
@ -37,7 +37,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val authClient = client.newBuilder()
.addInterceptor(interceptor)
.rateLimit(permits = 85, period = 1, unit = TimeUnit.MINUTES)
.rateLimit(permits = 85, period = 1.minutes)
.build()
suspend fun addLibManga(track: MangaTrack): MangaTrack {

View file

@ -5,6 +5,7 @@ import androidx.core.app.NotificationCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.notify
class ExtensionUpdateNotifier(private val context: Context) {
@ -29,4 +30,8 @@ class ExtensionUpdateNotifier(private val context: Context) {
setAutoCancel(true)
}
}
fun dismiss() {
context.cancelNotification(Notifications.ID_UPDATES_TO_EXTS)
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.drawable.Drawable
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.extension.anime.api.AnimeExtensionGithubApi
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
@ -195,7 +196,7 @@ class AnimeExtensionManager(
}
/**
* Returns an observable of the installation process for the given anime extension. It will complete
* Returns a flow of the installation process for the given anime extension. It will complete
* once the anime extension is installed or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
@ -206,7 +207,7 @@ class AnimeExtensionManager(
}
/**
* Returns an observable of the installation process for the given anime extension. It will complete
* Returns a flow of the installation process for the given anime extension. It will complete
* once the anime extension is updated or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
@ -356,6 +357,10 @@ class AnimeExtensionManager(
}
private fun updatePendingUpdatesCount() {
preferences.animeExtensionUpdatesCount().set(_installedAnimeExtensionsFlow.value.count { it.hasUpdate })
val pendingUpdateCount = _installedAnimeExtensionsFlow.value.count { it.hasUpdate }
preferences.animeExtensionUpdatesCount().set(pendingUpdateCount)
if (pendingUpdateCount == 0) {
ExtensionUpdateNotifier(context).dismiss()
}
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.drawable.Drawable
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.extension.manga.api.MangaExtensionGithubApi
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
@ -195,7 +196,7 @@ class MangaExtensionManager(
}
/**
* Returns an observable of the installation process for the given extension. It will complete
* Returns a flow of the installation process for the given extension. It will complete
* once the extension is installed or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
@ -206,7 +207,7 @@ class MangaExtensionManager(
}
/**
* Returns an observable of the installation process for the given extension. It will complete
* Returns a flow of the installation process for the given extension. It will complete
* once the extension is updated or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
@ -356,6 +357,10 @@ class MangaExtensionManager(
}
private fun updatePendingUpdatesCount() {
preferences.mangaExtensionUpdatesCount().set(_installedExtensionsFlow.value.count { it.hasUpdate })
val pendingUpdateCount = _installedExtensionsFlow.value.count { it.hasUpdate }
preferences.mangaExtensionUpdatesCount().set(pendingUpdateCount)
if (pendingUpdateCount == 0) {
ExtensionUpdateNotifier(context).dismiss()
}
}
}

View file

@ -157,14 +157,14 @@ class AnimeExtensionsScreenModel(
extensionManager.cancelInstallUpdateExtension(extension)
}
private fun removeDownloadState(extension: AnimeExtension) {
_currentDownloads.update { it - extension.pkgName }
}
private fun addDownloadState(extension: AnimeExtension, installStep: InstallStep) {
_currentDownloads.update { it + Pair(extension.pkgName, installStep) }
}
private fun removeDownloadState(extension: AnimeExtension) {
_currentDownloads.update { it - extension.pkgName }
}
private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: AnimeExtension) =
this
.onEach { installStep -> addDownloadState(extension, installStep) }

View file

@ -4,28 +4,36 @@ import eu.kanade.domain.entries.anime.model.hasCustomCover
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
import kotlinx.coroutines.runBlocking
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.track.anime.interactor.GetAnimeTracks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
data class AnimeMigrationFlag(
val flag: Int,
val isDefaultSelected: Boolean,
val titleId: Int,
) {
companion object {
fun create(flag: Int, defaultSelectionMap: Int, titleId: Int): AnimeMigrationFlag {
return AnimeMigrationFlag(
flag = flag,
isDefaultSelected = defaultSelectionMap and flag != 0,
titleId = titleId,
)
}
}
}
object AnimeMigrationFlags {
private const val EPISODES = 0b00001
private const val CATEGORIES = 0b00010
private const val TRACK = 0b00100
private const val CUSTOM_COVER = 0b01000
private const val DELETE_DOWNLOADED = 0b10000
private val coverCache: AnimeCoverCache by injectLazy()
private val getTracks: GetAnimeTracks = Injekt.get()
private val downloadCache: AnimeDownloadCache by injectLazy()
val flags get() = arrayOf(EPISODES, CATEGORIES, TRACK, CUSTOM_COVER, DELETE_DOWNLOADED)
private var enableFlags = emptyList<Int>().toMutableList()
fun hasEpisodes(value: Int): Boolean {
return value and EPISODES != 0
}
@ -34,10 +42,6 @@ object AnimeMigrationFlags {
return value and CATEGORIES != 0
}
fun hasTracks(value: Int): Boolean {
return value and TRACK != 0
}
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
@ -46,35 +50,32 @@ object AnimeMigrationFlags {
return value and DELETE_DOWNLOADED != 0
}
fun getEnabledFlagsPositions(value: Int): List<Int> {
return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null }
}
/** Returns information about applicable flags with default selections. */
fun getFlags(anime: Anime?, defaultSelectedBitMap: Int): List<AnimeMigrationFlag> {
val flags = mutableListOf<AnimeMigrationFlag>()
flags += AnimeMigrationFlag.create(EPISODES, defaultSelectedBitMap, R.string.chapters)
flags += AnimeMigrationFlag.create(CATEGORIES, defaultSelectedBitMap, R.string.categories)
fun getFlagsFromPositions(positions: Array<Int>): Int {
val fold = positions.fold(0) { accumulated, position -> accumulated or enableFlags[position] }
enableFlags.clear()
return fold
}
fun titles(anime: Anime?): Array<Int> {
enableFlags.add(EPISODES)
enableFlags.add(CATEGORIES)
val titles = arrayOf(R.string.episodes, R.string.anime_categories).toMutableList()
if (anime != null) {
if (runBlocking { getTracks.await(anime.id) }.isNotEmpty()) {
titles.add(R.string.track)
enableFlags.add(TRACK)
}
if (anime.hasCustomCover(coverCache)) {
titles.add(R.string.custom_cover)
enableFlags.add(CUSTOM_COVER)
flags += AnimeMigrationFlag.create(CUSTOM_COVER, defaultSelectedBitMap, R.string.custom_cover)
}
if (downloadCache.getDownloadCount(anime) > 0) {
titles.add(R.string.delete_downloaded)
enableFlags.add(DELETE_DOWNLOADED)
flags += AnimeMigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, R.string.delete_downloaded)
}
}
return titles.toTypedArray()
return flags
}
/** Returns a bit map of selected flags. */
fun getSelectedFlagsBitMap(
selectedFlags: List<Boolean>,
flags: List<AnimeMigrationFlag>,
): Int {
return selectedFlags
.zip(flags)
.filter { (isSelected, _) -> isSelected }
.map { (_, flag) -> flag.flag }
.reduceOrNull { acc, mask -> acc or mask } ?: 0
}
}

View file

@ -19,15 +19,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import cafe.adriel.voyager.core.model.StateScreenModel
import eu.kanade.domain.entries.anime.interactor.UpdateAnime
import eu.kanade.domain.entries.anime.model.hasCustomCover
@ -74,15 +73,8 @@ internal fun MigrateAnimeDialog(
val scope = rememberCoroutineScope()
val state by screenModel.state.collectAsState()
val activeFlags = remember { AnimeMigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) }
val items = remember {
AnimeMigrationFlags.titles(oldAnime)
.map { context.getString(it) }
.toList()
}
val selected = remember {
mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray())
}
val flags = remember { AnimeMigrationFlags.getFlags(oldAnime, screenModel.migrateFlags.get()) }
val selectedFlags = remember { flags.map { it.isDefaultSelected }.toMutableStateList() }
if (state.isMigrating) {
LoadingScreen(
@ -99,18 +91,16 @@ internal fun MigrateAnimeDialog(
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
items.forEachIndexed { index, title ->
val onChange: () -> Unit = {
selected[index] = !selected[index]
}
flags.forEachIndexed { index, flag ->
val onChange = { selectedFlags[index] = !selectedFlags[index] }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onChange),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = selected[index], onCheckedChange = { onChange() })
Text(text = title)
Checkbox(checked = selectedFlags[index], onCheckedChange = { onChange() })
Text(text = context.getString(flag.titleId))
}
}
}
@ -133,7 +123,12 @@ internal fun MigrateAnimeDialog(
TextButton(
onClick = {
scope.launchIO {
screenModel.migrateAnime(oldAnime, newAnime, false)
screenModel.migrateAnime(
oldAnime,
newAnime,
false,
AnimeMigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
)
withUIContext { onPopScreen() }
}
},
@ -143,12 +138,13 @@ internal fun MigrateAnimeDialog(
TextButton(
onClick = {
scope.launchIO {
val selectedIndices = mutableListOf<Int>()
selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) }
val newValue =
AnimeMigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray())
screenModel.migrateFlags.set(newValue)
screenModel.migrateAnime(oldAnime, newAnime, true)
screenModel.migrateAnime(
oldAnime,
newAnime,
true,
AnimeMigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
)
withUIContext { onPopScreen() }
}
},
@ -184,7 +180,13 @@ internal class MigrateAnimeDialogScreenModel(
Injekt.get<TrackManager>().services.filterIsInstance<EnhancedAnimeTrackService>()
}
suspend fun migrateAnime(oldAnime: Anime, newAnime: Anime, replace: Boolean) {
suspend fun migrateAnime(
oldAnime: Anime,
newAnime: Anime,
replace: Boolean,
flags: Int,
) {
migrateFlags.set(flags)
val source = sourceManager.get(newAnime.source) ?: return
val prevSource = sourceManager.get(oldAnime.source)
@ -200,6 +202,7 @@ internal class MigrateAnimeDialogScreenModel(
newAnime = newAnime,
sourceEpisodes = episodes,
replace = replace,
flags = flags,
)
} catch (_: Throwable) {
// Explicitly stop if an error occurred; the dialog normally gets popped at the end
@ -215,12 +218,10 @@ internal class MigrateAnimeDialogScreenModel(
newAnime: Anime,
sourceEpisodes: List<SEpisode>,
replace: Boolean,
flags: Int,
) {
val flags = migrateFlags.get()
val migrateEpisodes = AnimeMigrationFlags.hasEpisodes(flags)
val migrateCategories = AnimeMigrationFlags.hasCategories(flags)
val migrateTracks = AnimeMigrationFlags.hasTracks(flags)
val migrateCustomCover = AnimeMigrationFlags.hasCustomCover(flags)
val deleteDownloaded = AnimeMigrationFlags.hasDeleteDownloaded(flags)
@ -271,21 +272,20 @@ internal class MigrateAnimeDialogScreenModel(
}
// Update track
if (migrateTracks) {
val tracks = getTracks.await(oldAnime.id).mapNotNull { track ->
val updatedTrack = track.copy(animeId = newAnime.id)
getTracks.await(oldAnime.id).mapNotNull { track ->
val updatedTrack = track.copy(animeId = newAnime.id)
val service = enhancedServices
.firstOrNull { it.isTrackFrom(updatedTrack, oldAnime, oldSource) }
val service = enhancedServices
.firstOrNull { it.isTrackFrom(updatedTrack, oldAnime, oldSource) }
if (service != null) {
service.migrateTrack(updatedTrack, newAnime, newSource)
} else {
updatedTrack
}
if (service != null) {
service.migrateTrack(updatedTrack, newAnime, newSource)
} else {
updatedTrack
}
insertTrack.awaitAll(tracks)
}
.takeIf { it.isNotEmpty() }
?.let { insertTrack.awaitAll(it) }
// Delete downloaded
if (deleteDownloaded) {

View file

@ -158,14 +158,14 @@ class MangaExtensionsScreenModel(
extensionManager.cancelInstallUpdateExtension(extension)
}
private fun removeDownloadState(extension: MangaExtension) {
_currentDownloads.update { it - extension.pkgName }
}
private fun addDownloadState(extension: MangaExtension, installStep: InstallStep) {
_currentDownloads.update { it + Pair(extension.pkgName, installStep) }
}
private fun removeDownloadState(extension: MangaExtension) {
_currentDownloads.update { it - extension.pkgName }
}
private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: MangaExtension) =
this
.onEach { installStep -> addDownloadState(extension, installStep) }

View file

@ -4,28 +4,36 @@ import eu.kanade.domain.entries.manga.model.hasCustomCover
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.MangaCoverCache
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
import kotlinx.coroutines.runBlocking
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.track.manga.interactor.GetMangaTracks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
data class MangaMigrationFlag(
val flag: Int,
val isDefaultSelected: Boolean,
val titleId: Int,
) {
companion object {
fun create(flag: Int, defaultSelectionMap: Int, titleId: Int): MangaMigrationFlag {
return MangaMigrationFlag(
flag = flag,
isDefaultSelected = defaultSelectionMap and flag != 0,
titleId = titleId,
)
}
}
}
object MangaMigrationFlags {
private const val CHAPTERS = 0b00001
private const val CATEGORIES = 0b00010
private const val TRACK = 0b00100
private const val CUSTOM_COVER = 0b01000
private const val DELETE_DOWNLOADED = 0b10000
private val coverCache: MangaCoverCache by injectLazy()
private val getTracks: GetMangaTracks = Injekt.get()
private val downloadCache: MangaDownloadCache by injectLazy()
val flags get() = arrayOf(CHAPTERS, CATEGORIES, TRACK, CUSTOM_COVER, DELETE_DOWNLOADED)
private var enableFlags = emptyList<Int>().toMutableList()
fun hasChapters(value: Int): Boolean {
return value and CHAPTERS != 0
}
@ -34,10 +42,6 @@ object MangaMigrationFlags {
return value and CATEGORIES != 0
}
fun hasTracks(value: Int): Boolean {
return value and TRACK != 0
}
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
@ -46,34 +50,32 @@ object MangaMigrationFlags {
return value and DELETE_DOWNLOADED != 0
}
fun getEnabledFlagsPositions(value: Int): List<Int> {
return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null }
}
/** Returns information about applicable flags with default selections. */
fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List<MangaMigrationFlag> {
val flags = mutableListOf<MangaMigrationFlag>()
flags += MangaMigrationFlag.create(CHAPTERS, defaultSelectedBitMap, R.string.chapters)
flags += MangaMigrationFlag.create(CATEGORIES, defaultSelectedBitMap, R.string.categories)
fun getFlagsFromPositions(positions: Array<Int>): Int {
val fold = positions.fold(0) { accumulated, position -> accumulated or enableFlags[position] }
enableFlags.clear()
return fold
}
fun titles(manga: Manga?): Array<Int> {
enableFlags.add(CHAPTERS)
enableFlags.add(CATEGORIES)
val titles = arrayOf(R.string.chapters, R.string.manga_categories).toMutableList()
if (manga != null) {
if (runBlocking { getTracks.await(manga.id) }.isNotEmpty()) {
titles.add(R.string.track)
enableFlags.add(TRACK)
}
if (manga.hasCustomCover(coverCache)) {
titles.add(R.string.custom_cover)
enableFlags.add(CUSTOM_COVER)
flags += MangaMigrationFlag.create(CUSTOM_COVER, defaultSelectedBitMap, R.string.custom_cover)
}
if (downloadCache.getDownloadCount(manga) > 0) {
titles.add(R.string.delete_downloaded)
enableFlags.add(DELETE_DOWNLOADED)
flags += MangaMigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, R.string.delete_downloaded)
}
}
return titles.toTypedArray()
return flags
}
/** Returns a bit map of selected flags. */
fun getSelectedFlagsBitMap(
selectedFlags: List<Boolean>,
flags: List<MangaMigrationFlag>,
): Int {
return selectedFlags
.zip(flags)
.filter { (isSelected, _) -> isSelected }
.map { (_, flag) -> flag.flag }
.reduceOrNull { acc, mask -> acc or mask } ?: 0
}
}

View file

@ -19,15 +19,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import cafe.adriel.voyager.core.model.StateScreenModel
import eu.kanade.domain.entries.manga.interactor.UpdateManga
import eu.kanade.domain.entries.manga.model.hasCustomCover
@ -74,15 +73,8 @@ internal fun MigrateMangaDialog(
val scope = rememberCoroutineScope()
val state by screenModel.state.collectAsState()
val activeFlags = remember { MangaMigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) }
val items = remember {
MangaMigrationFlags.titles(oldManga)
.map { context.getString(it) }
.toList()
}
val selected = remember {
mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray())
}
val flags = remember { MangaMigrationFlags.getFlags(oldManga, screenModel.migrateFlags.get()) }
val selectedFlags = remember { flags.map { it.isDefaultSelected }.toMutableStateList() }
if (state.isMigrating) {
LoadingScreen(
@ -99,18 +91,16 @@ internal fun MigrateMangaDialog(
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
items.forEachIndexed { index, title ->
val onChange: () -> Unit = {
selected[index] = !selected[index]
}
flags.forEachIndexed { index, flag ->
val onChange = { selectedFlags[index] = !selectedFlags[index] }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onChange),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = selected[index], onCheckedChange = { onChange() })
Text(text = title)
Checkbox(checked = selectedFlags[index], onCheckedChange = { onChange() })
Text(text = context.getString(flag.titleId))
}
}
}
@ -133,7 +123,12 @@ internal fun MigrateMangaDialog(
TextButton(
onClick = {
scope.launchIO {
screenModel.migrateManga(oldManga, newManga, false)
screenModel.migrateManga(
oldManga,
newManga,
false,
MangaMigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
)
withUIContext { onPopScreen() }
}
},
@ -143,12 +138,13 @@ internal fun MigrateMangaDialog(
TextButton(
onClick = {
scope.launchIO {
val selectedIndices = mutableListOf<Int>()
selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) }
val newValue =
MangaMigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray())
screenModel.migrateFlags.set(newValue)
screenModel.migrateManga(oldManga, newManga, true)
screenModel.migrateManga(
oldManga,
newManga,
true,
MangaMigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
)
withUIContext { onPopScreen() }
}
},
@ -184,7 +180,13 @@ internal class MigrateMangaDialogScreenModel(
Injekt.get<TrackManager>().services.filterIsInstance<EnhancedMangaTrackService>()
}
suspend fun migrateManga(oldManga: Manga, newManga: Manga, replace: Boolean) {
suspend fun migrateManga(
oldManga: Manga,
newManga: Manga,
replace: Boolean,
flags: Int,
) {
migrateFlags.set(flags)
val source = sourceManager.get(newManga.source) ?: return
val prevSource = sourceManager.get(oldManga.source)
@ -200,6 +202,7 @@ internal class MigrateMangaDialogScreenModel(
newManga = newManga,
sourceChapters = chapters,
replace = replace,
flags = flags,
)
} catch (_: Throwable) {
// Explicitly stop if an error occurred; the dialog normally gets popped at the end
@ -215,12 +218,10 @@ internal class MigrateMangaDialogScreenModel(
newManga: Manga,
sourceChapters: List<SChapter>,
replace: Boolean,
flags: Int,
) {
val flags = migrateFlags.get()
val migrateChapters = MangaMigrationFlags.hasChapters(flags)
val migrateCategories = MangaMigrationFlags.hasCategories(flags)
val migrateTracks = MangaMigrationFlags.hasTracks(flags)
val migrateCustomCover = MangaMigrationFlags.hasCustomCover(flags)
val deleteDownloaded = MangaMigrationFlags.hasDeleteDownloaded(flags)
@ -271,21 +272,20 @@ internal class MigrateMangaDialogScreenModel(
}
// Update track
if (migrateTracks) {
val tracks = getTracks.await(oldManga.id).mapNotNull { track ->
val updatedTrack = track.copy(mangaId = newManga.id)
getTracks.await(oldManga.id).mapNotNull { track ->
val updatedTrack = track.copy(mangaId = newManga.id)
val service = enhancedServices
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
val service = enhancedServices
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
if (service != null) {
service.migrateTrack(updatedTrack, newManga, newSource)
} else {
updatedTrack
}
if (service != null) {
service.migrateTrack(updatedTrack, newManga, newSource)
} else {
updatedTrack
}
insertTrack.awaitAll(tracks)
}
.takeIf { it.isNotEmpty() }
?.let { insertTrack.awaitAll(it) }
// Delete downloaded
if (deleteDownloaded) {

View file

@ -89,13 +89,6 @@ class AnimeScreen(
val successState = state as AnimeScreenModel.State.Success
val isAnimeHttpSource = remember { successState.source is AnimeHttpSource }
val fetchInterval = remember(successState.anime.fetchInterval) {
FetchAnimeInterval(
interval = successState.anime.fetchInterval,
leadDays = screenModel.leadDay,
followDays = screenModel.followDay,
)
}
LaunchedEffect(successState.anime, screenModel.source) {
if (isAnimeHttpSource) {
@ -113,7 +106,7 @@ class AnimeScreen(
state = successState,
snackbarHostState = screenModel.snackbarHostState,
dateFormat = screenModel.dateFormat,
fetchInterval = fetchInterval,
fetchInterval = successState.anime.fetchInterval,
isTabletUi = isTabletUi(),
episodeSwipeStartAction = screenModel.episodeSwipeStartAction,
episodeSwipeEndAction = screenModel.episodeSwipeEndAction,
@ -244,7 +237,7 @@ class AnimeScreen(
}
is AnimeScreenModel.Dialog.SetAnimeFetchInterval -> {
SetIntervalDialog(
interval = if (dialog.anime.fetchInterval < 0) -dialog.anime.fetchInterval else 0,
interval = dialog.anime.fetchInterval,
onDismissRequest = onDismissRequest,
onValueChanged = { screenModel.setFetchInterval(dialog.anime, it) },
)

View file

@ -135,8 +135,6 @@ class AnimeScreenModel(
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get()
val leadDay = libraryPreferences.leadingAnimeExpectedDays().get()
val followDay = libraryPreferences.followingAnimeExpectedDays().get()
private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedEpisodeIds: HashSet<Long> = HashSet()
@ -377,20 +375,14 @@ class AnimeScreenModel(
}
}
fun setFetchInterval(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.fetchInterval < 0) 0 else anime.fetchInterval
else -> -newInterval
}
fun setFetchInterval(anime: Anime, interval: Int) {
coroutineScope.launchIO {
updateAnime.awaitUpdateFetchInterval(
anime.copy(fetchInterval = interval),
successState?.episodes?.map { it.episode }.orEmpty(),
// Custom intervals are negative
anime.copy(fetchInterval = -interval),
)
val newAnime = animeRepository.getAnimeById(animeId)
updateSuccessState { it.copy(anime = newAnime) }
val updatedAnime = animeRepository.getAnimeById(anime.id)
updateSuccessState { it.copy(anime = updatedAnime) }
}
}
@ -1105,10 +1097,3 @@ data class EpisodeItem(
) {
val isDownloaded = downloadState == AnimeDownload.State.DOWNLOADED
}
@Immutable
data class FetchAnimeInterval(
val interval: Int,
val leadDays: Int,
val followDays: Int,
)

View file

@ -84,13 +84,6 @@ class MangaScreen(
val successState = state as MangaScreenModel.State.Success
val isHttpSource = remember { successState.source is HttpSource }
val fetchInterval = remember(successState.manga.fetchInterval) {
FetchMangaInterval(
interval = successState.manga.fetchInterval,
leadDays = screenModel.leadDay,
followDays = screenModel.followDay,
)
}
LaunchedEffect(successState.manga, screenModel.source) {
if (isHttpSource) {
@ -108,7 +101,7 @@ class MangaScreen(
state = successState,
snackbarHostState = screenModel.snackbarHostState,
dateFormat = screenModel.dateFormat,
fetchInterval = fetchInterval,
fetchInterval = successState.manga.fetchInterval,
isTabletUi = isTabletUi(),
chapterSwipeStartAction = screenModel.chapterSwipeStartAction,
chapterSwipeEndAction = screenModel.chapterSwipeEndAction,
@ -226,7 +219,7 @@ class MangaScreen(
}
is MangaScreenModel.Dialog.SetMangaFetchInterval -> {
SetIntervalDialog(
interval = if (dialog.manga.fetchInterval < 0) -dialog.manga.fetchInterval else 0,
interval = dialog.manga.fetchInterval,
onDismissRequest = onDismissRequest,
onValueChanged = { screenModel.setFetchInterval(dialog.manga, it) },
)

View file

@ -131,8 +131,6 @@ class MangaScreenModel(
val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope)
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get()
val leadDay = libraryPreferences.leadingMangaExpectedDays().get()
val followDay = libraryPreferences.followingMangaExpectedDays().get()
private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedChapterIds: HashSet<Long> = HashSet()
@ -374,20 +372,14 @@ class MangaScreenModel(
}
}
fun setFetchInterval(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.fetchInterval < 0) 0 else manga.fetchInterval
else -> -newInterval
}
fun setFetchInterval(manga: Manga, interval: Int) {
coroutineScope.launchIO {
updateManga.awaitUpdateFetchInterval(
manga.copy(fetchInterval = interval),
successState?.chapters?.map { it.chapter }.orEmpty(),
// Custom intervals are negative
manga.copy(fetchInterval = -interval),
)
val newManga = mangaRepository.getMangaById(mangaId)
updateSuccessState { it.copy(manga = newManga) }
val updatedManga = mangaRepository.getMangaById(manga.id)
updateSuccessState { it.copy(manga = updatedManga) }
}
}
@ -1073,10 +1065,3 @@ data class ChapterItem(
) {
val isDownloaded = downloadState == MangaDownload.State.DOWNLOADED
}
@Immutable
data class FetchMangaInterval(
val interval: Int,
val leadDays: Int,
val followDays: Int,
)

View file

@ -25,6 +25,7 @@ import android.view.animation.AnimationUtils
import android.widget.Toast
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
@ -48,9 +49,12 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.transition.platform.MaterialContainerTransform
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.entries.manga.model.orientationType
import eu.kanade.presentation.reader.BottomReaderBar
import eu.kanade.presentation.reader.ChapterNavigator
import eu.kanade.presentation.reader.OrientationModeSelectDialog
import eu.kanade.presentation.reader.PageIndicatorText
import eu.kanade.presentation.reader.ReaderPageActionsDialog
import eu.kanade.presentation.reader.ReadingModeSelectDialog
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.Constants
@ -78,10 +82,7 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.copy
import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.util.view.setComposeContent
import eu.kanade.tachiyomi.util.view.setTooltip
import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@ -93,12 +94,12 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.preference.toggle
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.presentation.core.util.collectAsState
import tachiyomi.presentation.widget.util.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -124,7 +125,7 @@ class ReaderActivity : BaseActivity() {
val viewModel by viewModels<ReaderViewModel>()
private var assistUrl: String? = null
val hasCutout by lazy { hasDisplayCutout() }
private val hasCutout by lazy { hasDisplayCutout() }
/**
* Configuration at reader level, like background color or forced orientation.
@ -391,11 +392,12 @@ class ReaderActivity : BaseActivity() {
)
}
binding.dialogRoot.setComposeContent {
binding.readerMenuBottom.setComposeContent {
val state by viewModel.state.collectAsState()
val settingsScreenModel = remember {
ReaderSettingsScreenModel(
readerState = viewModel.state,
hasDisplayCutout = hasCutout,
onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType,
)
@ -426,6 +428,28 @@ class ReaderActivity : BaseActivity() {
screenModel = settingsScreenModel,
)
}
is ReaderViewModel.Dialog.ReadingModeSelect -> {
ReadingModeSelectDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode().get()) {
menuToggleToast = toast(stringRes)
}
},
)
}
is ReaderViewModel.Dialog.OrientationModeSelect -> {
OrientationModeSelectDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
menuToggleToast = toast(stringRes)
},
)
}
is ReaderViewModel.Dialog.PageActions -> {
ReaderPageActionsDialog(
onDismissRequest = onDismissRequest,
@ -438,36 +462,61 @@ class ReaderActivity : BaseActivity() {
}
}
// Init listeners on bottom menu
binding.readerNav.setComposeContent {
binding.readerMenuBottom.setComposeContent {
val state by viewModel.state.collectAsState()
if (state.viewer == null) return@setComposeContent
val isRtl = state.viewer is R2LPagerViewer
ChapterNavigator(
isRtl = isRtl,
onNextChapter = ::loadNextChapter,
enabledNext = state.viewerChapters?.nextChapter != null,
onPreviousChapter = ::loadPreviousChapter,
enabledPrevious = state.viewerChapters?.prevChapter != null,
currentPage = state.currentPage,
totalPages = state.totalPages,
onSliderValueChange = {
isScrollingThroughPages = true
moveToPageIndex(it)
},
)
}
val cropBorderPaged by readerPreferences.cropBorders().collectAsState()
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon().collectAsState()
val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
initBottomShortcuts()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ChapterNavigator(
isRtl = isRtl,
onNextChapter = ::loadNextChapter,
enabledNext = state.viewerChapters?.nextChapter != null,
onPreviousChapter = ::loadPreviousChapter,
enabledPrevious = state.viewerChapters?.prevChapter != null,
currentPage = state.currentPage,
totalPages = state.totalPages,
onSliderValueChange = {
isScrollingThroughPages = true
moveToPageIndex(it)
},
)
BottomReaderBar(
readingMode = ReadingModeType.fromPreference(viewModel.getMangaReadingMode(resolveDefault = false)),
onClickReadingMode = viewModel::openReadingModeSelectDialog,
orientationMode = OrientationType.fromPreference(viewModel.getMangaOrientationType(resolveDefault = false)),
onClickOrientationMode = viewModel::openOrientationModeSelectDialog,
cropEnabled = cropEnabled,
onClickCropBorder = {
val enabled = viewModel.toggleCropBorders()
menuToggleToast?.cancel()
menuToggleToast = toast(
if (enabled) {
R.string.on
} else {
R.string.off
},
)
},
onClickSettings = viewModel::openSettingsDialog,
)
}
}
val toolbarBackground = (binding.toolbar.background as MaterialShapeDrawable).apply {
elevation = resources.getDimension(R.dimen.m3_sys_elevation_level2)
alpha = if (isNightMode()) 230 else 242 // 90% dark 95% light
}
binding.toolbarBottom.background = toolbarBackground.copy(this@ReaderActivity)
val toolbarColor = ColorUtils.setAlphaComponent(
toolbarBackground.resolvedTintColor,
toolbarBackground.alpha,
@ -481,112 +530,6 @@ class ReaderActivity : BaseActivity() {
setMenuVisibility(viewModel.state.value.menuVisible)
}
private fun initBottomShortcuts() {
// Reading mode
with(binding.actionReadingMode) {
setTooltip(R.string.viewer)
setOnClickListener {
popupMenu(
items = ReadingModeType.entries.map { it.flagValue to it.stringRes },
selectedItemId = viewModel.getMangaReadingMode(resolveDefault = false),
) {
val newReadingMode = ReadingModeType.fromPreference(itemId)
viewModel.setMangaReadingMode(newReadingMode)
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode().get()) {
menuToggleToast = toast(newReadingMode.stringRes)
}
updateCropBordersShortcut()
}
}
}
// Crop borders
with(binding.actionCropBorders) {
setTooltip(R.string.pref_crop_borders)
setOnClickListener {
val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
val enabled = if (isPagerType) {
readerPreferences.cropBorders().toggle()
} else {
readerPreferences.cropBordersWebtoon().toggle()
}
menuToggleToast?.cancel()
menuToggleToast = toast(
if (enabled) {
R.string.on
} else {
R.string.off
},
)
}
}
updateCropBordersShortcut()
listOf(readerPreferences.cropBorders(), readerPreferences.cropBordersWebtoon())
.forEach { pref ->
pref.changes()
.onEach { updateCropBordersShortcut() }
.launchIn(lifecycleScope)
}
// Rotation
with(binding.actionRotation) {
setTooltip(R.string.rotation_type)
setOnClickListener {
popupMenu(
items = OrientationType.entries.map { it.flagValue to it.stringRes },
selectedItemId = viewModel.manga?.orientationType?.toInt()
?: readerPreferences.defaultOrientationType().get(),
) {
val newOrientation = OrientationType.fromPreference(itemId)
viewModel.setMangaOrientationType(newOrientation)
menuToggleToast?.cancel()
menuToggleToast = toast(newOrientation.stringRes)
}
}
}
// Settings sheet
with(binding.actionSettings) {
setTooltip(R.string.action_settings)
setOnClickListener {
viewModel.openSettingsDialog()
}
}
}
private fun updateOrientationShortcut(preference: Int) {
val orientation = OrientationType.fromPreference(preference)
binding.actionRotation.setImageResource(orientation.iconRes)
}
private fun updateCropBordersShortcut() {
val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
val enabled = if (isPagerType) {
readerPreferences.cropBorders().get()
} else {
readerPreferences.cropBordersWebtoon().get()
}
binding.actionCropBorders.setImageResource(
if (enabled) {
R.drawable.ic_crop_24dp
} else {
R.drawable.ic_crop_off_24dp
},
)
}
/**
* Sets the visibility of the menu according to [visible] and with an optional parameter to
* [animate] the views.
@ -653,13 +596,8 @@ class ReaderActivity : BaseActivity() {
*/
private fun setManga(manga: Manga) {
val prevViewer = viewModel.state.value.viewer
val viewerMode = ReadingModeType.fromPreference(viewModel.getMangaReadingMode(resolveDefault = false))
binding.actionReadingMode.setImageResource(viewerMode.iconRes)
val newViewer = ReadingModeType.toViewer(viewModel.getMangaReadingMode(), this)
updateCropBordersShortcut()
if (window.sharedElementEnterTransition is MaterialContainerTransform) {
// Wait until transition is complete to avoid crash on API 26
window.sharedElementEnterTransition.doOnEnd {
@ -894,7 +832,6 @@ class ReaderActivity : BaseActivity() {
if (newOrientation.flag != requestedOrientation) {
requestedOrientation = newOrientation.flag
}
updateOrientationShortcut(viewModel.getMangaOrientationType(resolveDefault = false))
}
/**

View file

@ -61,7 +61,7 @@ class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet)
override fun onDraw(canvas: Canvas) {
if (navigation == null) return
navigation?.regions?.forEach { region ->
navigation?.getRegions()?.forEach { region ->
val rect = region.rectF
// Scale rect from 1f,1f to screen width and height

View file

@ -60,6 +60,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import tachiyomi.core.preference.toggle
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withIOContext
@ -129,6 +130,15 @@ class ReaderViewModel @JvmOverloads constructor(
field = value
}
/**
* The visible page index of the currently loaded chapter. Used to restore from process kill.
*/
private var chapterPageIndex = savedState.get<Int>("page_index") ?: -1
set(value) {
savedState["page_index"] = value
field = value
}
/**
* The chapter loader for the loaded manga. It'll be null until [manga] is set.
*/
@ -213,7 +223,10 @@ class ReaderViewModel @JvmOverloads constructor(
.distinctUntilChanged()
.filterNotNull()
.onEach { currentChapter ->
if (!currentChapter.chapter.read) {
if (chapterPageIndex >= 0) {
// Restore from SavedState
currentChapter.requestedPage = chapterPageIndex
} else if (!currentChapter.chapter.read) {
currentChapter.requestedPage = currentChapter.chapter.last_page_read
}
chapterId = currentChapter.chapter.id!!
@ -507,6 +520,7 @@ class ReaderViewModel @JvmOverloads constructor(
it.copy(currentPage = pageIndex + 1)
}
readerChapter.requestedPage = pageIndex
chapterPageIndex = pageIndex
if (!incognitoMode && page.status != Page.State.ERROR) {
readerChapter.chapter.last_page_read = pageIndex
@ -678,6 +692,15 @@ class ReaderViewModel @JvmOverloads constructor(
}
}
fun toggleCropBorders(): Boolean {
val isPagerType = ReadingModeType.isPagerType(getMangaReadingMode())
return if (isPagerType) {
readerPreferences.cropBorders().toggle()
} else {
readerPreferences.cropBordersWebtoon().toggle()
}
}
/**
* Generate a filename for the given [manga] and [page]
*/
@ -700,6 +723,14 @@ class ReaderViewModel @JvmOverloads constructor(
mutableState.update { it.copy(dialog = Dialog.Loading) }
}
fun openReadingModeSelectDialog() {
mutableState.update { it.copy(dialog = Dialog.ReadingModeSelect) }
}
fun openOrientationModeSelectDialog() {
mutableState.update { it.copy(dialog = Dialog.OrientationModeSelect) }
}
fun openPageDialog(page: ReaderPage) {
mutableState.update { it.copy(dialog = Dialog.PageActions(page)) }
}
@ -910,6 +941,8 @@ class ReaderViewModel @JvmOverloads constructor(
sealed interface Dialog {
data object Loading : Dialog
data object Settings : Dialog
data object ReadingModeSelect : Dialog
data object OrientationModeSelect : Dialog
data class PageActions(val page: ReaderPage) : Dialog
}

View file

@ -13,6 +13,7 @@ import uy.kohesive.injekt.api.get
class ReaderSettingsScreenModel(
readerState: StateFlow<ReaderViewModel.State>,
val hasDisplayCutout: Boolean,
val onChangeReadingMode: (ReadingModeType) -> Unit,
val onChangeOrientation: (OrientationType) -> Unit,
val preferences: ReaderPreferences = Injekt.get(),

View file

@ -32,15 +32,19 @@ abstract class ViewerNavigation {
private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f)
abstract var regions: List<Region>
var invertMode: ReaderPreferences.TappingInvertMode = ReaderPreferences.TappingInvertMode.NONE
protected abstract var regionList: List<Region>
/** Returns regions with applied inversion. */
fun getRegions(): List<Region> {
return regionList.map { it.invert(invertMode) }
}
fun getAction(pos: PointF): NavigationRegion {
val x = pos.x
val y = pos.y
val region = regions.map { it.invert(invertMode) }
.find { it.rectF.contains(x, y) }
val region = getRegions().find { it.rectF.contains(x, y) }
return when {
region != null -> region.type
constantMenuRegion.contains(x, y) -> NavigationRegion.MENU

View file

@ -14,5 +14,5 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
*/
class DisabledNavigation : ViewerNavigation() {
override var regions: List<Region> = emptyList()
override var regionList: List<Region> = emptyList()
}

View file

@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
*/
class EdgeNavigation : ViewerNavigation() {
override var regions: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rectF = RectF(0f, 0f, 0.33f, 1f),
type = NavigationRegion.NEXT,

View file

@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
*/
class KindlishNavigation : ViewerNavigation() {
override var regions: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rectF = RectF(0.33f, 0.33f, 1f, 1f),
type = NavigationRegion.NEXT,

View file

@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
*/
open class LNavigation : ViewerNavigation() {
override var regions: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rectF = RectF(0f, 0.33f, 0.33f, 0.66f),
type = NavigationRegion.PREV,

View file

@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
*/
class RightAndLeftNavigation : ViewerNavigation() {
override var regions: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rectF = RectF(0f, 0f, 0.33f, 1f),
type = NavigationRegion.LEFT,

View file

@ -1,22 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import tachiyomi.core.util.lang.launchIO
class AnilistLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
val matchResult = regex.find(data?.fragment.toString())
if (matchResult?.groups?.get(1) != null) {
lifecycleScope.launchIO {
trackManager.aniList.login(matchResult.groups[1]!!.value)
returnToSettings()
}
} else {
trackManager.aniList.logout()
returnToSettings()
}
}
}

View file

@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import tachiyomi.core.util.lang.launchIO
class BangumiLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.bangumi.login(code)
returnToSettings()
}
} else {
trackManager.bangumi.logout()
returnToSettings()
}
}
}

View file

@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import tachiyomi.core.util.lang.launchIO
class MyAnimeListLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.myAnimeList.login(code)
returnToSettings()
}
} else {
trackManager.myAnimeList.logout()
returnToSettings()
}
}
}

View file

@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import tachiyomi.core.util.lang.launchIO
class ShikimoriLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.shikimori.login(code)
returnToSettings()
}
} else {
trackManager.shikimori.logout()
returnToSettings()
}
}
}

View file

@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import tachiyomi.core.util.lang.launchIO
class SimklLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.simkl.login(code)
returnToSettings()
}
} else {
trackManager.simkl.logout()
returnToSettings()
}
}
}

View file

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import tachiyomi.core.util.lang.launchIO
class TrackLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
when (data?.host) {
"anilist-auth" -> handleAnilist(data)
"bangumi-auth" -> handleBangumi(data)
"myanimelist-auth" -> handleMyAnimeList(data)
"shikimori-auth" -> handleShikimori(data)
"simkl-auth" -> handleSimkl(data)
}
}
private fun handleAnilist(data: Uri) {
val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
val matchResult = regex.find(data.fragment.toString())
if (matchResult?.groups?.get(1) != null) {
lifecycleScope.launchIO {
trackManager.aniList.login(matchResult.groups[1]!!.value)
returnToSettings()
}
} else {
trackManager.aniList.logout()
returnToSettings()
}
}
private fun handleBangumi(data: Uri) {
val code = data.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.bangumi.login(code)
returnToSettings()
}
} else {
trackManager.bangumi.logout()
returnToSettings()
}
}
private fun handleMyAnimeList(data: Uri) {
val code = data.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.myAnimeList.login(code)
returnToSettings()
}
} else {
trackManager.myAnimeList.logout()
returnToSettings()
}
}
private fun handleShikimori(data: Uri) {
val code = data.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.shikimori.login(code)
returnToSettings()
}
} else {
trackManager.shikimori.logout()
returnToSettings()
}
}
private fun handleSimkl(data: Uri?) {
val code = data?.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.simkl.login(code)
returnToSettings()
}
} else {
trackManager.simkl.logout()
returnToSettings()
}
}
}

View file

@ -3,13 +3,16 @@ package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.items.chapter.model.Chapter
import tachiyomi.source.local.entries.manga.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Returns a copy of the list with not downloaded chapters removed
* Returns a copy of the list with not downloaded chapters removed.
*/
fun List<Chapter>.filterDownloadedChapters(manga: Manga): List<Chapter> {
if (manga.isLocal()) return this
val downloadCache: MangaDownloadCache = Injekt.get()
return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source, false) }

View file

@ -3,13 +3,16 @@ package eu.kanade.tachiyomi.util.episode
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.items.episode.model.Episode
import tachiyomi.source.local.entries.anime.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Returns a copy of the list with not downloaded chapters removed
* Returns a copy of the list with not downloaded chapters removed.
*/
fun List<Episode>.filterDownloadedEpisodes(anime: Anime): List<Episode> {
if (anime.isLocal()) return this
val downloadCache: AnimeDownloadCache = Injekt.get()
return filter { downloadCache.isEpisodeDownloaded(it.name, it.scanlator, anime.title, anime.source, false) }

View file

@ -7,20 +7,13 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.PermissionChecker
import androidx.core.content.getSystemService
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.core.net.toUri
import com.hippo.unifile.UniFile
import eu.kanade.domain.ui.UiPreferences
@ -35,7 +28,6 @@ import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import kotlin.math.roundToInt
/**
* Copies a string to clipboard
@ -69,25 +61,6 @@ fun Context.copyToClipboard(label: String, content: String) {
*/
fun Context.hasPermission(permission: String) = PermissionChecker.checkSelfPermission(this, permission) == PermissionChecker.PERMISSION_GRANTED
/**
* Returns the color for the given attribute.
*
* @param resource the attribute.
* @param alphaFactor the alpha number [0,1].
*/
@ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int {
val typedArray = obtainStyledAttributes(intArrayOf(resource))
val color = typedArray.getColor(0, 0)
typedArray.recycle()
if (alphaFactor < 1f) {
val alpha = (color.alpha * alphaFactor).roundToInt()
return Color.argb(alpha, color.red, color.green, color.blue)
}
return color
}
val Context.powerManager: PowerManager
get() = getSystemService()!!

View file

@ -2,11 +2,8 @@
package eu.kanade.tachiyomi.util.view
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
@ -14,11 +11,7 @@ import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.TooltipCompat
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@ -27,11 +20,8 @@ import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.forEach
import com.google.android.material.shape.MaterialShapeDrawable
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
inline fun ComponentActivity.setComposeContent(
parent: CompositionContext? = null,
@ -65,24 +55,6 @@ fun ComposeView.setComposeContent(
}
}
/**
* Adds a tooltip shown on long press.
*
* @param stringRes String resource for tooltip.
*/
inline fun View.setTooltip(@StringRes stringRes: Int) {
setTooltip(context.getString(stringRes))
}
/**
* Adds a tooltip shown on long press.
*
* @param text Text for tooltip.
*/
inline fun View.setTooltip(text: String) {
TooltipCompat.setTooltipText(this, text)
}
/**
* Shows a popup menu on top of this view.
*
@ -110,57 +82,6 @@ inline fun View.popupMenu(
return popup
}
/**
* Shows a popup menu on top of this view.
*
* @param items menu item names to inflate the menu with. List of itemId to stringRes pairs.
* @param selectedItemId optionally show a checkmark beside an item with this itemId.
* @param onMenuItemClick function to execute when a menu item is clicked.
*/
@SuppressLint("RestrictedApi")
inline fun View.popupMenu(
items: List<Pair<Int, Int>>,
selectedItemId: Int? = null,
noinline onMenuItemClick: MenuItem.() -> Unit,
): PopupMenu {
val popup = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0)
items.forEach { (id, stringRes) ->
popup.menu.add(0, id, 0, stringRes)
}
if (selectedItemId != null) {
(popup.menu as? MenuBuilder)?.setOptionalIconsVisible(true)
val emptyIcon = AppCompatResources.getDrawable(context, R.drawable.ic_blank_24dp)
popup.menu.forEach { item ->
item.icon = when (item.itemId) {
selectedItemId -> AppCompatResources.getDrawable(context, R.drawable.ic_check_24dp)?.mutate()?.apply {
setTint(context.getResourceColor(android.R.attr.textColorPrimary))
}
else -> emptyIcon
}
}
}
popup.setOnMenuItemClickListener {
it.onMenuItemClick()
true
}
popup.show()
return popup
}
/**
* Returns a deep copy of the provided [Drawable]
*/
inline fun <reified T : Drawable> T.copy(context: Context): T? {
return (constantState?.newDrawable()?.mutate() as? T).apply {
if (this is MaterialShapeDrawable) {
initializeElevationOverlay(context)
}
}
}
fun View?.isVisibleOnScreen(): Boolean {
if (this == null) {
return false

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#000"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>

View file

@ -1,5 +1,4 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -58,83 +57,11 @@
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize" />
<LinearLayout
<androidx.compose.ui.platform.ComposeView
android:id="@+id/reader_menu_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/reader_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layoutDirection="ltr" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbar_bottom"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:layout_gravity="bottom"
android:clickable="true"
tools:ignore="KeyboardInaccessibleWidget">
<ImageButton
android:id="@+id/action_reading_mode"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/viewer"
android:padding="@dimen/screen_edge_margin"
app:layout_constraintEnd_toStartOf="@id/action_crop_borders"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_reader_default_24dp"
app:tint="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/action_crop_borders"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/pref_crop_borders"
android:padding="@dimen/screen_edge_margin"
app:layout_constraintEnd_toStartOf="@id/action_rotation"
app:layout_constraintStart_toEndOf="@+id/action_reading_mode"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_crop_24dp"
app:tint="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/action_rotation"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/pref_rotation_type"
android:padding="@dimen/screen_edge_margin"
app:layout_constraintEnd_toStartOf="@id/action_settings"
app:layout_constraintStart_toEndOf="@+id/action_crop_borders"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_screen_rotation_24dp"
app:tint="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/action_settings"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_settings"
android:padding="@dimen/screen_edge_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/action_rotation"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_settings_24dp"
app:tint="?attr/colorOnSurface" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
android:layout_gravity="bottom" />
</FrameLayout>

View file

@ -8,10 +8,17 @@ import java.io.IOException
import java.util.ArrayDeque
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toDuration
import kotlin.time.toDurationUnit
/**
* An OkHttp interceptor that handles rate limiting.
*
* This uses `java.time` APIs and is the legacy method, kept
* for compatibility reasons with existing extensions.
*
* Examples:
*
* permits = 5, period = 1, unit = seconds => 5 requests per second
@ -19,27 +26,43 @@ import java.util.concurrent.TimeUnit
*
* @since extension-lib 1.3
*
* @param permits {Int} Number of requests allowed within a period of units.
* @param period {Long} The limiting duration. Defaults to 1.
* @param unit {TimeUnit} The unit of time for the period. Defaults to seconds.
* @param permits [Int] Number of requests allowed within a period of units.
* @param period [Long] The limiting duration. Defaults to 1.
* @param unit [TimeUnit] The unit of time for the period. Defaults to seconds.
*/
@Deprecated("Use the version with kotlin.time APIs instead.")
fun OkHttpClient.Builder.rateLimit(
permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS,
) = addInterceptor(RateLimitInterceptor(null, permits, period, unit))
) = addInterceptor(RateLimitInterceptor(null, permits, period.toDuration(unit.toDurationUnit())))
/**
* An OkHttp interceptor that handles rate limiting.
*
* Examples:
*
* permits = 5, period = 1.seconds => 5 requests per second
* permits = 10, period = 2.minutes => 10 requests per 2 minutes
*
* @since extension-lib 1.5
*
* @param permits [Int] Number of requests allowed within a period of units.
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
*/
fun OkHttpClient.Builder.rateLimit(permits: Int, period: Duration = 1.seconds) =
addInterceptor(RateLimitInterceptor(null, permits, period))
/** We can probably accept domains or wildcards by comparing with [endsWith], etc. */
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
internal class RateLimitInterceptor(
private val host: String?,
private val permits: Int,
period: Long,
unit: TimeUnit,
period: Duration,
) : Interceptor {
private val requestQueue = ArrayDeque<Long>(permits)
private val rateLimitMillis = unit.toMillis(period)
private val rateLimitMillis = period.inWholeMilliseconds
private val fairLock = Semaphore(1, true)
override fun intercept(chain: Interceptor.Chain): Response {

View file

@ -1,12 +1,20 @@
package eu.kanade.tachiyomi.network.interceptor
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toDuration
import kotlin.time.toDurationUnit
/**
* An OkHttp interceptor that handles given url host's rate limiting.
*
* This uses Java Time APIs and is the legacy method, kept
* for compatibility reasons with existing extensions.
*
* Examples:
*
* httpUrl = "api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1, unit = seconds => 5 requests per second to api.manga.com
@ -14,14 +22,55 @@ import java.util.concurrent.TimeUnit
*
* @since extension-lib 1.3
*
* @param httpUrl {HttpUrl} The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
* @param permits {Int} Number of requests allowed within a period of units.
* @param period {Long} The limiting duration. Defaults to 1.
* @param unit {TimeUnit} The unit of time for the period. Defaults to seconds.
* @param httpUrl [HttpUrl] The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
* @param permits [Int] Number of requests allowed within a period of units.
* @param period [Long] The limiting duration. Defaults to 1.
* @param unit [TimeUnit] The unit of time for the period. Defaults to seconds.
*/
@Deprecated("Use the version with kotlin.time APIs instead.")
fun OkHttpClient.Builder.rateLimitHost(
httpUrl: HttpUrl,
permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS,
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period, unit))
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period.toDuration(unit.toDurationUnit())))
/**
* An OkHttp interceptor that handles given url host's rate limiting.
*
* Examples:
*
* httpUrl = "https://api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1.seconds => 5 requests per second to api.manga.com
* httpUrl = "https://imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2.minutes => 10 requests per 2 minutes to imagecdn.manga.com
*
* @since extension-lib 1.5
*
* @param httpUrl [HttpUrl] The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
* @param permits [Int] Number of requests allowed within a period of units.
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
*/
fun OkHttpClient.Builder.rateLimitHost(
httpUrl: HttpUrl,
permits: Int,
period: Duration = 1.seconds,
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period))
/**
* An OkHttp interceptor that handles given url host's rate limiting.
*
* Examples:
*
* url = "https://api.manga.com", permits = 5, period = 1.seconds => 5 requests per second to api.manga.com
* url = "https://imagecdn.manga.com", permits = 10, period = 2.minutes => 10 requests per 2 minutes to imagecdn.manga.com
*
* @since extension-lib 1.5
*
* @param url [String] The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
* @param permits [Int] Number of requests allowed within a period of units.
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
*/
fun OkHttpClient.Builder.rateLimitHost(
url: String,
permits: Int,
period: Duration = 1.seconds,
) = addInterceptor(RateLimitInterceptor(url.toHttpUrlOrNull()?.host, permits, period))

View file

@ -1,7 +1,7 @@
package tachiyomi.data
import app.cash.sqldelight.ColumnAdapter
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import java.util.Date
object DateColumnAdapter : ColumnAdapter<Date, Long> {

View file

@ -1,6 +1,6 @@
package tachiyomi.data.entries.anime
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.library.anime.LibraryAnime

View file

@ -1,6 +1,6 @@
package tachiyomi.data.entries.manga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.library.manga.LibraryManga

View file

@ -1,4 +1,4 @@
import eu.kanade.tachiyomi.source.model.UpdateStrategy;
import eu.kanade.tachiyomi.model.UpdateStrategy;
import kotlin.collections.List;
import kotlin.Boolean;
import kotlin.String;

View file

@ -1,4 +1,4 @@
import eu.kanade.tachiyomi.source.model.UpdateStrategy;
import eu.kanade.tachiyomi.model.UpdateStrategy;
import kotlin.collections.List;
import kotlin.Boolean;
import kotlin.String;

View file

@ -2,34 +2,34 @@ package tachiyomi.domain.entries.anime.interactor
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.anime.model.AnimeUpdate
import tachiyomi.domain.items.episode.interactor.GetEpisodeByAnimeId
import tachiyomi.domain.items.episode.model.Episode
import tachiyomi.domain.library.service.LibraryPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
const val MAX_GRACE_PERIOD = 28
const val MAX_FETCH_INTERVAL = 28
private const val FETCH_INTERVAL_GRACE_PERIOD = 1
class SetAnimeFetchInterval(
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val getEpisodeByAnimeId: GetEpisodeByAnimeId,
) {
fun update(
suspend fun toAnimeUpdateOrNull(
anime: Anime,
episodes: List<Episode>,
zonedDateTime: ZonedDateTime,
fetchRange: Pair<Long, Long>,
dateTime: ZonedDateTime,
window: Pair<Long, Long>,
): AnimeUpdate? {
val currentInterval = if (fetchRange.first == 0L && fetchRange.second == 0L) {
getCurrent(ZonedDateTime.now())
val currentWindow = if (window.first == 0L && window.second == 0L) {
getWindow(ZonedDateTime.now())
} else {
fetchRange
window
}
val interval = anime.fetchInterval.takeIf { it < 0 } ?: calculateInterval(episodes, zonedDateTime)
val nextUpdate = calculateNextUpdate(anime, interval, zonedDateTime, currentInterval)
val episodes = getEpisodeByAnimeId.await(anime.id)
val interval = anime.fetchInterval.takeIf { it < 0 } ?: calculateInterval(episodes, dateTime)
val nextUpdate = calculateNextUpdate(anime, interval, dateTime, currentWindow)
return if (anime.nextUpdate == nextUpdate && anime.fetchInterval == interval) {
null
@ -38,20 +38,11 @@ class SetAnimeFetchInterval(
}
}
fun getCurrent(timeToCal: ZonedDateTime): Pair<Long, Long> {
// 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()
}
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)
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
val upperBound = today.plusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
}
internal fun calculateInterval(episodes: List<Episode>, zonedDateTime: ZonedDateTime): Int {
@ -91,35 +82,41 @@ class SetAnimeFetchInterval(
// Default to 7 days
else -> 7
}
// Min 1, max 28 days
return interval.coerceIn(1, MAX_GRACE_PERIOD)
return interval.coerceIn(1, MAX_FETCH_INTERVAL)
}
private fun calculateNextUpdate(
anime: Anime,
interval: Int,
zonedDateTime: ZonedDateTime,
fetchRange: Pair<Long, Long>,
dateTime: ZonedDateTime,
window: Pair<Long, Long>,
): Long {
return if (
anime.nextUpdate !in fetchRange.first.rangeTo(fetchRange.second + 1) ||
anime.nextUpdate !in window.first.rangeTo(window.second + 1) ||
anime.fetchInterval == 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
val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(anime.lastUpdate), dateTime.zone)
.toLocalDate()
.atStartOfDay()
val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, dateTime).toInt()
val cycle = timeSinceLatest.floorDiv(
interval.absoluteValue.takeIf { interval < 0 }
?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10),
)
latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(dateTime.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
private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int): Int {
if (delta >= MAX_FETCH_INTERVAL) return MAX_FETCH_INTERVAL
// double delta again if missed more than 9 check in new delta
val cycle = timeSinceLatest.floorDiv(delta) + 1
return if (cycle > doubleWhenOver) {
doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue)
doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver)
} else {
delta
}

View file

@ -1,6 +1,6 @@
package tachiyomi.domain.entries.anime.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import tachiyomi.core.preference.TriState
import java.io.Serializable
import kotlin.math.pow

View file

@ -1,6 +1,6 @@
package tachiyomi.domain.entries.anime.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
data class AnimeUpdate(
val id: Long,

View file

@ -2,34 +2,34 @@ package tachiyomi.domain.entries.manga.interactor
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.entries.manga.model.MangaUpdate
import tachiyomi.domain.items.chapter.interactor.GetChapterByMangaId
import tachiyomi.domain.items.chapter.model.Chapter
import tachiyomi.domain.library.service.LibraryPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
const val MAX_GRACE_PERIOD = 28
const val MAX_FETCH_INTERVAL = 28
private const val FETCH_INTERVAL_GRACE_PERIOD = 1
class SetMangaFetchInterval(
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val getChapterByMangaId: GetChapterByMangaId,
) {
fun update(
suspend fun toMangaUpdateOrNull(
manga: Manga,
chapters: List<Chapter>,
zonedDateTime: ZonedDateTime,
fetchRange: Pair<Long, Long>,
dateTime: ZonedDateTime,
window: Pair<Long, Long>,
): MangaUpdate? {
val currentInterval = if (fetchRange.first == 0L && fetchRange.second == 0L) {
getCurrent(ZonedDateTime.now())
val currentWindow = if (window.first == 0L && window.second == 0L) {
getWindow(ZonedDateTime.now())
} else {
fetchRange
window
}
val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(chapters, zonedDateTime)
val nextUpdate = calculateNextUpdate(manga, interval, zonedDateTime, currentInterval)
val chapters = getChapterByMangaId.await(manga.id)
val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(chapters, dateTime)
val nextUpdate = calculateNextUpdate(manga, interval, dateTime, currentWindow)
return if (manga.nextUpdate == nextUpdate && manga.fetchInterval == interval) {
null
@ -38,20 +38,11 @@ class SetMangaFetchInterval(
}
}
fun getCurrent(timeToCal: ZonedDateTime): Pair<Long, Long> {
// 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()
}
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)
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
val upperBound = today.plusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
}
internal fun calculateInterval(chapters: List<Chapter>, zonedDateTime: ZonedDateTime): Int {
@ -91,35 +82,41 @@ class SetMangaFetchInterval(
// Default to 7 days
else -> 7
}
// Min 1, max 28 days
return interval.coerceIn(1, MAX_GRACE_PERIOD)
return interval.coerceIn(1, MAX_FETCH_INTERVAL)
}
private fun calculateNextUpdate(
manga: Manga,
interval: Int,
zonedDateTime: ZonedDateTime,
fetchRange: Pair<Long, Long>,
dateTime: ZonedDateTime,
window: Pair<Long, Long>,
): Long {
return if (
manga.nextUpdate !in fetchRange.first.rangeTo(fetchRange.second + 1) ||
manga.nextUpdate !in window.first.rangeTo(window.second + 1) ||
manga.fetchInterval == 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
val latestDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(manga.lastUpdate), dateTime.zone)
.toLocalDate()
.atStartOfDay()
val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, dateTime).toInt()
val cycle = timeSinceLatest.floorDiv(
interval.absoluteValue.takeIf { interval < 0 }
?: doubleInterval(interval, timeSinceLatest, doubleWhenOver = 10),
)
latestDate.plusDays((cycle + 1) * interval.toLong()).toEpochSecond(dateTime.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
private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int): Int {
if (delta >= MAX_FETCH_INTERVAL) return MAX_FETCH_INTERVAL
// double delta again if missed more than 9 check in new delta
val cycle = timeSinceLatest.floorDiv(delta) + 1
return if (cycle > doubleWhenOver) {
doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver, maxValue)
doubleInterval(delta * 2, timeSinceLatest, doubleWhenOver)
} else {
delta
}

View file

@ -1,6 +1,6 @@
package tachiyomi.domain.entries.manga.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import tachiyomi.core.preference.TriState
import java.io.Serializable

View file

@ -1,6 +1,6 @@
package tachiyomi.domain.entries.manga.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
data class MangaUpdate(
val id: Long,

View file

@ -62,12 +62,6 @@ class LibraryPreferences(
),
)
fun leadingAnimeExpectedDays() = preferenceStore.getInt("pref_library_before_expect_key", 1)
fun followingAnimeExpectedDays() = preferenceStore.getInt("pref_library_after_expect_key", 1)
fun leadingMangaExpectedDays() = preferenceStore.getInt("pref_library_before_expect_key", 1)
fun followingMangaExpectedDays() = preferenceStore.getInt("pref_library_after_expect_key", 1)
fun autoUpdateMetadata() = preferenceStore.getBoolean("auto_update_metadata", false)
fun autoUpdateTrackers() = preferenceStore.getBoolean("auto_update_trackers", false)

View file

@ -11,6 +11,7 @@ import java.time.ZonedDateTime
@Execution(ExecutionMode.CONCURRENT)
class SetAnimeFetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private var episode = Episode.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
@ -19,14 +20,8 @@ class SetAnimeFetchIntervalTest {
private val setAnimeFetchInterval = SetAnimeFetchInterval(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`() {
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
val episodes = mutableListOf<Episode>()
(1..1).forEach {
val duration = Duration.ofHours(10)
@ -63,9 +58,8 @@ class SetAnimeFetchIntervalTest {
setAnimeFetchInterval.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`() {
fun `calculateInterval returns default of 1 day when interval less than 1`() {
val episodes = mutableListOf<Episode>()
(1..5).forEach {
val duration = Duration.ofHours(15L * it)
@ -98,9 +92,8 @@ class SetAnimeFetchIntervalTest {
setAnimeFetchInterval.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`() {
fun `calculateInterval returns floored value when interval is decimal`() {
val episodes = mutableListOf<Episode>()
(1..5).forEach {
val duration = Duration.ofHours(25L * it)
@ -121,9 +114,8 @@ class SetAnimeFetchIntervalTest {
setAnimeFetchInterval.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`() {
fun `calculateInterval returns interval based on fetch time if upload time not available`() {
val episodes = mutableListOf<Episode>()
(1..5).forEach {
val duration = Duration.ofHours(25L * it)
@ -132,4 +124,9 @@ class SetAnimeFetchIntervalTest {
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
}
private fun episodeAddTime(episode: Episode, duration: Duration): Episode {
val newTime = testTime.plus(duration).toEpochSecond() * 1000
return episode.copy(dateFetch = newTime, dateUpload = newTime)
}
}

View file

@ -11,6 +11,7 @@ import java.time.ZonedDateTime
@Execution(ExecutionMode.CONCURRENT)
class SetMangaFetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private var chapter = Chapter.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
@ -19,14 +20,8 @@ class SetMangaFetchIntervalTest {
private val setMangaFetchInterval = SetMangaFetchInterval(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`() {
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
val chapters = mutableListOf<Chapter>()
(1..1).forEach {
val duration = Duration.ofHours(10)
@ -63,9 +58,8 @@ class SetMangaFetchIntervalTest {
setMangaFetchInterval.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`() {
fun `calculateInterval returns default of 1 day when interval less than 1`() {
val chapters = mutableListOf<Chapter>()
(1..5).forEach {
val duration = Duration.ofHours(15L * it)
@ -98,9 +92,8 @@ class SetMangaFetchIntervalTest {
setMangaFetchInterval.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`() {
fun `calculateInterval returns floored value when interval is decimal`() {
val chapters = mutableListOf<Chapter>()
(1..5).forEach {
val duration = Duration.ofHours(25L * it)
@ -121,9 +114,8 @@ class SetMangaFetchIntervalTest {
setMangaFetchInterval.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`() {
fun `calculateInterval returns interval based on fetch time if upload time not available`() {
val chapters = mutableListOf<Chapter>()
(1..5).forEach {
val duration = Duration.ofHours(25L * it)
@ -132,4 +124,9 @@ class SetMangaFetchIntervalTest {
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
}
private fun chapterAddTime(chapter: Chapter, duration: Duration): Chapter {
val newTime = testTime.plus(duration).toEpochSecond() * 1000
return chapter.copy(dateFetch = newTime, dateUpload = newTime)
}
}

View file

@ -23,7 +23,7 @@ lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.r
lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle_version" }
work-runtime = "androidx.work:work-runtime-ktx:2.8.1"
guava = "com.google.guava:guava:32.0.1-android"
guava = "com.google.guava:guava:32.1.2-android"
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" }

View file

@ -19,7 +19,7 @@ flowreactivenetwork = "ru.beryukhov:flowreactivenetwork:1.0.4"
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp_version" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp_version" }
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp_version" }
okio = "com.squareup.okio:okio:3.4.0"
okio = "com.squareup.okio:okio:3.5.0"
conscrypt-android = "org.conscrypt:conscrypt-android:2.5.2"

View file

@ -8,6 +8,7 @@
<!-- Models -->
<string name="name">Name</string>
<string name="categories">Categories</string>
<string name="manga">Manga</string>
<string name="chapters">Chapters</string>
<string name="track">Tracking</string>
@ -260,16 +261,6 @@
<string name="pref_update_only_started">That haven\'t been started</string>
<string name="pref_update_only_in_release_period">Outside expected release period</string>
<string name="pref_update_release_grace_period">Expected manga release grace period</string>
<plurals name="pref_update_release_leading_days">
<item quantity="one">%d day before</item>
<item quantity="other">%d days before</item>
</plurals>
<plurals name="pref_update_release_following_days">
<item quantity="one">%d day after</item>
<item quantity="other">%d days after</item>
</plurals>
<string name="pref_update_release_grace_period_info">A low grace period is recommended to minimize stress on sources. The more checks for an entry that are missed, the longer the interval in between checks will be with a maximum of 28 days.</string>
<string name="pref_library_update_refresh_metadata">Automatically refresh metadata</string>
<string name="pref_library_update_refresh_metadata_summary">Check for new cover and details when updating library</string>
<string name="pref_library_update_refresh_trackers">Automatically refresh trackers</string>
@ -466,6 +457,7 @@
<string name="enhanced_services_not_installed">Available but source not installed: %s</string>
<string name="enhanced_tracking_info">Services that provide enhanced features for specific sources. Entries are automatically tracked when added to your library.</string>
<string name="action_track">Track</string>
<string name="track_activity_name">Tracking login</string>
<!-- Browse section -->
<string name="pref_hide_in_library_items">Hide entries already in library</string>
@ -624,10 +616,6 @@
<item quantity="one">1 day</item>
<item quantity="other">%d days</item>
</plurals>
<plurals name="range_interval_day">
<item quantity="one">%1$d - %2$d day</item>
<item quantity="other">%1$d - %2$d days</item>
</plurals>
<!-- Item info -->
<plurals name="missing_items">

View file

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.animesource.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
import java.io.Serializable
interface SAnime : Serializable {

View file

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.animesource.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.model.UpdateStrategy
class SAnimeImpl : SAnime {

View file

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source.model
package eu.kanade.tachiyomi.model
/**
* Define the update strategy for a single [SManga].
* Define the update strategy for a single SManga or SAnime.
* The strategy used will only take effect on the library update.
*
* @since extensions-lib 1.4

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.source.model
import eu.kanade.tachiyomi.model.UpdateStrategy
import java.io.Serializable
interface SManga : Serializable {

View file

@ -1,5 +1,7 @@
package eu.kanade.tachiyomi.source.model
import eu.kanade.tachiyomi.model.UpdateStrategy
class SMangaImpl : SManga {
override lateinit var url: String