Last commit merged: b7d282235d
This commit is contained in:
LuftVerbot 2023-11-24 20:16:18 +01:00
parent c07cc8dc21
commit 76df725cab
63 changed files with 2030 additions and 1887 deletions

View file

@ -248,7 +248,6 @@ dependencies {
implementation(libs.aboutLibraries.compose)
implementation(libs.bundles.voyager)
implementation(libs.compose.materialmotion)
implementation(libs.compose.simpleicons)
implementation(libs.swipe)
// Logging

View file

@ -17,31 +17,29 @@ class AddAnimeTracks(
private val syncChapterProgressWithTrack: SyncEpisodeProgressWithTrack,
) {
suspend fun bindEnhancedTracks(anime: Anime, source: AnimeSource) {
withNonCancellableContext {
getTracks.await(anime.id)
.filterIsInstance<EnhancedAnimeTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(anime)?.let { track ->
track.anime_id = anime.id
(service as Tracker).animeService.bind(track)
insertTrack.await(track.toDomainTrack()!!)
suspend fun bindEnhancedTracks(anime: Anime, source: AnimeSource) = withNonCancellableContext {
getTracks.await(anime.id)
.filterIsInstance<EnhancedAnimeTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(anime)?.let { track ->
track.anime_id = anime.id
(service as Tracker).animeService.bind(track)
insertTrack.await(track.toDomainTrack()!!)
syncChapterProgressWithTrack.await(
anime.id,
track.toDomainTrack()!!,
service.animeService,
)
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match manga: ${anime.title} with service $service" }
syncChapterProgressWithTrack.await(
anime.id,
track.toDomainTrack()!!,
service.animeService,
)
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match anime: ${anime.title} with service $service" }
}
}
}
}
}

View file

@ -17,31 +17,29 @@ class AddMangaTracks(
private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack,
) {
suspend fun bindEnhancedTracks(manga: Manga, source: MangaSource) {
withNonCancellableContext {
getTracks.await(manga.id)
.filterIsInstance<EnhancedMangaTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id
(service as Tracker).mangaService.bind(track)
insertTrack.await(track.toDomainTrack()!!)
suspend fun bindEnhancedTracks(manga: Manga, source: MangaSource) = withNonCancellableContext {
getTracks.await(manga.id)
.filterIsInstance<EnhancedMangaTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id
(service as Tracker).mangaService.bind(track)
insertTrack.await(track.toDomainTrack()!!)
syncChapterProgressWithTrack.await(
manga.id,
track.toDomainTrack()!!,
service.mangaService,
)
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match manga: ${manga.title} with service $service" }
syncChapterProgressWithTrack.await(
manga.id,
track.toDomainTrack()!!,
service.mangaService,
)
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match manga: ${manga.title} with service $service" }
}
}
}
}
}

View file

@ -19,6 +19,35 @@ import kotlinx.coroutines.delay
import tachiyomi.domain.category.model.Category
import kotlin.time.Duration.Companion.seconds
@Composable
fun CategorySortAlphabeticallyDialog(
onDismissRequest: () -> Unit,
onSort: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onSort()
onDismissRequest()
}) {
Text(text = stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
},
title = {
Text(text = stringResource(R.string.action_sort_category))
},
text = {
Text(text = stringResource(R.string.sort_category_confirmation))
},
)
}
@Composable
fun CategoryCreateDialog(
onDismissRequest: () -> Unit,

View file

@ -302,8 +302,14 @@ private fun AnimeScreenSmallImpl(
val episodes = remember(state) { state.processedEpisodes }
val isAnySelected by remember {
derivedStateOf {
episodes.fastAny { it.selected }
}
}
val internalOnBackPressed = {
if (episodes.fastAny { it.selected }) {
if (isAnySelected) {
onAllEpisodeSelected(false)
} else {
onBackClicked()
@ -312,17 +318,22 @@ private fun AnimeScreenSmallImpl(
BackHandler(onBack = internalOnBackPressed)
Scaffold(
topBar = {
val firstVisibleItemIndex by remember {
derivedStateOf { episodeListState.firstVisibleItemIndex }
val selectedEpisodeCount: Int = remember(episodes) {
episodes.count { it.selected }
}
val firstVisibleItemScrollOffset by remember {
derivedStateOf { episodeListState.firstVisibleItemScrollOffset }
val isFirstItemVisible by remember {
derivedStateOf { episodeListState.firstVisibleItemIndex == 0 }
}
val isFirstItemScrolled by remember {
derivedStateOf { episodeListState.firstVisibleItemScrollOffset > 0 }
}
val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f,
if (!isFirstItemVisible) 1f else 0f,
label = "Top Bar Title",
)
val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f,
label = "Top Bar Background",
)
EntryToolbar(
title = state.anime.title,
@ -337,7 +348,7 @@ private fun AnimeScreenSmallImpl(
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
changeAnimeSkipIntro = changeAnimeSkipIntro,
actionModeCounter = episodes.count { it.selected },
actionModeCounter = selectedEpisodeCount,
onSelectAll = { onAllEpisodeSelected(true) },
onInvertSelection = { onInvertSelection() },
isManga = false,
@ -345,8 +356,11 @@ private fun AnimeScreenSmallImpl(
)
},
bottomBar = {
val selectedEpisodes = remember(episodes) {
episodes.filter { it.selected }
}
SharedAnimeBottomActionMenu(
selected = episodes.filter { it.selected },
selected = selectedEpisodes,
onEpisodeClicked = onEpisodeClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
@ -359,19 +373,20 @@ private fun AnimeScreenSmallImpl(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(episodes) {
episodes.fastAny { !it.episode.seen } && !isAnySelected
}
AnimatedVisibility(
visible = episodes.fastAny { !it.episode.seen } && episodes.fastAll { !it.selected },
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (state.episodes.fastAny { it.episode.seen }) {
R.string.action_resume
} else {
R.string.action_start
val isWatching = remember(state.episodes) {
state.episodes.fastAny { it.episode.seen }
}
Text(text = stringResource(id))
Text(text = stringResource(if (isWatching) R.string.action_resume else R.string.action_start))
},
icon = {
Icon(
@ -390,7 +405,7 @@ private fun AnimeScreenSmallImpl(
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = episodes.fastAll { !it.selected },
enabled = !isAnySelected,
indicatorPadding = WindowInsets.systemBars.only(WindowInsetsSides.Top).asPaddingValues(),
) {
val layoutDirection = LocalLayoutDirection.current
@ -462,10 +477,13 @@ private fun AnimeScreenSmallImpl(
key = EntryScreenItem.ITEM_HEADER,
contentType = EntryScreenItem.ITEM_HEADER,
) {
val missingItemsCount = remember(episodes) {
episodes.map { it.episode.episodeNumber }.missingItemsCount()
}
ItemHeader(
enabled = episodes.fastAll { !it.selected },
enabled = !isAnySelected,
itemCount = episodes.size,
missingItemsCount = episodes.map { it.episode.episodeNumber }.missingItemsCount(),
missingItemsCount = missingItemsCount,
onClick = onFilterClicked,
isManga = false,
)
@ -574,13 +592,19 @@ fun AnimeScreenLargeImpl(
val episodes = remember(state) { state.processedEpisodes }
val isAnySelected by remember {
derivedStateOf {
episodes.fastAny { it.selected }
}
}
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
var topBarHeight by remember { mutableIntStateOf(0) }
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = episodes.fastAll { !it.selected },
enabled = !isAnySelected,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
@ -590,7 +614,7 @@ fun AnimeScreenLargeImpl(
val episodeListState = rememberLazyListState()
val internalOnBackPressed = {
if (episodes.fastAny { it.selected }) {
if (isAnySelected) {
onAllEpisodeSelected(false)
} else {
onBackClicked()
@ -600,10 +624,13 @@ fun AnimeScreenLargeImpl(
Scaffold(
topBar = {
val selectedEpisodeCount = remember(episodes) {
episodes.count { it.selected }
}
EntryToolbar(
modifier = Modifier.onSizeChanged { topBarHeight = (it.height) },
title = state.anime.title,
titleAlphaProvider = { if (episodes.fastAny { it.selected }) 1f else 0f },
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
backgroundAlphaProvider = { 1f },
hasFilters = state.anime.episodesFiltered(),
onBackClicked = internalOnBackPressed,
@ -614,7 +641,7 @@ fun AnimeScreenLargeImpl(
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
changeAnimeSkipIntro = changeAnimeSkipIntro,
actionModeCounter = episodes.count { it.selected },
actionModeCounter = selectedEpisodeCount,
onSelectAll = { onAllEpisodeSelected(true) },
onInvertSelection = { onInvertSelection() },
isManga = false,
@ -626,8 +653,11 @@ fun AnimeScreenLargeImpl(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
val selectedEpisodes = remember(episodes) {
episodes.filter { it.selected }
}
SharedAnimeBottomActionMenu(
selected = episodes.filter { it.selected },
selected = selectedEpisodes,
onEpisodeClicked = onEpisodeClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
@ -641,19 +671,20 @@ fun AnimeScreenLargeImpl(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(episodes) {
episodes.fastAny { !it.episode.seen } && !isAnySelected
}
AnimatedVisibility(
visible = episodes.fastAny { !it.episode.seen } && episodes.fastAll { !it.selected },
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (state.episodes.fastAny { it.episode.seen }) {
R.string.action_resume
} else {
R.string.action_start
val isWatching = remember(state.episodes) {
state.episodes.fastAny { it.episode.seen }
}
Text(text = stringResource(id))
Text(text = stringResource(if (isWatching) R.string.action_resume else R.string.action_start))
},
icon = {
Icon(
@ -729,10 +760,13 @@ fun AnimeScreenLargeImpl(
key = EntryScreenItem.ITEM_HEADER,
contentType = EntryScreenItem.ITEM_HEADER,
) {
val missingItemsCount = remember(episodes) {
episodes.map { it.episode.episodeNumber }.missingItemsCount()
}
ItemHeader(
enabled = episodes.fastAll { !it.selected },
enabled = !isAnySelected,
itemCount = episodes.size,
missingItemsCount = episodes.map { it.episode.episodeNumber }.missingItemsCount(),
missingItemsCount = missingItemsCount,
onClick = onFilterButtonClicked,
isManga = false,
)

View file

@ -298,7 +298,7 @@ fun ExpandableAnimeDescription(
) {
tags.forEach {
TagsChip(
modifier = Modifier.padding(vertical = 4.dp),
modifier = DefaultTagChipModifier,
text = it,
onClick = {
tagSelected = it
@ -314,7 +314,7 @@ fun ExpandableAnimeDescription(
) {
items(items = tags) {
TagsChip(
modifier = Modifier.padding(vertical = 4.dp),
modifier = DefaultTagChipModifier,
text = it,
onClick = {
tagSelected = it
@ -676,6 +676,8 @@ private fun AnimeSummary(
}
}
private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@Composable
private fun TagsChip(
text: String,

View file

@ -282,8 +282,14 @@ private fun MangaScreenSmallImpl(
val chapters = remember(state) { state.processedChapters }
val isAnySelected by remember {
derivedStateOf {
chapters.fastAny { it.selected }
}
}
val internalOnBackPressed = {
if (chapters.fastAny { it.selected }) {
if (isAnySelected) {
onAllChapterSelected(false)
} else {
onBackClicked()
@ -293,17 +299,22 @@ private fun MangaScreenSmallImpl(
Scaffold(
topBar = {
val firstVisibleItemIndex by remember {
derivedStateOf { chapterListState.firstVisibleItemIndex }
val selectedChapterCount: Int = remember(chapters) {
chapters.count { it.selected }
}
val firstVisibleItemScrollOffset by remember {
derivedStateOf { chapterListState.firstVisibleItemScrollOffset }
val isFirstItemVisible by remember {
derivedStateOf { chapterListState.firstVisibleItemIndex == 0 }
}
val isFirstItemScrolled by remember {
derivedStateOf { chapterListState.firstVisibleItemScrollOffset > 0 }
}
val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f,
if (!isFirstItemVisible) 1f else 0f,
label = "Top Bar Title",
)
val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f,
label = "Top Bar Background",
)
EntryToolbar(
title = state.manga.title,
@ -318,7 +329,7 @@ private fun MangaScreenSmallImpl(
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
changeAnimeSkipIntro = null,
actionModeCounter = chapters.count { it.selected },
actionModeCounter = selectedChapterCount,
onSelectAll = { onAllChapterSelected(true) },
onInvertSelection = { onInvertSelection() },
isManga = true,
@ -326,8 +337,11 @@ private fun MangaScreenSmallImpl(
)
},
bottomBar = {
val selectedChapters = remember(chapters) {
chapters.filter { it.selected }
}
SharedMangaBottomActionMenu(
selected = chapters.filter { it.selected },
selected = selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
@ -338,19 +352,20 @@ private fun MangaScreenSmallImpl(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = chapters.fastAny { !it.chapter.read } && chapters.fastAll { !it.selected },
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (state.chapters.fastAny { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(text = stringResource(id))
Text(text = stringResource(if (isReading) R.string.action_resume else R.string.action_start))
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
@ -364,7 +379,7 @@ private fun MangaScreenSmallImpl(
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
indicatorPadding = WindowInsets.systemBars.only(WindowInsetsSides.Top).asPaddingValues(),
) {
val layoutDirection = LocalLayoutDirection.current
@ -436,10 +451,13 @@ private fun MangaScreenSmallImpl(
key = EntryScreenItem.ITEM_HEADER,
contentType = EntryScreenItem.ITEM_HEADER,
) {
val missingItemsCount = remember(chapters) {
chapters.map { it.chapter.chapterNumber }.missingItemsCount()
}
ItemHeader(
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
itemCount = chapters.size,
missingItemsCount = chapters.map { it.chapter.chapterNumber }.missingItemsCount(),
missingItemsCount = missingItemsCount,
onClick = onFilterClicked,
isManga = true,
)
@ -519,12 +537,18 @@ fun MangaScreenLargeImpl(
val chapters = remember(state) { state.processedChapters }
val isAnySelected by remember {
derivedStateOf {
chapters.fastAny { it.selected }
}
}
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
var topBarHeight by remember { mutableIntStateOf(0) }
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
@ -534,7 +558,7 @@ fun MangaScreenLargeImpl(
val chapterListState = rememberLazyListState()
val internalOnBackPressed = {
if (chapters.fastAny { it.selected }) {
if (isAnySelected) {
onAllChapterSelected(false)
} else {
onBackClicked()
@ -544,10 +568,13 @@ fun MangaScreenLargeImpl(
Scaffold(
topBar = {
val selectedChapterCount = remember(chapters) {
chapters.count { it.selected }
}
EntryToolbar(
modifier = Modifier.onSizeChanged { topBarHeight = it.height },
title = state.manga.title,
titleAlphaProvider = { if (chapters.fastAny { it.selected }) 1f else 0f },
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
backgroundAlphaProvider = { 1f },
hasFilters = state.manga.chaptersFiltered(),
onBackClicked = internalOnBackPressed,
@ -558,7 +585,7 @@ fun MangaScreenLargeImpl(
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
changeAnimeSkipIntro = null,
actionModeCounter = chapters.count { it.selected },
actionModeCounter = selectedChapterCount,
onSelectAll = { onAllChapterSelected(true) },
onInvertSelection = { onInvertSelection() },
isManga = true,
@ -570,8 +597,11 @@ fun MangaScreenLargeImpl(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
val selectedChapters = remember(chapters) {
chapters.filter { it.selected }
}
SharedMangaBottomActionMenu(
selected = chapters.filter { it.selected },
selected = selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
@ -583,19 +613,20 @@ fun MangaScreenLargeImpl(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = chapters.fastAny { !it.chapter.read } && chapters.fastAll { !it.selected },
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (state.chapters.fastAny { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(text = stringResource(id))
Text(text = stringResource(if (isReading) R.string.action_resume else R.string.action_start))
},
icon = {
Icon(
@ -671,10 +702,13 @@ fun MangaScreenLargeImpl(
key = EntryScreenItem.ITEM_HEADER,
contentType = EntryScreenItem.ITEM_HEADER,
) {
val missingItemsCount = remember(chapters) {
chapters.map { it.chapter.chapterNumber }.missingItemsCount()
}
ItemHeader(
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
itemCount = chapters.size,
missingItemsCount = chapters.map { it.chapter.chapterNumber }.missingItemsCount(),
missingItemsCount = missingItemsCount,
onClick = onFilterButtonClicked,
isManga = true,
)

View file

@ -298,7 +298,7 @@ fun ExpandableMangaDescription(
) {
tags.forEach {
TagsChip(
modifier = Modifier.padding(vertical = 4.dp),
modifier = DefaultTagChipModifier,
text = it,
onClick = {
tagSelected = it
@ -314,7 +314,7 @@ fun ExpandableMangaDescription(
) {
items(items = tags) {
TagsChip(
modifier = Modifier.padding(vertical = 4.dp),
modifier = DefaultTagChipModifier,
text = it,
onClick = {
tagSelected = it
@ -676,6 +676,8 @@ private fun MangaSummary(
}
}
private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@Composable
private fun TagsChip(
text: String,

View file

@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -329,12 +328,6 @@ object SettingsReaderScreen : SearchableSettings {
subtitle = stringResource(R.string.pref_dual_page_invert_summary),
enabled = dualPageSplit,
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.longStripSplitWebtoon(),
title = stringResource(R.string.pref_long_strip_split),
subtitle = stringResource(R.string.split_tall_images_summary),
enabled = !isReleaseBuildType, // TODO: Show in release build when the feature is stable
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.webtoonDoubleTapZoomEnabled(),
title = stringResource(R.string.pref_double_tap_zoom),
@ -373,11 +366,6 @@ object SettingsReaderScreen : SearchableSettings {
pref = readerPreferences.readWithLongTap(),
title = stringResource(R.string.pref_read_with_long_tap),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.folderPerManga(),
title = stringResource(R.string.pref_create_folder_per_manga),
subtitle = stringResource(R.string.pref_create_folder_per_manga_summary),
),
),
)
}

View file

@ -272,7 +272,7 @@ private fun getIndex() = settingScreens
SettingsData(
title = stringResource(screen.getTitleRes()),
route = screen,
contents = screen.sourcePreferences(),
contents = screen.getPreferences(),
)
}

View file

@ -23,10 +23,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import compose.icons.SimpleIcons
import compose.icons.simpleicons.Discord
import compose.icons.simpleicons.Github
import compose.icons.simpleicons.Reddit
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.LogoHeader
@ -51,6 +47,10 @@ import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.presentation.core.components.LinkIcon
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
import tachiyomi.presentation.core.icons.Github
import tachiyomi.presentation.core.icons.Reddit
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
@ -183,17 +183,17 @@ object AboutScreen : Screen() {
)
LinkIcon(
label = "Discord",
icon = SimpleIcons.Discord,
icon = CustomIcons.Discord,
url = "https://discord.gg/F32UjdJZrR",
)
LinkIcon(
label = "Reddit",
icon = SimpleIcons.Reddit,
icon = CustomIcons.Reddit,
url = "https://www.reddit.com/r/Aniyomi",
)
LinkIcon(
label = "GitHub",
icon = SimpleIcons.Github,
icon = CustomIcons.Github,
url = "https://github.com/aniyomiorg/aniyomi",
)
}

View file

@ -6,12 +6,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalTextApi::class)
@Composable
fun PageIndicatorText(
currentPage: Int,

View file

@ -16,7 +16,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.SettingsChipRow
@ -193,13 +192,6 @@ private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenM
)
}
if (!isReleaseBuildType) {
CheckboxItem(
label = stringResource(R.string.pref_long_strip_split),
pref = screenModel.preferences.longStripSplitWebtoon(),
)
}
CheckboxItem(
label = stringResource(R.string.pref_double_tap_zoom),
pref = screenModel.preferences.webtoonDoubleTapZoomEnabled(),

View file

@ -17,7 +17,6 @@ import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager
import tachiyomi.core.preference.PreferenceStore
@ -492,13 +491,6 @@ object Migrations {
if (oldVersion < 100) {
BackupCreateJob.setupTask(context)
}
if (oldVersion < 102) {
// This was accidentally visible from the reader settings sheet, but should always
// be disabled in release builds.
if (isReleaseBuildType) {
readerPreferences.longStripSplitWebtoon().set(false)
}
}
if (oldVersion < 105) {
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
if (pref.isSet() && "battery_not_low" in pref.get()) {

View file

@ -4,17 +4,24 @@ package eu.kanade.tachiyomi.data.backup
internal object BackupConst {
const val BACKUP_CATEGORY = 0x1
const val BACKUP_CATEGORY_MASK = 0x1
const val BACKUP_CHAPTER = 0x2
const val BACKUP_CHAPTER_MASK = 0x2
const val BACKUP_HISTORY = 0x4
const val BACKUP_HISTORY_MASK = 0x4
const val BACKUP_TRACK = 0x8
const val BACKUP_TRACK_MASK = 0x8
const val BACKUP_PREFS = 0x10
const val BACKUP_PREFS_MASK = 0x10
const val BACKUP_EXT_PREFS = 0x20
const val BACKUP_EXT_PREFS_MASK = 0x20
const val BACKUP_EXTENSIONS = 0x40
const val BACKUP_EXTENSIONS_MASK = 0x40
const val BACKUP_ALL = 0x7F
}

View file

@ -48,7 +48,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
}
return try {
val location = BackupManager(context).createBackup(uri, flags, isAutoBackup)
val location = BackupCreator(context).createBackup(uri, flags, isAutoBackup)
if (!isAutoBackup) {
notifier.showBackupComplete(
UniFile.fromUri(context, location.toUri()),

View file

@ -0,0 +1,449 @@
package eu.kanade.tachiyomi.data.backup
import android.Manifest
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import androidx.preference.PreferenceManager
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupAnime
import eu.kanade.tachiyomi.data.backup.models.BackupAnimeHistory
import eu.kanade.tachiyomi.data.backup.models.BackupAnimeSource
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtension
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionPreferences
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.backupAnimeTrackMapper
import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupEpisodeMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
import eu.kanade.tachiyomi.source.anime.getPreferenceKey
import eu.kanade.tachiyomi.source.manga.getPreferenceKey
import eu.kanade.tachiyomi.util.system.hasPermission
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.buffer
import okio.gzip
import okio.sink
import tachiyomi.core.util.system.logcat
import tachiyomi.data.handlers.anime.AnimeDatabaseHandler
import tachiyomi.data.handlers.manga.MangaDatabaseHandler
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.anime.interactor.GetAnimeCategories
import tachiyomi.domain.category.manga.interactor.GetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.entries.anime.interactor.GetAnimeFavorites
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.manga.interactor.GetMangaFavorites
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.history.anime.interactor.GetAnimeHistory
import tachiyomi.domain.history.manga.interactor.GetMangaHistory
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.source.anime.service.AnimeSourceManager
import tachiyomi.domain.source.manga.service.MangaSourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileOutputStream
class BackupCreator(
private val context: Context,
) {
private val mangaHandler: MangaDatabaseHandler = Injekt.get()
private val animeHandler: AnimeDatabaseHandler = Injekt.get()
private val mangaSourceManager: MangaSourceManager = Injekt.get()
private val animeSourceManager: AnimeSourceManager = Injekt.get()
private val backupPreferences: BackupPreferences = Injekt.get()
private val libraryPreferences: LibraryPreferences = Injekt.get()
private val getMangaCategories: GetMangaCategories = Injekt.get()
private val getAnimeCategories: GetAnimeCategories = Injekt.get()
private val getMangaFavorites: GetMangaFavorites = Injekt.get()
private val getAnimeFavorites: GetAnimeFavorites = Injekt.get()
private val getMangaHistory: GetMangaHistory = Injekt.get()
private val getAnimeHistory: GetAnimeHistory = Injekt.get()
internal val parser = ProtoBuf
/**
* Create backup file from database
*
* @param uri path of Uri
* @param isAutoBackup backup called from scheduled backup job
*/
suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String {
if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
throw IllegalStateException(context.getString(R.string.missing_storage_permission))
}
val databaseAnime = getAnimeFavorites.await()
val databaseManga = getMangaFavorites.await()
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val backup = Backup(
backupMangas(databaseManga, flags),
backupCategories(flags),
backupAnimes(databaseAnime, flags),
backupAnimeCategories(flags),
emptyList(),
prepExtensionInfoForSync(databaseManga),
emptyList(),
prepAnimeExtensionInfoForSync(databaseAnime),
backupPreferences(prefs, flags),
backupExtensionPreferences(flags),
backupExtensions(flags),
)
var file: UniFile? = null
try {
file = (
if (isAutoBackup) {
// Get dir of file and create
var dir = UniFile.fromUri(context, uri)
dir = dir.createDirectory("automatic")
// Delete older backups
val numberOfBackups = backupPreferences.numberOfBackups().get()
dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(numberOfBackups - 1)
.forEach { it.delete() }
// Create new file to place backup
dir.createFile(Backup.getFilename())
} else {
UniFile.fromUri(context, uri)
}
)
?: throw Exception(context.getString(R.string.create_backup_file_error))
if (!file.isFile) {
throw IllegalStateException("Failed to get handle on a backup file")
}
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.getString(R.string.empty_backup_error))
}
file.openOutputStream().also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}.sink().gzip().buffer().use { it.write(byteArray) }
val fileUri = file.uri
// Make sure it's a valid backup file
BackupFileValidator().validate(context, fileUri)
return fileUri.toString()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
file?.delete()
throw e
}
}
private fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map(Manga::source)
.distinct()
.map(mangaSourceManager::getOrStub)
.map(BackupSource::copyFrom)
.toList()
}
private fun prepAnimeExtensionInfoForSync(animes: List<Anime>): List<BackupAnimeSource> {
return animes
.asSequence()
.map(Anime::source)
.distinct()
.map(animeSourceManager::getOrStub)
.map(BackupAnimeSource::copyFrom)
.toList()
}
/**
* Backup the categories of manga library
*
* @return list of [BackupCategory] to be backed up
*/
private suspend fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BackupConst.BACKUP_CATEGORY_MASK == BackupConst.BACKUP_CATEGORY) {
getMangaCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
} else {
emptyList()
}
}
/**
* Backup the categories of anime library
*
* @return list of [BackupCategory] to be backed up
*/
private suspend fun backupAnimeCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BackupConst.BACKUP_CATEGORY_MASK == BackupConst.BACKUP_CATEGORY) {
getAnimeCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
} else {
emptyList()
}
}
private suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map {
backupManga(it, flags)
}
}
private suspend fun backupAnimes(animes: List<Anime>, flags: Int): List<BackupAnime> {
return animes.map {
backupAnime(it, flags)
}
}
/**
* Convert a manga to Json
*
* @param manga manga that gets converted
* @param options options for the backup
* @return [BackupManga] containing manga in a serializable form
*/
private suspend fun backupManga(manga: Manga, options: Int): BackupManga {
// Entry for this manga
val mangaObject = BackupManga.copyFrom(manga)
// Check if user wants chapter information in backup
if (options and BackupConst.BACKUP_CHAPTER_MASK == BackupConst.BACKUP_CHAPTER) {
// Backup all the chapters
val chapters = mangaHandler.awaitList {
chaptersQueries.getChaptersByMangaId(
manga.id,
backupChapterMapper,
)
}
if (chapters.isNotEmpty()) {
mangaObject.chapters = chapters
}
}
// Check if user wants category information in backup
if (options and BackupConst.BACKUP_CATEGORY_MASK == BackupConst.BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = getMangaCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
}
}
// Check if user wants track information in backup
if (options and BackupConst.BACKUP_TRACK_MASK == BackupConst.BACKUP_TRACK) {
val tracks = mangaHandler.awaitList {
manga_syncQueries.getTracksByMangaId(
manga.id,
backupTrackMapper,
)
}
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BackupConst.BACKUP_HISTORY_MASK == BackupConst.BACKUP_HISTORY) {
val historyByMangaId = getMangaHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = mangaHandler.awaitOne {
chaptersQueries.getChapterById(
history.chapterId,
)
}
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
}
/**
* Convert an anime to Json
*
* @param anime anime that gets converted
* @param options options for the backup
* @return [BackupAnime] containing anime in a serializable form
*/
private suspend fun backupAnime(anime: Anime, options: Int): BackupAnime {
// Entry for this anime
val animeObject = BackupAnime.copyFrom(anime)
// Check if user wants chapter information in backup
if (options and BackupConst.BACKUP_CHAPTER_MASK == BackupConst.BACKUP_CHAPTER) {
// Backup all the chapters
val episodes = animeHandler.awaitList {
episodesQueries.getEpisodesByAnimeId(
anime.id,
backupEpisodeMapper,
)
}
if (episodes.isNotEmpty()) {
animeObject.episodes = episodes
}
}
// Check if user wants category information in backup
if (options and BackupConst.BACKUP_CATEGORY_MASK == BackupConst.BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForAnime = getAnimeCategories.await(anime.id)
if (categoriesForAnime.isNotEmpty()) {
animeObject.categories = categoriesForAnime.map { it.order }
}
}
// Check if user wants track information in backup
if (options and BackupConst.BACKUP_TRACK_MASK == BackupConst.BACKUP_TRACK) {
val tracks = animeHandler.awaitList {
anime_syncQueries.getTracksByAnimeId(
anime.id,
backupAnimeTrackMapper,
)
}
if (tracks.isNotEmpty()) {
animeObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BackupConst.BACKUP_HISTORY_MASK == BackupConst.BACKUP_HISTORY) {
val historyByAnimeId = getAnimeHistory.await(anime.id)
if (historyByAnimeId.isNotEmpty()) {
val history = historyByAnimeId.map { history ->
val episode = animeHandler.awaitOne {
episodesQueries.getEpisodeById(
history.episodeId,
)
}
BackupAnimeHistory(episode.url, history.seenAt?.time ?: 0L)
}
if (history.isNotEmpty()) {
animeObject.history = history
}
}
}
return animeObject
}
private fun backupExtensionPreferences(flags: Int): List<BackupExtensionPreferences> {
if (flags and BackupConst.BACKUP_EXT_PREFS_MASK != BackupConst.BACKUP_EXT_PREFS) return emptyList()
val prefs = mutableListOf<Pair<String, SharedPreferences>>()
Injekt.get<AnimeSourceManager>().getOnlineSources().forEach {
val name = it.getPreferenceKey()
prefs += Pair(name, context.getSharedPreferences(name, 0x0))
}
Injekt.get<MangaSourceManager>().getOnlineSources().forEach {
val name = it.getPreferenceKey()
prefs += Pair(name, context.getSharedPreferences(name, 0x0))
}
return prefs.map {
BackupExtensionPreferences(
it.first,
backupPreferences(it.second, BackupConst.BACKUP_PREFS),
)
}
}
@Suppress("DEPRECATION")
private fun backupExtensions(flags: Int): List<BackupExtension> {
if (flags and BackupConst.BACKUP_EXTENSIONS_MASK != BackupConst.BACKUP_EXTENSIONS) return emptyList()
val installedExtensions = mutableListOf<BackupExtension>()
Injekt.get<AnimeExtensionManager>().installedExtensionsFlow.value.forEach {
val packageName = it.pkgName
val apk = File(
context.packageManager
.getApplicationInfo(
packageName,
PackageManager.GET_META_DATA,
).publicSourceDir,
).readBytes()
installedExtensions.add(
BackupExtension(packageName, apk),
)
}
Injekt.get<MangaExtensionManager>().installedExtensionsFlow.value.forEach {
val packageName = it.pkgName
val apk = File(
context.packageManager
.getApplicationInfo(
packageName,
PackageManager.GET_META_DATA,
).publicSourceDir,
).readBytes()
installedExtensions.add(
BackupExtension(packageName, apk),
)
}
return installedExtensions
}
private fun backupPreferences(prefs: SharedPreferences, flags: Int): List<BackupPreference> {
if (flags and BackupConst.BACKUP_PREFS_MASK != BackupConst.BACKUP_PREFS) return emptyList()
val backupPreferences = mutableListOf<BackupPreference>()
for (pref in prefs.all) {
val toAdd = when (pref.value) {
is Int -> {
BackupPreference(pref.key, IntPreferenceValue(pref.value as Int))
}
is Long -> {
BackupPreference(pref.key, LongPreferenceValue(pref.value as Long))
}
is Float -> {
BackupPreference(pref.key, FloatPreferenceValue(pref.value as Float))
}
is String -> {
BackupPreference(pref.key, StringPreferenceValue(pref.value as String))
}
is Boolean -> {
BackupPreference(pref.key, BooleanPreferenceValue(pref.value as Boolean))
}
is Set<*> -> {
(pref.value as? Set<String>)?.let {
BackupPreference(pref.key, StringSetPreferenceValue(it))
} ?: continue
}
else -> {
continue
}
}
backupPreferences.add(toAdd)
}
return backupPreferences
}
}

View file

@ -78,5 +78,8 @@ class BackupFileValidator(
return Results(missingSources, missingTrackers)
}
data class Results(val missingSources: List<String>, val missingTrackers: List<String>)
data class Results(
val missingSources: List<String>,
val missingTrackers: List<String>,
)
}

View file

@ -26,7 +26,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: return Result.failure()
val sync = inputData.getBoolean(SYNC, false)
val sync = inputData.getBoolean(SYNC_KEY, false)
try {
setForeground(getForegroundInfo())
@ -67,7 +67,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
fun start(context: Context, uri: Uri, sync: Boolean = false) {
val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(),
SYNC to sync,
SYNC_KEY to sync,
)
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG)
@ -86,4 +86,4 @@ private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String
private const val SYNC = "sync" // Boolean
private const val SYNC_KEY = "sync" // Boolean

View file

@ -5,8 +5,14 @@ import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import androidx.preference.PreferenceManager
import data.Manga_sync
import data.Mangas
import dataanime.Anime_sync
import dataanime.Animes
import eu.kanade.domain.entries.anime.interactor.UpdateAnime
import eu.kanade.domain.entries.manga.interactor.UpdateManga
import eu.kanade.domain.items.chapter.model.copyFrom
import eu.kanade.domain.items.episode.model.copyFrom
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.BackupAnime
import eu.kanade.tachiyomi.data.backup.models.BackupAnimeHistory
@ -24,20 +30,28 @@ import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import eu.kanade.tachiyomi.source.anime.model.copyFrom
import eu.kanade.tachiyomi.source.manga.model.copyFrom
import eu.kanade.tachiyomi.util.BackupUtil
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import tachiyomi.core.util.system.logcat
import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.data.handlers.anime.AnimeDatabaseHandler
import tachiyomi.data.handlers.manga.MangaDatabaseHandler
import tachiyomi.domain.category.anime.interactor.GetAnimeCategories
import tachiyomi.domain.category.manga.interactor.GetMangaCategories
import tachiyomi.domain.entries.anime.interactor.AnimeFetchInterval
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.manga.interactor.MangaFetchInterval
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.history.anime.model.AnimeHistoryUpdate
import tachiyomi.domain.history.manga.model.MangaHistoryUpdate
import tachiyomi.domain.items.chapter.model.Chapter
import tachiyomi.domain.items.chapter.repository.ChapterRepository
import tachiyomi.domain.items.episode.model.Episode
import tachiyomi.domain.items.episode.repository.EpisodeRepository
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.track.anime.model.AnimeTrack
import tachiyomi.domain.track.manga.model.MangaTrack
import uy.kohesive.injekt.Injekt
@ -47,25 +61,29 @@ import java.text.SimpleDateFormat
import java.time.ZonedDateTime
import java.util.Date
import java.util.Locale
import kotlin.math.max
class BackupRestorer(
private val context: Context,
private val notifier: BackupNotifier,
) {
private val mangaHandler: MangaDatabaseHandler = Injekt.get()
private val updateManga: UpdateManga = Injekt.get()
private val chapterRepository: ChapterRepository = Injekt.get()
private val getMangaCategories: GetMangaCategories = Injekt.get()
private val mangaFetchInterval: MangaFetchInterval = Injekt.get()
private val animeHandler: AnimeDatabaseHandler = Injekt.get()
private val updateAnime: UpdateAnime = Injekt.get()
private val episodeRepository: EpisodeRepository = Injekt.get()
private val getAnimeCategories: GetAnimeCategories = Injekt.get()
private val animeFetchInterval: AnimeFetchInterval = Injekt.get()
private val libraryPreferences: LibraryPreferences = Injekt.get()
private var now = ZonedDateTime.now()
private var currentMangaFetchWindow = mangaFetchInterval.getWindow(now)
private var currentAnimeFetchWindow = animeFetchInterval.getWindow(now)
private var backupManager = BackupManager(context)
private var restoreAmount = 0
private var restoreProgress = 0
@ -128,7 +146,7 @@ class BackupRestorer(
private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean {
val backup = BackupUtil.decodeBackup(context, uri)
restoreAmount = backup.backupManga.size + backup.backupAnime.size + 2 // +2 for categories
restoreAmount = backup.backupManga.size + backup.backupAnime.size + 3 // +3 for categories, app prefs, source prefs
// Restore categories
if (backup.backupCategories.isNotEmpty()) {
@ -194,7 +212,38 @@ class BackupRestorer(
}
private suspend fun restoreCategories(backupCategories: List<BackupCategory>) {
backupManager.restoreCategories(backupCategories)
// Get categories from file and from db
val dbCategories = getMangaCategories.await()
val categories = backupCategories.map {
var category = it.getCategory()
var found = false
for (dbCategory in dbCategories) {
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.name == dbCategory.name) {
category = category.copy(id = dbCategory.id)
found = true
break
}
}
if (!found) {
// Let the db assign the id
val id = mangaHandler.awaitOneExecutable {
categoriesQueries.insert(category.name, category.order, category.flags)
categoriesQueries.selectLastInsertedRowId()
}
category = category.copy(id = id)
}
category
}
libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories)
.distinctBy { it.flags }
.size > 1,
)
restoreProgress += 1
showRestoreProgress(
@ -206,7 +255,38 @@ class BackupRestorer(
}
private suspend fun restoreAnimeCategories(backupCategories: List<BackupCategory>) {
backupManager.restoreAnimeCategories(backupCategories)
// Get categories from file and from db
val dbCategories = getAnimeCategories.await()
val categories = backupCategories.map {
var category = it.getCategory()
var found = false
for (dbCategory in dbCategories) {
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.name == dbCategory.name) {
category = category.copy(id = dbCategory.id)
found = true
break
}
}
if (!found) {
// Let the db assign the id
val id = animeHandler.awaitOneExecutable {
categoriesQueries.insert(category.name, category.order, category.flags)
categoriesQueries.selectLastInsertedRowId()
}
category = category.copy(id = id)
}
category
}
libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories)
.distinctBy { it.flags }
.size > 1,
)
restoreProgress += 1
showRestoreProgress(
@ -230,14 +310,14 @@ class BackupRestorer(
val tracks = backupManga.getTrackingImpl()
try {
val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source)
val dbManga = getMangaFromDatabase(manga.url, manga.source)
val restoredManga = if (dbManga == null) {
// Manga not in database
restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories)
} else {
// Manga in database
// Copy information from manga already in database
val updateManga = backupManager.restoreExistingManga(manga, dbManga)
val updateManga = restoreExistingManga(manga, dbManga)
// Fetch rest of manga information
restoreNewManga(
updateManga,
@ -272,6 +352,50 @@ class BackupRestorer(
}
}
/**
* Returns manga
*
* @return [Manga], null if not found
*/
private suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? {
return mangaHandler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) }
}
private suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga {
var updatedManga = manga.copy(id = dbManga._id)
updatedManga = updatedManga.copyFrom(dbManga)
updateManga(updatedManga)
return updatedManga
}
private suspend fun updateManga(manga: Manga): Long {
mangaHandler.await(true) {
mangasQueries.update(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre?.joinToString(separator = ", "),
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = null,
calculateInterval = null,
initialized = manga.initialized,
viewer = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
mangaId = manga.id!!,
updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode),
)
}
return manga.id
}
/**
* Fetches manga information
*
@ -287,12 +411,139 @@ class BackupRestorer(
tracks: List<MangaTrack>,
backupCategories: List<BackupCategory>,
): Manga {
val fetchedManga = backupManager.restoreNewManga(manga)
backupManager.restoreChapters(fetchedManga, chapters)
val fetchedManga = restoreNewManga(manga)
restoreChapters(fetchedManga, chapters)
restoreExtras(fetchedManga, categories, history, tracks, backupCategories)
return fetchedManga
}
private suspend fun restoreChapters(
manga: Manga,
chapters: List<Chapter>,
) {
val dbChapters = mangaHandler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id!!) }
val processed = chapters.map { chapter ->
var updatedChapter = chapter
val dbChapter = dbChapters.find { it.url == updatedChapter.url }
if (dbChapter != null) {
updatedChapter = updatedChapter.copy(id = dbChapter._id)
updatedChapter = updatedChapter.copyFrom(dbChapter)
if (dbChapter.read && !updatedChapter.read) {
updatedChapter = updatedChapter.copy(
read = true,
lastPageRead = dbChapter.last_page_read,
)
} else if (chapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
}
if (!updatedChapter.bookmark && dbChapter.bookmark) {
updatedChapter = updatedChapter.copy(bookmark = true)
}
}
updatedChapter.copy(mangaId = manga.id ?: -1)
}
val newChapters = processed.groupBy { it.id > 0 }
newChapters[true]?.let { updateKnownChapters(it) }
newChapters[false]?.let { insertChapters(it) }
}
/**
* Inserts list of chapters
*/
private suspend fun insertChapters(chapters: List<tachiyomi.domain.items.chapter.model.Chapter>) {
mangaHandler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.insert(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
)
}
}
}
/**
* Updates a list of chapters with known database ids
*/
private suspend fun updateKnownChapters(
chapters: List<tachiyomi.domain.items.chapter.model.Chapter>,
) {
mangaHandler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.update(
mangaId = null,
url = null,
name = null,
scanlator = null,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterNumber = null,
sourceOrder = null,
dateFetch = null,
dateUpload = null,
chapterId = chapter.id,
)
}
}
}
/**
* Fetches manga information
*
* @param manga manga that needs updating
* @return Updated manga info.
*/
private suspend fun restoreNewManga(manga: Manga): Manga {
return manga.copy(
initialized = manga.description != null,
id = insertManga(manga),
)
}
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
private suspend fun insertManga(manga: Manga): Long {
return mangaHandler.awaitOneExecutable(true) {
mangasQueries.insert(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status.toLong(),
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = 0L,
calculateInterval = 0L,
initialized = manga.initialized,
viewerFlags = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
)
mangasQueries.selectLastInsertedRowId()
}
}
private suspend fun restoreNewManga(
backupManga: Manga,
chapters: List<Chapter>,
@ -301,7 +552,7 @@ class BackupRestorer(
tracks: List<MangaTrack>,
backupCategories: List<BackupCategory>,
): Manga {
backupManager.restoreChapters(backupManga, chapters)
restoreChapters(backupManga, chapters)
restoreExtras(backupManga, categories, history, tracks, backupCategories)
return backupManga
}
@ -313,9 +564,186 @@ class BackupRestorer(
tracks: List<MangaTrack>,
backupCategories: List<BackupCategory>,
) {
backupManager.restoreCategories(manga, categories, backupCategories)
backupManager.restoreHistory(history)
backupManager.restoreTracking(manga, tracks)
restoreCategories(manga, categories, backupCategories)
restoreHistory(history)
restoreTracking(manga, tracks)
}
/**
* Restores the categories a manga is in.
*
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
private suspend fun restoreCategories(
manga: Manga,
categories: List<Int>,
backupCategories: List<BackupCategory>,
) {
val dbCategories = getMangaCategories.await()
val mangaCategoriesToUpdate = mutableListOf<Pair<Long, Long>>()
categories.forEach { backupCategoryOrder ->
backupCategories.firstOrNull {
it.order == backupCategoryOrder.toLong()
}?.let { backupCategory ->
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id))
}
}
}
// Update database
if (mangaCategoriesToUpdate.isNotEmpty()) {
mangaHandler.await(true) {
mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
mangas_categoriesQueries.insert(mangaId, categoryId)
}
}
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
private suspend fun restoreHistory(history: List<BackupHistory>) {
// List containing history to be updated
val toUpdate = mutableListOf<MangaHistoryUpdate>()
for ((url, lastRead, readDuration) in history) {
var dbHistory = mangaHandler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) }
// Check if history already in database and update
if (dbHistory != null) {
dbHistory = dbHistory.copy(
last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)),
time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read,
)
toUpdate.add(
MangaHistoryUpdate(
chapterId = dbHistory.chapter_id,
readAt = dbHistory.last_read!!,
sessionReadDuration = dbHistory.time_read,
),
)
} else {
// If not in database create
mangaHandler
.awaitOneOrNull { chaptersQueries.getChapterByUrl(url) }
?.let {
toUpdate.add(
MangaHistoryUpdate(
chapterId = it._id,
readAt = Date(lastRead),
sessionReadDuration = readDuration,
),
)
}
}
}
mangaHandler.await(true) {
toUpdate.forEach { payload ->
historyQueries.upsert(
payload.chapterId,
payload.readAt,
payload.sessionReadDuration,
)
}
}
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore.
*/
private suspend fun restoreTracking(
manga: Manga,
tracks: List<tachiyomi.domain.track.manga.model.MangaTrack>,
) {
// Get tracks from database
val dbTracks = mangaHandler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) }
val toUpdate = mutableListOf<Manga_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.manga.model.MangaTrack>()
tracks
// Fix foreign keys with the current manga id
.map { it.copy(mangaId = manga.id) }
.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(
last_chapter_read = max(
dbTrack.last_chapter_read,
track.lastChapterRead,
),
)
isInDatabase = true
toUpdate.add(temp)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database
if (toUpdate.isNotEmpty()) {
mangaHandler.await(true) {
toUpdate.forEach { track ->
manga_syncQueries.update(
track.manga_id,
track.sync_id,
track.remote_id,
track.library_id,
track.title,
track.last_chapter_read,
track.total_chapters,
track.status,
track.score,
track.remote_url,
track.start_date,
track.finish_date,
track._id,
)
}
}
}
if (toInsert.isNotEmpty()) {
mangaHandler.await(true) {
toInsert.forEach { track ->
manga_syncQueries.insert(
track.mangaId,
track.syncId,
track.remoteId,
track.libraryId,
track.title,
track.lastChapterRead,
track.totalChapters,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
)
}
}
}
}
private suspend fun restoreAnime(
@ -331,14 +759,14 @@ class BackupRestorer(
val tracks = backupAnime.getTrackingImpl()
try {
val dbAnime = backupManager.getAnimeFromDatabase(anime.url, anime.source)
val dbAnime = getAnimeFromDatabase(anime.url, anime.source)
val restoredAnime = if (dbAnime == null) {
// Anime not in database
restoreExistingAnime(anime, episodes, categories, history, tracks, backupCategories)
} else {
// Anime in database
// Copy information from anime already in database
val updateAnime = backupManager.restoreExistingAnime(anime, dbAnime)
val updateAnime = restoreExistingAnime(anime, dbAnime)
// Fetch rest of anime information
restoreNewAnime(
updateAnime,
@ -373,6 +801,50 @@ class BackupRestorer(
}
}
/**
* Returns anime
*
* @return [Anime], null if not found
*/
private suspend fun getAnimeFromDatabase(url: String, source: Long): Animes? {
return animeHandler.awaitOneOrNull { animesQueries.getAnimeByUrlAndSource(url, source) }
}
private suspend fun restoreExistingAnime(anime: Anime, dbAnime: Animes): Anime {
var updatedAnime = anime.copy(id = dbAnime._id)
updatedAnime = updatedAnime.copyFrom(dbAnime)
updateAnime(updatedAnime)
return updatedAnime
}
private suspend fun updateAnime(anime: Anime): Long {
animeHandler.await(true) {
animesQueries.update(
source = anime.source,
url = anime.url,
artist = anime.artist,
author = anime.author,
description = anime.description,
genre = anime.genre?.joinToString(separator = ", "),
title = anime.title,
status = anime.status,
thumbnailUrl = anime.thumbnailUrl,
favorite = anime.favorite,
lastUpdate = anime.lastUpdate,
nextUpdate = null,
calculateInterval = null,
initialized = anime.initialized,
viewer = anime.viewerFlags,
episodeFlags = anime.episodeFlags,
coverLastModified = anime.coverLastModified,
dateAdded = anime.dateAdded,
animeId = anime.id!!,
updateStrategy = anime.updateStrategy.let(UpdateStrategyColumnAdapter::encode),
)
}
return anime.id
}
/**
* Fetches anime information
*
@ -388,12 +860,143 @@ class BackupRestorer(
tracks: List<AnimeTrack>,
backupCategories: List<BackupCategory>,
): Anime {
val fetchedAnime = backupManager.restoreNewAnime(anime)
backupManager.restoreEpisodes(fetchedAnime, episodes)
val fetchedAnime = restoreNewAnime(anime)
restoreEpisodes(fetchedAnime, episodes)
restoreExtras(fetchedAnime, categories, history, tracks, backupCategories)
return fetchedAnime
}
private suspend fun restoreEpisodes(
anime: Anime,
episodes: List<tachiyomi.domain.items.episode.model.Episode>,
) {
val dbEpisodes = animeHandler.awaitList { episodesQueries.getEpisodesByAnimeId(anime.id!!) }
val processed = episodes.map { episode ->
var updatedEpisode = episode
val dbEpisode = dbEpisodes.find { it.url == updatedEpisode.url }
if (dbEpisode != null) {
updatedEpisode = updatedEpisode.copy(id = dbEpisode._id)
updatedEpisode = updatedEpisode.copyFrom(dbEpisode)
if (dbEpisode.seen && !updatedEpisode.seen) {
updatedEpisode = updatedEpisode.copy(
seen = true,
lastSecondSeen = dbEpisode.last_second_seen,
)
} else if (updatedEpisode.lastSecondSeen == 0L && dbEpisode.last_second_seen != 0L) {
updatedEpisode = updatedEpisode.copy(
lastSecondSeen = dbEpisode.last_second_seen,
)
}
if (!updatedEpisode.bookmark && dbEpisode.bookmark) {
updatedEpisode = updatedEpisode.copy(bookmark = true)
}
}
updatedEpisode.copy(animeId = anime.id ?: -1)
}
val newEpisodes = processed.groupBy { it.id > 0 }
newEpisodes[true]?.let { updateKnownEpisodes(it) }
newEpisodes[false]?.let { insertEpisodes(it) }
}
/**
* Inserts list of episodes
*/
private suspend fun insertEpisodes(episodes: List<tachiyomi.domain.items.episode.model.Episode>) {
animeHandler.await(true) {
episodes.forEach { episode ->
episodesQueries.insert(
episode.animeId,
episode.url,
episode.name,
episode.scanlator,
episode.seen,
episode.bookmark,
episode.lastSecondSeen,
episode.totalSeconds,
episode.episodeNumber,
episode.sourceOrder,
episode.dateFetch,
episode.dateUpload,
)
}
}
}
/**
* Updates a list of episodes with known database ids
*/
private suspend fun updateKnownEpisodes(
episodes: List<tachiyomi.domain.items.episode.model.Episode>,
) {
animeHandler.await(true) {
episodes.forEach { episode ->
episodesQueries.update(
animeId = null,
url = null,
name = null,
scanlator = null,
seen = episode.seen,
bookmark = episode.bookmark,
lastSecondSeen = episode.lastSecondSeen,
totalSeconds = episode.totalSeconds,
episodeNumber = null,
sourceOrder = null,
dateFetch = null,
dateUpload = null,
episodeId = episode.id,
)
}
}
}
/**
* Fetches anime information
*
* @param anime anime that needs updating
* @return Updated anime info.
*/
private suspend fun restoreNewAnime(anime: Anime): Anime {
return anime.copy(
initialized = anime.description != null,
id = insertAnime(anime),
)
}
/**
* Inserts anime and returns id
*
* @return id of [Anime], null if not found
*/
private suspend fun insertAnime(anime: Anime): Long {
return animeHandler.awaitOneExecutable(true) {
animesQueries.insert(
source = anime.source,
url = anime.url,
artist = anime.artist,
author = anime.author,
description = anime.description,
genre = anime.genre,
title = anime.title,
status = anime.status.toLong(),
thumbnailUrl = anime.thumbnailUrl,
favorite = anime.favorite,
lastUpdate = anime.lastUpdate,
nextUpdate = 0L,
calculateInterval = 0L,
initialized = anime.initialized,
viewerFlags = anime.viewerFlags,
episodeFlags = anime.episodeFlags,
coverLastModified = anime.coverLastModified,
dateAdded = anime.dateAdded,
updateStrategy = anime.updateStrategy,
)
animesQueries.selectLastInsertedRowId()
}
}
private suspend fun restoreNewAnime(
backupAnime: Anime,
episodes: List<Episode>,
@ -402,7 +1005,7 @@ class BackupRestorer(
tracks: List<AnimeTrack>,
backupCategories: List<BackupCategory>,
): Anime {
backupManager.restoreEpisodes(backupAnime, episodes)
restoreEpisodes(backupAnime, episodes)
restoreExtras(backupAnime, categories, history, tracks, backupCategories)
return backupAnime
}
@ -414,9 +1017,186 @@ class BackupRestorer(
tracks: List<AnimeTrack>,
backupCategories: List<BackupCategory>,
) {
backupManager.restoreAnimeCategories(anime, categories, backupCategories)
backupManager.restoreAnimeHistory(history)
backupManager.restoreAnimeTracking(anime, tracks)
restoreAnimeCategories(anime, categories, backupCategories)
restoreAnimeHistory(history)
restoreAnimeTracking(anime, tracks)
}
/**
* Restores the categories an anime is in.
*
* @param anime the anime whose categories have to be restored.
* @param categories the categories to restore.
*/
private suspend fun restoreAnimeCategories(
anime: Anime,
categories: List<Int>,
backupCategories: List<BackupCategory>,
) {
val dbCategories = getAnimeCategories.await()
val animeCategoriesToUpdate = mutableListOf<Pair<Long, Long>>()
categories.forEach { backupCategoryOrder ->
backupCategories.firstOrNull {
it.order == backupCategoryOrder.toLong()
}?.let { backupCategory ->
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
animeCategoriesToUpdate.add(Pair(anime.id, dbCategory.id))
}
}
}
// Update database
if (animeCategoriesToUpdate.isNotEmpty()) {
animeHandler.await(true) {
animes_categoriesQueries.deleteAnimeCategoryByAnimeId(anime.id)
animeCategoriesToUpdate.forEach { (animeId, categoryId) ->
animes_categoriesQueries.insert(animeId, categoryId)
}
}
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
private suspend fun restoreAnimeHistory(history: List<BackupAnimeHistory>) {
// List containing history to be updated
val toUpdate = mutableListOf<AnimeHistoryUpdate>()
for ((url, lastSeen) in history) {
var dbHistory = animeHandler.awaitOneOrNull {
animehistoryQueries.getHistoryByEpisodeUrl(
url,
)
}
// Check if history already in database and update
if (dbHistory != null) {
dbHistory = dbHistory.copy(
last_seen = Date(max(lastSeen, dbHistory.last_seen?.time ?: 0L)),
)
toUpdate.add(
AnimeHistoryUpdate(
episodeId = dbHistory.episode_id,
seenAt = dbHistory.last_seen!!,
),
)
} else {
// If not in database create
animeHandler
.awaitOneOrNull { episodesQueries.getEpisodeByUrl(url) }
?.let {
toUpdate.add(
AnimeHistoryUpdate(
episodeId = it._id,
seenAt = Date(lastSeen),
),
)
}
}
}
animeHandler.await(true) {
toUpdate.forEach { payload ->
animehistoryQueries.upsert(
payload.episodeId,
payload.seenAt,
)
}
}
}
/**
* Restores the sync of a manga.
*
* @param anime the anime whose sync have to be restored.
* @param tracks the track list to restore.
*/
private suspend fun restoreAnimeTracking(
anime: Anime,
tracks: List<tachiyomi.domain.track.anime.model.AnimeTrack>,
) {
// Get tracks from database
val dbTracks = animeHandler.awaitList { anime_syncQueries.getTracksByAnimeId(anime.id) }
val toUpdate = mutableListOf<Anime_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.anime.model.AnimeTrack>()
tracks
// Fix foreign keys with the current manga id
.map { it.copy(animeId = anime.id) }
.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(
last_episode_seen = max(
dbTrack.last_episode_seen,
track.lastEpisodeSeen,
),
)
isInDatabase = true
toUpdate.add(temp)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database
if (toUpdate.isNotEmpty()) {
animeHandler.await(true) {
toUpdate.forEach { track ->
anime_syncQueries.update(
track.anime_id,
track.sync_id,
track.remote_id,
track.library_id,
track.title,
track.last_episode_seen,
track.total_episodes,
track.status,
track.score,
track.remote_url,
track.start_date,
track.finish_date,
track._id,
)
}
}
}
if (toInsert.isNotEmpty()) {
animeHandler.await(true) {
toInsert.forEach { track ->
anime_syncQueries.insert(
track.animeId,
track.syncId,
track.remoteId,
track.libraryId,
track.title,
track.lastEpisodeSeen,
track.totalEpisodes,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
)
}
}
}
}
private fun restorePreferences(
@ -464,6 +1244,9 @@ class BackupRestorer(
val sharedPrefs = context.getSharedPreferences(it.name, 0x0)
restorePreferences(it.prefs, sharedPrefs)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.extension_settings), context.getString(R.string.restoring_backup))
}
private fun restoreExtensions(extensions: List<BackupExtension>) {

View file

@ -186,7 +186,40 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
.distinctBy { it.anime.id }
}
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val skippedUpdates = mutableListOf<Pair<Anime, String?>>()
val fetchWindow = animeFetchInterval.getWindow(ZonedDateTime.now())
animeToUpdate = listToUpdate
.filter {
when {
it.anime.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> {
skippedUpdates.add(it.anime to context.getString(R.string.skipped_reason_not_always_update))
false
}
ENTRY_NON_COMPLETED in restrictions && it.anime.status.toInt() == SAnime.COMPLETED -> {
skippedUpdates.add(it.anime to context.getString(R.string.skipped_reason_completed))
false
}
ENTRY_HAS_UNVIEWED in restrictions && it.unseenCount != 0L -> {
skippedUpdates.add(it.anime to context.getString(R.string.skipped_reason_not_caught_up))
false
}
ENTRY_NON_VIEWED in restrictions && it.totalEpisodes > 0L && !it.hasStarted -> {
skippedUpdates.add(it.anime to context.getString(R.string.skipped_reason_not_started))
false
}
ENTRY_OUTSIDE_RELEASE_PERIOD in restrictions && it.anime.nextUpdate > fetchWindow.second -> {
skippedUpdates.add(it.anime to context.getString(R.string.skipped_reason_not_in_release_period))
false
}
else -> true
}
}
.sortedBy { it.anime.title }
// Warn when excessively checking a single source
@ -197,6 +230,17 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
if (maxUpdatesFromSource > ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
notifier.showQueueSizeWarningNotification()
}
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)
}
}
/**
@ -212,10 +256,8 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val progressCount = AtomicInteger(0)
val currentlyUpdatingAnime = CopyOnWriteArrayList<Anime>()
val newUpdates = CopyOnWriteArrayList<Pair<Anime, Array<Episode>>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = animeFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
@ -237,79 +279,30 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
progressCount,
anime,
) {
when {
anime.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(
anime to context.getString(
R.string.skipped_reason_not_always_update,
),
)
try {
val newChapters = updateAnime(anime, fetchWindow)
.sortedByDescending { it.sourceOrder }
ENTRY_NON_COMPLETED in restrictions && anime.status.toInt() == SAnime.COMPLETED ->
skippedUpdates.add(
anime to context.getString(
R.string.skipped_reason_completed,
),
)
ENTRY_HAS_UNVIEWED in restrictions && libraryAnime.unseenCount != 0L ->
skippedUpdates.add(
anime to context.getString(
R.string.skipped_reason_not_caught_up,
),
)
ENTRY_NON_VIEWED in restrictions && libraryAnime.totalEpisodes > 0L && !libraryAnime.hasStarted ->
skippedUpdates.add(
anime to context.getString(
R.string.skipped_reason_not_started,
),
)
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, fetchWindow)
.sortedByDescending { it.sourceOrder }
if (newEpisodes.isNotEmpty()) {
val categoryIds = getCategories.await(anime.id).map { it.id }
if (anime.shouldDownloadNewEpisodes(
categoryIds,
downloadPreferences,
)
) {
downloadEpisodes(anime, newEpisodes)
hasDownloads.set(true)
}
libraryPreferences.newAnimeUpdatesCount().getAndSet { it + newEpisodes.size }
// Convert to the anime that contains new chapters
newUpdates.add(
anime to newEpisodes.toTypedArray(),
)
}
} catch (e: Throwable) {
val errorMessage = when (e) {
is NoEpisodesException -> context.getString(
R.string.no_episodes_error,
)
// failedUpdates will already have the source, don't need to copy it into the message
is AnimeSourceNotInstalledException -> context.getString(
R.string.loader_not_implemented_error,
)
else -> e.message
}
failedUpdates.add(anime to errorMessage)
if (newChapters.isNotEmpty()) {
val categoryIds = getCategories.await(anime.id).map { it.id }
if (anime.shouldDownloadNewEpisodes(categoryIds, downloadPreferences)) {
downloadEpisodes(anime, newChapters)
hasDownloads.set(true)
}
libraryPreferences.newAnimeUpdatesCount().getAndSet { it + newChapters.size }
// Convert to the manga that contains new chapters
newUpdates.add(anime to newChapters.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = when (e) {
is NoEpisodesException -> context.getString(R.string.no_chapters_error)
// failedUpdates will already have the source, don't need to copy it into the message
is AnimeSourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
else -> e.message
}
failedUpdates.add(anime to errorMessage)
}
if (libraryPreferences.autoUpdateTrackers().get()) {
@ -339,16 +332,6 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
errorFile.getUriCompat(context),
)
}
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)
}
}
private fun downloadEpisodes(anime: Anime, episodes: List<Episode>) {
@ -466,29 +449,27 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
completed: AtomicInteger,
anime: Anime,
block: suspend () -> Unit,
) {
coroutineScope {
ensureActive()
) = coroutineScope {
ensureActive()
updatingAnime.add(anime)
notifier.showProgressNotification(
updatingAnime,
completed.get(),
animeToUpdate.size,
)
updatingAnime.add(anime)
notifier.showProgressNotification(
updatingAnime,
completed.get(),
animeToUpdate.size,
)
block()
block()
ensureActive()
ensureActive()
updatingAnime.remove(anime)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingAnime,
completed.get(),
animeToUpdate.size,
)
}
updatingAnime.remove(anime)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingAnime,
completed.get(),
animeToUpdate.size,
)
}
/**

View file

@ -30,10 +30,14 @@ import tachiyomi.core.util.lang.launchUI
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.items.episode.model.Episode
import uy.kohesive.injekt.injectLazy
import java.text.NumberFormat
class AnimeLibraryUpdateNotifier(private val context: Context) {
private val preferences: SecurityPreferences by injectLazy()
private val percentFormatter = NumberFormat.getPercentInstance().apply {
maximumFractionDigits = 0
}
/**
* Pending intent of action that cancels the library update
@ -82,7 +86,7 @@ class AnimeLibraryUpdateNotifier(private val context: Context) {
} else {
val updatingText = anime.joinToString("\n") { it.title.chop(40) }
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_updating, current, total))
.setContentTitle(context.getString(R.string.notification_updating_progress, percentFormatter.format(current.toFloat() / total)))
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
}

View file

@ -186,7 +186,40 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
.distinctBy { it.manga.id }
}
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val skippedUpdates = mutableListOf<Pair<Manga, String?>>()
val fetchWindow = mangaFetchInterval.getWindow(ZonedDateTime.now())
mangaToUpdate = listToUpdate
.filter {
when {
it.manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_always_update))
false
}
ENTRY_NON_COMPLETED in restrictions && it.manga.status.toInt() == SManga.COMPLETED -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_completed))
false
}
ENTRY_HAS_UNVIEWED in restrictions && it.unreadCount != 0L -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_caught_up))
false
}
ENTRY_NON_VIEWED in restrictions && it.totalChapters > 0L && !it.hasStarted -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_started))
false
}
ENTRY_OUTSIDE_RELEASE_PERIOD in restrictions && it.manga.nextUpdate > fetchWindow.second -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_in_release_period))
false
}
else -> true
}
}
.sortedBy { it.manga.title }
// Warn when excessively checking a single source
@ -197,6 +230,17 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
notifier.showQueueSizeWarningNotification()
}
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)
}
}
/**
@ -212,10 +256,8 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val progressCount = AtomicInteger(0)
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = mangaFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
@ -237,79 +279,29 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
progressCount,
manga,
) {
when {
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(
manga to context.getString(
R.string.skipped_reason_not_always_update,
),
)
try {
val newChapters = updateManga(manga, fetchWindow)
.sortedByDescending { it.sourceOrder }
ENTRY_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
skippedUpdates.add(
manga to context.getString(
R.string.skipped_reason_completed,
),
)
ENTRY_HAS_UNVIEWED in restrictions && libraryManga.unreadCount != 0L ->
skippedUpdates.add(
manga to context.getString(
R.string.skipped_reason_not_caught_up,
),
)
ENTRY_NON_VIEWED in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
skippedUpdates.add(
manga to context.getString(
R.string.skipped_reason_not_started,
),
)
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, fetchWindow)
.sortedByDescending { it.sourceOrder }
if (newChapters.isNotEmpty()) {
val categoryIds = getCategories.await(manga.id).map { it.id }
if (manga.shouldDownloadNewChapters(
categoryIds,
downloadPreferences,
)
) {
downloadChapters(manga, newChapters)
hasDownloads.set(true)
}
libraryPreferences.newMangaUpdatesCount().getAndSet { it + newChapters.size }
// Convert to the manga that contains new chapters
newUpdates.add(
manga to newChapters.toTypedArray(),
)
}
} catch (e: Throwable) {
val errorMessage = when (e) {
is NoChaptersException -> context.getString(
R.string.no_chapters_error,
)
// failedUpdates will already have the source, don't need to copy it into the message
is SourceNotInstalledException -> context.getString(
R.string.loader_not_implemented_error,
)
else -> e.message
}
failedUpdates.add(manga to errorMessage)
if (newChapters.isNotEmpty()) {
val categoryIds = getCategories.await(manga.id).map { it.id }
if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) {
downloadChapters(manga, newChapters)
hasDownloads.set(true)
}
libraryPreferences.newMangaUpdatesCount().getAndSet { it + newChapters.size }
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = when (e) {
is NoChaptersException -> context.getString(R.string.no_chapters_error)
// failedUpdates will already have the source, don't need to copy it into the message
is SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
else -> e.message
}
failedUpdates.add(manga to errorMessage)
}
if (libraryPreferences.autoUpdateTrackers().get()) {
@ -339,16 +331,6 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
errorFile.getUriCompat(context),
)
}
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)
}
}
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
@ -466,29 +448,27 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
completed: AtomicInteger,
manga: Manga,
block: suspend () -> Unit,
) {
coroutineScope {
ensureActive()
) = coroutineScope {
ensureActive()
updatingManga.add(manga)
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
updatingManga.add(manga)
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
block()
block()
ensureActive()
ensureActive()
updatingManga.remove(manga)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
}
updatingManga.remove(manga)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
}
/**

View file

@ -30,10 +30,14 @@ import tachiyomi.core.util.lang.launchUI
import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.items.chapter.model.Chapter
import uy.kohesive.injekt.injectLazy
import java.text.NumberFormat
class MangaLibraryUpdateNotifier(private val context: Context) {
private val preferences: SecurityPreferences by injectLazy()
private val percentFormatter = NumberFormat.getPercentInstance().apply {
maximumFractionDigits = 0
}
/**
* Pending intent of action that cancels the library update
@ -82,7 +86,7 @@ class MangaLibraryUpdateNotifier(private val context: Context) {
} else {
val updatingText = manga.joinToString("\n") { it.title.chop(40) }
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_updating, current, total))
.setContentTitle(context.getString(R.string.notification_updating_progress, percentFormatter.format(current.toFloat() / total)))
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
}

View file

@ -173,19 +173,12 @@ sealed class Image(
}
sealed interface Location {
data class Pictures private constructor(val relativePath: String) : Location {
companion object {
fun create(relativePath: String = ""): Pictures {
return Pictures(relativePath)
}
}
}
data class Pictures(val relativePath: String) : Location
data object Cache : Location
fun directory(context: Context): File {
return when (this) {
Cache -> context.cacheImageDir
is Pictures -> {
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
@ -199,6 +192,7 @@ sealed interface Location {
}
file
}
Cache -> context.cacheImageDir
}
}
}

View file

@ -8,6 +8,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
@ -104,7 +105,7 @@ class PackageInstallerInstallerAnime(private val service: Service) : InstallerAn
}
init {
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
ContextCompat.registerReceiver(service, packageActionReceiver, IntentFilter(INSTALL_ACTION), ContextCompat.RECEIVER_EXPORTED)
}
}

View file

@ -272,7 +272,7 @@ internal class AnimeExtensionInstaller(private val context: Context) {
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(this, filter)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**

View file

@ -8,6 +8,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
@ -104,7 +105,7 @@ class PackageInstallerInstallerManga(private val service: Service) : InstallerMa
}
init {
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
ContextCompat.registerReceiver(service, packageActionReceiver, IntentFilter(INSTALL_ACTION), ContextCompat.RECEIVER_EXPORTED)
}
}

View file

@ -272,7 +272,7 @@ internal class MangaExtensionInstaller(private val context: Context) {
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(this, filter)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**

View file

@ -11,6 +11,8 @@ import uy.kohesive.injekt.api.get
fun AnimeSource.icon(): Drawable? = Injekt.get<AnimeExtensionManager>().getAppIconForSource(this.id)
fun AnimeSource.getPreferenceKey(): String = "source_$id"
fun AnimeSource.toStubSource(): StubAnimeSource = StubAnimeSource(id = id, lang = lang, name = name)
fun AnimeSource.getNameForAnimeInfo(): String {

View file

@ -11,6 +11,8 @@ import uy.kohesive.injekt.api.get
fun MangaSource.icon(): Drawable? = Injekt.get<MangaExtensionManager>().getAppIconForSource(this.id)
fun MangaSource.getPreferenceKey(): String = "source_$id"
fun MangaSource.toStubSource(): StubMangaSource = StubMangaSource(id = id, lang = lang, name = name)
fun MangaSource.getNameForMangaInfo(): String {

View file

@ -88,6 +88,15 @@ class AnimeCategoryScreenModel(
}
}
fun sortAlphabetically() {
coroutineScope.launch {
when (reorderCategory.sortAlphabetically()) {
is ReorderAnimeCategory.Result.InternalError -> _events.send(AnimeCategoryEvent.InternalError)
else -> {}
}
}
}
fun moveUp(category: Category) {
coroutineScope.launch {
when (reorderCategory.moveUp(category)) {
@ -142,6 +151,7 @@ class AnimeCategoryScreenModel(
sealed interface AnimeCategoryDialog {
data object Create : AnimeCategoryDialog
data object SortAlphabetically : AnimeCategoryDialog
data class Rename(val category: Category) : AnimeCategoryDialog
data class Delete(val category: Category) : AnimeCategoryDialog
}

View file

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.ui.category.anime
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
@ -11,6 +14,8 @@ import eu.kanade.presentation.category.AnimeCategoryScreen
import eu.kanade.presentation.category.components.CategoryCreateDialog
import eu.kanade.presentation.category.components.CategoryDeleteDialog
import eu.kanade.presentation.category.components.CategoryRenameDialog
import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent
import eu.kanade.tachiyomi.R
import tachiyomi.presentation.core.screens.LoadingScreen
@ -25,6 +30,14 @@ fun Screen.animeCategoryTab(): TabContent {
return TabContent(
titleRes = R.string.label_anime,
searchEnabled = false,
actions =
listOf(
AppBar.Action(
title = stringResource(R.string.action_sort),
icon = Icons.Outlined.SortByAlpha,
onClick = { screenModel.showDialog(AnimeCategoryDialog.SortAlphabetically) },
),
),
content = { contentPadding, _ ->
if (state is AnimeCategoryScreenState.Loading) {
LoadingScreen()
@ -66,6 +79,12 @@ fun Screen.animeCategoryTab(): TabContent {
category = dialog.category,
)
}
is AnimeCategoryDialog.SortAlphabetically -> {
CategorySortAlphabeticallyDialog(
onDismissRequest = screenModel::dismissDialog,
onSort = { screenModel.sortAlphabetically() },
)
}
}
}
},

View file

@ -88,6 +88,15 @@ class MangaCategoryScreenModel(
}
}
fun sortAlphabetically() {
coroutineScope.launch {
when (reorderCategory.sortAlphabetically()) {
is ReorderMangaCategory.Result.InternalError -> _events.send(MangaCategoryEvent.InternalError)
else -> {}
}
}
}
fun moveUp(category: Category) {
coroutineScope.launch {
when (reorderCategory.moveUp(category)) {
@ -142,6 +151,7 @@ class MangaCategoryScreenModel(
sealed interface MangaCategoryDialog {
data object Create : MangaCategoryDialog
data object SortAlphabetically : MangaCategoryDialog
data class Rename(val category: Category) : MangaCategoryDialog
data class Delete(val category: Category) : MangaCategoryDialog
}

View file

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.ui.category.manga
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
@ -11,6 +14,8 @@ import eu.kanade.presentation.category.MangaCategoryScreen
import eu.kanade.presentation.category.components.CategoryCreateDialog
import eu.kanade.presentation.category.components.CategoryDeleteDialog
import eu.kanade.presentation.category.components.CategoryRenameDialog
import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent
import eu.kanade.tachiyomi.R
import tachiyomi.presentation.core.screens.LoadingScreen
@ -25,6 +30,13 @@ fun Screen.mangaCategoryTab(): TabContent {
return TabContent(
titleRes = R.string.label_manga,
searchEnabled = false,
actions = listOf(
AppBar.Action(
title = stringResource(R.string.action_sort),
icon = Icons.Outlined.SortByAlpha,
onClick = { screenModel.showDialog(MangaCategoryDialog.SortAlphabetically) },
),
),
content = { contentPadding, _ ->
if (state is MangaCategoryScreenState.Loading) {
@ -67,6 +79,12 @@ fun Screen.mangaCategoryTab(): TabContent {
category = dialog.category,
)
}
is MangaCategoryDialog.SortAlphabetically -> {
CategorySortAlphabeticallyDialog(
onDismissRequest = screenModel::dismissDialog,
onSort = { screenModel.sortAlphabetically() },
)
}
}
}
},

View file

@ -102,8 +102,8 @@ class AnimeCoverScreenModel(
imageSaver.save(
Image.Cover(
bitmap = bitmap,
name = anime.title,
location = if (temp) Location.Cache else Location.Pictures.create(),
name = "cover",
location = if (temp) Location.Cache else Location.Pictures(anime.title),
),
)
}

View file

@ -16,6 +16,7 @@ import eu.kanade.domain.entries.anime.model.downloadedFilter
import eu.kanade.domain.entries.anime.model.toSAnime
import eu.kanade.domain.items.episode.interactor.SetSeenStatus
import eu.kanade.domain.items.episode.interactor.SyncEpisodesWithSource
import eu.kanade.domain.track.anime.interactor.AddAnimeTracks
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.entries.DownloadAction
@ -103,6 +104,7 @@ class AnimeScreenModel(
private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get(),
private val getCategories: GetAnimeCategories = Injekt.get(),
private val getTracks: GetAnimeTracks = Injekt.get(),
private val addTracks: AddAnimeTracks = Injekt.get(),
private val setAnimeCategories: SetAnimeCategories = Injekt.get(),
private val animeRepository: AnimeRepository = Injekt.get(),
internal val setAnimeViewerFlags: SetAnimeViewerFlags = Injekt.get(),
@ -333,24 +335,7 @@ class AnimeScreenModel(
}
// Finally match with enhanced tracking when available
val source = state.source
state.trackItems
.map { it.tracker }
.filterIsInstance<EnhancedAnimeTracker>()
.filter { it.accept(source) }
.forEach { service ->
launchIO {
try {
service.match(anime)?.let { track ->
(service as AnimeTracker).register(track, animeId)
}
} catch (e: Exception) {
logcat(LogPriority.WARN, e) {
"Could not match anime: ${anime.title} with service $service"
}
}
}
}
addTracks.bindEnhancedTracks(anime, state.source)
if (autoOpenTrack) {
showTrackDialog()
}

View file

@ -102,8 +102,8 @@ class MangaCoverScreenModel(
imageSaver.save(
Image.Cover(
bitmap = bitmap,
name = manga.title,
location = if (temp) Location.Cache else Location.Pictures.create(),
name = "cover",
location = if (temp) Location.Cache else Location.Pictures(manga.title),
),
)
}

View file

@ -15,6 +15,7 @@ import eu.kanade.domain.entries.manga.model.downloadedFilter
import eu.kanade.domain.entries.manga.model.toSManga
import eu.kanade.domain.items.chapter.interactor.SetReadStatus
import eu.kanade.domain.items.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.track.manga.interactor.AddMangaTracks
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.entries.DownloadAction
@ -99,6 +100,7 @@ class MangaScreenModel(
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val getCategories: GetMangaCategories = Injekt.get(),
private val getTracks: GetMangaTracks = Injekt.get(),
private val addTracks: AddMangaTracks = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val mangaRepository: MangaRepository = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
@ -329,24 +331,7 @@ class MangaScreenModel(
}
// Finally match with enhanced tracking when available
val source = state.source
state.trackItems
.map { it.tracker }
.filterIsInstance<EnhancedMangaTracker>()
.filter { it.accept(source) }
.forEach { service ->
launchIO {
try {
service.match(manga)?.let { track ->
(service as MangaTracker).register(track, mangaId)
}
} catch (e: Exception) {
logcat(LogPriority.WARN, e) {
"Could not match manga: ${manga.title} with service $service"
}
}
}
}
addTracks.bindEnhancedTracks(manga, state.source)
if (autoOpenTrack) {
showTrackDialog()
}

View file

@ -170,6 +170,7 @@ class MainActivity : BaseActivity() {
}
// Draw edge-to-edge
// TODO: replace with ComponentActivity#enableEdgeToEdge
WindowCompat.setDecorFitsSystemWindows(window, false)
setComposeContent {

View file

@ -498,7 +498,7 @@ class PlayerViewModel @JvmOverloads constructor(
image = Image.Page(
inputStream = imageStream,
name = filename,
location = Location.Pictures.create(relativePath),
location = Location.Pictures(relativePath),
),
)
notifier.onComplete(uri)

View file

@ -27,7 +27,6 @@ import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.StencilPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
@ -425,8 +424,8 @@ class ReaderViewModel @JvmOverloads constructor(
* [page]'s chapter is different from the currently active.
*/
fun onPageSelected(page: ReaderPage) {
// InsertPage and StencilPage doesn't change page progress
if (page is InsertPage || page is StencilPage) {
// InsertPage doesn't change page progress
if (page is InsertPage) {
return
}
@ -766,23 +765,14 @@ class ReaderViewModel @JvmOverloads constructor(
val filename = generateFilename(manga, page)
// Pictures directory.
val relativePath = if (readerPreferences.folderPerManga().get()) {
DiskUtil.buildValidFilename(
manga.title,
)
} else {
""
}
// Copy file in background.
// Copy file in background
viewModelScope.launchNonCancellable {
try {
val uri = imageSaver.save(
image = Image.Page(
inputStream = page.stream!!,
name = filename,
location = Location.Pictures.create(relativePath),
location = Location.Pictures(DiskUtil.buildValidFilename(manga.title)),
),
)
withUIContext {

View file

@ -1,16 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.model
import java.io.InputStream
class StencilPage(
parent: ReaderPage,
stencilStream: () -> InputStream,
) : ReaderPage(parent.index, parent.url, parent.imageUrl) {
override var chapter: ReaderChapter = parent.chapter
init {
status = State.READY
stream = stencilStream
}
}

View file

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.reader.setting
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.getEnum
@ -39,12 +38,6 @@ class ReaderPreferences(
OrientationType.FREE.flagValue,
)
// TODO: Enable in release build when the feature is stable
fun longStripSplitWebtoon() = preferenceStore.getBoolean(
"pref_long_strip_split_webtoon",
!isReleaseBuildType,
)
fun webtoonDoubleTapZoomEnabled() = preferenceStore.getBoolean(
"pref_enable_double_tap_zoom_webtoon",
true,
@ -81,8 +74,6 @@ class ReaderPreferences(
ReaderHideThreshold.LOW,
)
fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false)
fun skipRead() = preferenceStore.getBoolean("skip_read", false)
fun skipFiltered() = preferenceStore.getBoolean("skip_filtered", true)

View file

@ -7,12 +7,10 @@ import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.StencilPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import tachiyomi.core.util.system.logcat
/**
* RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
@ -27,27 +25,6 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
var currentChapter: ReaderChapter? = null
fun onLongStripSplit(currentStrip: Any?, newStrips: List<StencilPage>) {
if (newStrips.isEmpty()) return
if (currentStrip is StencilPage) return
val placeAtIndex = items.indexOf(currentStrip) + 1
// Stop constantly adding split images
if (items.getOrNull(placeAtIndex) is StencilPage) return
val updatedItems = items.toMutableList()
updatedItems.addAll(placeAtIndex, newStrips)
updateItems(updatedItems)
logcat { "New adapter item count is $itemCount" }
}
fun cleanupSplitStrips() {
if (items.any { it is StencilPage }) {
val updatedItems = items.filterNot { it is StencilPage }
updateItems(updatedItems)
}
}
/**
* Context that has been wrapped to use the correct theme values based on the
* current app theme and reader background color

View file

@ -32,11 +32,6 @@ class WebtoonConfig(
var sidePadding = 0
private set
var longStripSplit = false
private set
var longStripSplitChangedListener: ((Boolean) -> Unit)? = null
var doubleTapZoom = true
private set
@ -67,15 +62,6 @@ class WebtoonConfig(
readerPreferences.dualPageInvertWebtoon()
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.longStripSplitWebtoon()
.register(
{ longStripSplit = it },
{
imagePropertyChangedListener?.invoke()
longStripSplitChangedListener?.invoke(it)
},
)
readerPreferences.webtoonDoubleTapZoomEnabled()
.register(
{ doubleTapZoom = it },

View file

@ -13,7 +13,6 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.StencilPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
@ -24,12 +23,10 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.suspendCancellableCoroutine
import logcat.LogPriority
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.core.util.system.logcat
import java.io.BufferedInputStream
import java.io.InputStream
@ -221,40 +218,9 @@ class WebtoonPageHolder(
}
}
if (viewer.config.longStripSplit) {
if (page is StencilPage) {
return imageStream
}
val isStripSplitNeeded = ImageUtil.isStripSplitNeeded(imageStream)
if (isStripSplitNeeded) {
return onStripSplit(imageStream)
}
}
return imageStream
}
private fun onStripSplit(imageStream: BufferedInputStream): InputStream {
try {
// If we have reached this point [page] and its stream shouldn't be null
val page = page!!
val stream = page.stream!!
val splitData = ImageUtil.getSplitDataForStream(imageStream).toMutableList()
val currentSplitData = splitData.removeFirst()
val newPages = splitData.map {
StencilPage(page) { ImageUtil.splitStrip(it, stream) }
}
return ImageUtil.splitStrip(currentSplitData) { imageStream }
.also {
// Running [onLongStripSplit] first results in issues with splitting
viewer.onLongStripSplit(page, newPages)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to split image" }
return imageStream
}
}
/**
* Called when the page has an error.
*/

View file

@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.StencilPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
@ -151,12 +150,6 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
}
config.longStripSplitChangedListener = { enabled ->
if (!enabled) {
cleanupSplitStrips()
}
}
frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
frame.addView(recycler)
}
@ -205,11 +198,6 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
logcat { "onPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Skip preload on StencilPage
if (page is StencilPage) {
return
}
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
@ -359,15 +347,4 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
min(position + 3, adapter.itemCount - 1),
)
}
fun onLongStripSplit(currentStrip: Any?, newStrips: List<StencilPage>) {
activity.runOnUiThread {
// Need to insert on UI thread else images will go blank
adapter.onLongStripSplit(currentStrip, newStrips)
}
}
private fun cleanupSplitStrips() {
adapter.cleanupSplitStrips()
}
}

View file

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.util
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupManager
import eu.kanade.tachiyomi.data.backup.BackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
@ -29,7 +29,7 @@ object BackupUtil {
* Decode a potentially-gzipped backup.
*/
fun decodeBackup(context: Context, uri: Uri): Backup {
val backupManager = BackupManager(context)
val backupCreator = BackupCreator(context)
val backupStringSource = context.contentResolver.openInputStream(uri)!!.source().buffer()
@ -43,9 +43,9 @@ object BackupUtil {
}.use { it.readByteArray() }
return try {
backupManager.parser.decodeFromByteArray(BackupSerializer, backupString)
backupCreator.parser.decodeFromByteArray(BackupSerializer, backupString)
} catch (e: SerializationException) {
val fullBackup = backupManager.parser.decodeFromByteArray(
val fullBackup = backupCreator.parser.decodeFromByteArray(
FullBackupSerializer,
backupString,
)

View file

@ -283,48 +283,6 @@ object ImageUtil {
index + 1,
)}.jpg"
/**
* Check whether the image is a long Strip that needs splitting
* @return true if the image is not animated and it's height is greater than image width and screen height
*/
fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
if (isAnimatedAndSupported(imageStream)) return false
val options = extractImageOptions(imageStream)
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
}
/**
* Split the imageStream according to the provided splitData
*/
fun splitStrip(splitData: SplitData, streamFn: () -> InputStream): InputStream {
val bitmapRegionDecoder = getBitmapRegionDecoder(streamFn())
?: throw Exception("Failed to create new instance of BitmapRegionDecoder")
logcat {
"WebtoonSplit #${splitData.index} with topOffset=${splitData.topOffset} " +
"splitHeight=${splitData.splitHeight} bottomOffset=${splitData.bottomOffset}"
}
try {
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, null)
val outputStream = ByteArrayOutputStream()
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
return ByteArrayInputStream(outputStream.toByteArray())
} catch (e: Throwable) {
throw e
} finally {
bitmapRegionDecoder.recycle()
}
}
fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
return extractImageOptions(imageStream).splitData
}
private val BitmapFactory.Options.splitData
get(): List<SplitData> {
val imageHeight = outHeight

View file

@ -57,6 +57,27 @@ class ReorderAnimeCategory(
}
}
suspend fun sortAlphabetically() = withNonCancellableContext {
mutex.withLock {
val updates = categoryRepository.getAllAnimeCategories()
.sortedBy { category -> category.name }
.mapIndexed { index, category ->
CategoryUpdate(
id = category.id,
order = index.toLong(),
)
}
try {
categoryRepository.updatePartialAnimeCategories(updates)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
}
sealed interface Result {
data object Success : Result
data object Unchanged : Result

View file

@ -57,6 +57,27 @@ class ReorderMangaCategory(
}
}
suspend fun sortAlphabetically() = withNonCancellableContext {
mutex.withLock {
val updates = categoryRepository.getAllMangaCategories()
.sortedBy { category -> category.name }
.mapIndexed { index, category ->
CategoryUpdate(
id = category.id,
order = index.toLong(),
)
}
try {
categoryRepository.updatePartialMangaCategories(updates)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
}
sealed interface Result {
data object Success : Result
data object Unchanged : Result

View file

@ -24,5 +24,4 @@ kotlin.mpp.androidSourceSetLayoutVersion=2
android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
#android.nonFinalResIds=false
android.nonTransitiveRClass=false

View file

@ -1,5 +1,5 @@
[versions]
agp_version = "8.1.1"
agp_version = "8.1.2"
lifecycle_version = "2.6.2"
paging_version = "3.2.1"
@ -28,7 +28,7 @@ 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" }
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-rc01"
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-rc02"
test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha01"
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04"

View file

@ -4,7 +4,7 @@ compose-bom = "2023.09.00-alpha02"
accompanist = "0.33.1-alpha"
[libraries]
activity = "androidx.activity:activity-compose:1.7.2"
activity = "androidx.activity:activity-compose:1.8.0"
bom = { group = "dev.chrisbanes.compose", name = "compose-bom", version.ref = "compose-bom" }
foundation = { module = "androidx.compose.foundation:foundation" }
animation = { module = "androidx.compose.animation:animation" }

View file

@ -18,7 +18,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.5.0"
okio = "com.squareup.okio:okio:3.6.0"
conscrypt-android = "org.conscrypt:conscrypt-android:2.5.2"
@ -51,13 +51,12 @@ natural-comparator = "com.github.gpanther:java-nat-sort:natural-comparator-1.1"
richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" }
material = "com.google.android.material:material:1.9.0"
material = "com.google.android.material:material:1.10.0"
flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533"
photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.1.0"
compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0"
swipe = "me.saket.swipe:swipe:1.2.0"

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -92,6 +92,8 @@
<string name="action_move_category">Set categories</string>
<string name="delete_category_confirmation">Do you wish to delete the category \"%s\"?</string>
<string name="delete_category">Delete category</string>
<string name="action_sort_category">Sort categories</string>
<string name="sort_category_confirmation">Would you like to sort the categories alphabetically?</string>
<string name="action_edit_cover">Edit cover</string>
<string name="action_view_chapters">View chapters</string>
<string name="action_pause">Pause</string>
@ -328,7 +330,6 @@
<string name="pref_dual_page_invert_summary">If the placement of the split wide pages don\'t match reading direction</string>
<string name="pref_page_rotate">Rotate wide pages to fit</string>
<string name="pref_page_rotate_invert">Flip orientation of rotated wide pages</string>
<string name="pref_long_strip_split">Split tall images (BETA)</string>
<string name="pref_double_tap_zoom">Double tap to zoom</string>
<string name="pref_cutout_short">Show content in cutout area</string>
<string name="pref_page_transitions">Animate page transitions</string>
@ -364,8 +365,6 @@
<string name="tapping_inverted_both">Both</string>
<string name="pref_reader_actions">Actions</string>
<string name="pref_read_with_long_tap">Show on actions long tap</string>
<string name="pref_create_folder_per_manga">Save pages into separate folders</string>
<string name="pref_create_folder_per_manga_summary">Creates folders according to entries\' title</string>
<string name="pref_reader_theme">Background color</string>
<string name="white_background">White</string>
<string name="gray_background">Gray</string>
@ -819,7 +818,7 @@
<!-- Library update service notifications -->
<string name="notification_check_updates">Checking for new chapters</string>
<string name="notification_updating">Updating library… (%1$d/%2$d)</string>
<string name="notification_updating_progress">Updating library… (%s)</string>
<string name="notification_size_warning">Large updates harm sources and may lead to slower updates and also increased battery usage. Tap to learn more.</string>
<string name="notification_new_chapters">New chapters found</string>
<plurals name="notification_new_chapters_summary">

View file

@ -0,0 +1,7 @@
package tachiyomi.presentation.core.icons
/**
* Icons imported from https://simpleicons.org using
* https://github.com/DevSrSouza/svg-to-compose
*/
object CustomIcons

View file

@ -0,0 +1,76 @@
package tachiyomi.presentation.core.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val CustomIcons.Discord: ImageVector
get() {
if (_discord != null) {
return _discord!!
}
_discord = ImageVector.Builder(
name = "Discord", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f,
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero,
) {
moveTo(20.317f, 4.3698f)
arcToRelative(19.7913f, 19.7913f, 0.0f, false, false, -4.8851f, -1.5152f)
arcToRelative(0.0741f, 0.0741f, 0.0f, false, false, -0.0785f, 0.0371f)
curveToRelative(-0.211f, 0.3753f, -0.4447f, 0.8648f, -0.6083f, 1.2495f)
curveToRelative(-1.8447f, -0.2762f, -3.68f, -0.2762f, -5.4868f, 0.0f)
curveToRelative(-0.1636f, -0.3933f, -0.4058f, -0.8742f, -0.6177f, -1.2495f)
arcToRelative(0.077f, 0.077f, 0.0f, false, false, -0.0785f, -0.037f)
arcToRelative(19.7363f, 19.7363f, 0.0f, false, false, -4.8852f, 1.515f)
arcToRelative(0.0699f, 0.0699f, 0.0f, false, false, -0.0321f, 0.0277f)
curveTo(0.5334f, 9.0458f, -0.319f, 13.5799f, 0.0992f, 18.0578f)
arcToRelative(0.0824f, 0.0824f, 0.0f, false, false, 0.0312f, 0.0561f)
curveToRelative(2.0528f, 1.5076f, 4.0413f, 2.4228f, 5.9929f, 3.0294f)
arcToRelative(0.0777f, 0.0777f, 0.0f, false, false, 0.0842f, -0.0276f)
curveToRelative(0.4616f, -0.6304f, 0.8731f, -1.2952f, 1.226f, -1.9942f)
arcToRelative(0.076f, 0.076f, 0.0f, false, false, -0.0416f, -0.1057f)
curveToRelative(-0.6528f, -0.2476f, -1.2743f, -0.5495f, -1.8722f, -0.8923f)
arcToRelative(0.077f, 0.077f, 0.0f, false, true, -0.0076f, -0.1277f)
curveToRelative(0.1258f, -0.0943f, 0.2517f, -0.1923f, 0.3718f, -0.2914f)
arcToRelative(0.0743f, 0.0743f, 0.0f, false, true, 0.0776f, -0.0105f)
curveToRelative(3.9278f, 1.7933f, 8.18f, 1.7933f, 12.0614f, 0.0f)
arcToRelative(0.0739f, 0.0739f, 0.0f, false, true, 0.0785f, 0.0095f)
curveToRelative(0.1202f, 0.099f, 0.246f, 0.1981f, 0.3728f, 0.2924f)
arcToRelative(0.077f, 0.077f, 0.0f, false, true, -0.0066f, 0.1276f)
arcToRelative(12.2986f, 12.2986f, 0.0f, false, true, -1.873f, 0.8914f)
arcToRelative(0.0766f, 0.0766f, 0.0f, false, false, -0.0407f, 0.1067f)
curveToRelative(0.3604f, 0.698f, 0.7719f, 1.3628f, 1.225f, 1.9932f)
arcToRelative(0.076f, 0.076f, 0.0f, false, false, 0.0842f, 0.0286f)
curveToRelative(1.961f, -0.6067f, 3.9495f, -1.5219f, 6.0023f, -3.0294f)
arcToRelative(0.077f, 0.077f, 0.0f, false, false, 0.0313f, -0.0552f)
curveToRelative(0.5004f, -5.177f, -0.8382f, -9.6739f, -3.5485f, -13.6604f)
arcToRelative(0.061f, 0.061f, 0.0f, false, false, -0.0312f, -0.0286f)
close()
moveTo(8.02f, 15.3312f)
curveToRelative(-1.1825f, 0.0f, -2.1569f, -1.0857f, -2.1569f, -2.419f)
curveToRelative(0.0f, -1.3332f, 0.9555f, -2.4189f, 2.157f, -2.4189f)
curveToRelative(1.2108f, 0.0f, 2.1757f, 1.0952f, 2.1568f, 2.419f)
curveToRelative(0.0f, 1.3332f, -0.9555f, 2.4189f, -2.1569f, 2.4189f)
close()
moveTo(15.9948f, 15.3312f)
curveToRelative(-1.1825f, 0.0f, -2.1569f, -1.0857f, -2.1569f, -2.419f)
curveToRelative(0.0f, -1.3332f, 0.9554f, -2.4189f, 2.1569f, -2.4189f)
curveToRelative(1.2108f, 0.0f, 2.1757f, 1.0952f, 2.1568f, 2.419f)
curveToRelative(0.0f, 1.3332f, -0.946f, 2.4189f, -2.1568f, 2.4189f)
close()
}
}
.build()
return _discord!!
}
private var _discord: ImageVector? = null

View file

@ -0,0 +1,59 @@
package tachiyomi.presentation.core.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val CustomIcons.Github: ImageVector
get() {
if (_github != null) {
return _github!!
}
_github = Builder(
name = "Github", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f,
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero,
) {
moveTo(12.0f, 0.297f)
curveToRelative(-6.63f, 0.0f, -12.0f, 5.373f, -12.0f, 12.0f)
curveToRelative(0.0f, 5.303f, 3.438f, 9.8f, 8.205f, 11.385f)
curveToRelative(0.6f, 0.113f, 0.82f, -0.258f, 0.82f, -0.577f)
curveToRelative(0.0f, -0.285f, -0.01f, -1.04f, -0.015f, -2.04f)
curveToRelative(-3.338f, 0.724f, -4.042f, -1.61f, -4.042f, -1.61f)
curveTo(4.422f, 18.07f, 3.633f, 17.7f, 3.633f, 17.7f)
curveToRelative(-1.087f, -0.744f, 0.084f, -0.729f, 0.084f, -0.729f)
curveToRelative(1.205f, 0.084f, 1.838f, 1.236f, 1.838f, 1.236f)
curveToRelative(1.07f, 1.835f, 2.809f, 1.305f, 3.495f, 0.998f)
curveToRelative(0.108f, -0.776f, 0.417f, -1.305f, 0.76f, -1.605f)
curveToRelative(-2.665f, -0.3f, -5.466f, -1.332f, -5.466f, -5.93f)
curveToRelative(0.0f, -1.31f, 0.465f, -2.38f, 1.235f, -3.22f)
curveToRelative(-0.135f, -0.303f, -0.54f, -1.523f, 0.105f, -3.176f)
curveToRelative(0.0f, 0.0f, 1.005f, -0.322f, 3.3f, 1.23f)
curveToRelative(0.96f, -0.267f, 1.98f, -0.399f, 3.0f, -0.405f)
curveToRelative(1.02f, 0.006f, 2.04f, 0.138f, 3.0f, 0.405f)
curveToRelative(2.28f, -1.552f, 3.285f, -1.23f, 3.285f, -1.23f)
curveToRelative(0.645f, 1.653f, 0.24f, 2.873f, 0.12f, 3.176f)
curveToRelative(0.765f, 0.84f, 1.23f, 1.91f, 1.23f, 3.22f)
curveToRelative(0.0f, 4.61f, -2.805f, 5.625f, -5.475f, 5.92f)
curveToRelative(0.42f, 0.36f, 0.81f, 1.096f, 0.81f, 2.22f)
curveToRelative(0.0f, 1.606f, -0.015f, 2.896f, -0.015f, 3.286f)
curveToRelative(0.0f, 0.315f, 0.21f, 0.69f, 0.825f, 0.57f)
curveTo(20.565f, 22.092f, 24.0f, 17.592f, 24.0f, 12.297f)
curveToRelative(0.0f, -6.627f, -5.373f, -12.0f, -12.0f, -12.0f)
}
}
.build()
return _github!!
}
private var _github: ImageVector? = null

View file

@ -0,0 +1,85 @@
package tachiyomi.presentation.core.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val CustomIcons.Reddit: ImageVector
get() {
if (_reddit != null) {
return _reddit!!
}
_reddit = Builder(
name = "Reddit", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f,
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero,
) {
moveTo(12.0f, 0.0f)
arcTo(12.0f, 12.0f, 0.0f, false, false, 0.0f, 12.0f)
arcToRelative(12.0f, 12.0f, 0.0f, false, false, 12.0f, 12.0f)
arcToRelative(12.0f, 12.0f, 0.0f, false, false, 12.0f, -12.0f)
arcTo(12.0f, 12.0f, 0.0f, false, false, 12.0f, 0.0f)
close()
moveTo(17.01f, 4.744f)
curveToRelative(0.688f, 0.0f, 1.25f, 0.561f, 1.25f, 1.249f)
arcToRelative(1.25f, 1.25f, 0.0f, false, true, -2.498f, 0.056f)
lineToRelative(-2.597f, -0.547f)
lineToRelative(-0.8f, 3.747f)
curveToRelative(1.824f, 0.07f, 3.48f, 0.632f, 4.674f, 1.488f)
curveToRelative(0.308f, -0.309f, 0.73f, -0.491f, 1.207f, -0.491f)
curveToRelative(0.968f, 0.0f, 1.754f, 0.786f, 1.754f, 1.754f)
curveToRelative(0.0f, 0.716f, -0.435f, 1.333f, -1.01f, 1.614f)
arcToRelative(3.111f, 3.111f, 0.0f, false, true, 0.042f, 0.52f)
curveToRelative(0.0f, 2.694f, -3.13f, 4.87f, -7.004f, 4.87f)
curveToRelative(-3.874f, 0.0f, -7.004f, -2.176f, -7.004f, -4.87f)
curveToRelative(0.0f, -0.183f, 0.015f, -0.366f, 0.043f, -0.534f)
arcTo(1.748f, 1.748f, 0.0f, false, true, 4.028f, 12.0f)
curveToRelative(0.0f, -0.968f, 0.786f, -1.754f, 1.754f, -1.754f)
curveToRelative(0.463f, 0.0f, 0.898f, 0.196f, 1.207f, 0.49f)
curveToRelative(1.207f, -0.883f, 2.878f, -1.43f, 4.744f, -1.487f)
lineToRelative(0.885f, -4.182f)
arcToRelative(0.342f, 0.342f, 0.0f, false, true, 0.14f, -0.197f)
arcToRelative(0.35f, 0.35f, 0.0f, false, true, 0.238f, -0.042f)
lineToRelative(2.906f, 0.617f)
arcToRelative(1.214f, 1.214f, 0.0f, false, true, 1.108f, -0.701f)
close()
moveTo(9.25f, 12.0f)
curveTo(8.561f, 12.0f, 8.0f, 12.562f, 8.0f, 13.25f)
curveToRelative(0.0f, 0.687f, 0.561f, 1.248f, 1.25f, 1.248f)
curveToRelative(0.687f, 0.0f, 1.248f, -0.561f, 1.248f, -1.249f)
curveToRelative(0.0f, -0.688f, -0.561f, -1.249f, -1.249f, -1.249f)
close()
moveTo(14.75f, 12.0f)
curveToRelative(-0.687f, 0.0f, -1.248f, 0.561f, -1.248f, 1.25f)
curveToRelative(0.0f, 0.687f, 0.561f, 1.248f, 1.249f, 1.248f)
curveToRelative(0.688f, 0.0f, 1.249f, -0.561f, 1.249f, -1.249f)
curveToRelative(0.0f, -0.687f, -0.562f, -1.249f, -1.25f, -1.249f)
close()
moveTo(9.284f, 15.99f)
arcToRelative(0.327f, 0.327f, 0.0f, false, false, -0.231f, 0.094f)
arcToRelative(0.33f, 0.33f, 0.0f, false, false, 0.0f, 0.463f)
curveToRelative(0.842f, 0.842f, 2.484f, 0.913f, 2.961f, 0.913f)
curveToRelative(0.477f, 0.0f, 2.105f, -0.056f, 2.961f, -0.913f)
arcToRelative(0.361f, 0.361f, 0.0f, false, false, 0.029f, -0.463f)
arcToRelative(0.33f, 0.33f, 0.0f, false, false, -0.464f, 0.0f)
curveToRelative(-0.547f, 0.533f, -1.684f, 0.73f, -2.512f, 0.73f)
curveToRelative(-0.828f, 0.0f, -1.979f, -0.196f, -2.512f, -0.73f)
arcToRelative(0.326f, 0.326f, 0.0f, false, false, -0.232f, -0.095f)
close()
}
}
.build()
return _reddit!!
}
private var _reddit: ImageVector? = null