From 2b126f1ff56b63e470b48a04149e28c609f01148 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sat, 31 May 2025 21:03:39 +0600 Subject: [PATCH] Cleanup migrate manga dialog and related code (#2156) --- .../java/eu/kanade/domain/DomainModule.kt | 6 + .../source/service/SourcePreferences.kt | 10 +- .../ui/browse/migration/MigrationFlags.kt | 89 ----- .../browse/migration/search/MigrateDialog.kt | 312 ------------------ .../migration/search/MigrateSearchScreen.kt | 37 +-- .../MigrateSearchScreenDialogScreenModel.kt | 43 --- .../search/MigrateSearchScreenModel.kt | 2 +- ...Screen.kt => MigrateSourceSearchScreen.kt} | 21 +- .../source/browse/BrowseSourceScreen.kt | 16 +- .../source/browse/BrowseSourceScreenModel.kt | 2 +- .../source/globalsearch/SearchScreenModel.kt | 19 +- .../ui/history/HistoryScreenModel.kt | 6 +- .../kanade/tachiyomi/ui/history/HistoryTab.kt | 18 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 14 +- .../tachiyomi/ui/manga/MangaScreenModel.kt | 4 +- .../domain/migration/models/MigrationFlag.kt | 28 ++ .../migration/usecases/MigrateMangaUseCase.kt | 145 ++++++++ .../feature/common/utils/MigrationFlag.kt | 15 + .../migration/dialog/MigrateMangaDialog.kt | 167 ++++++++++ .../common/preference/AndroidPreference.kt | 27 +- .../preference/AndroidPreferenceStore.kt | 23 +- .../preference/InMemoryPreferenceStore.kt | 14 +- .../core/common/preference/PreferenceStore.kt | 13 +- .../library/service/LibraryPreferences.kt | 4 +- 24 files changed, 510 insertions(+), 525 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenDialogScreenModel.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/{SourceSearchScreen.kt => MigrateSourceSearchScreen.kt} (91%) create mode 100644 app/src/main/java/mihon/domain/migration/models/MigrationFlag.kt create mode 100644 app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt create mode 100644 app/src/main/java/mihon/feature/common/utils/MigrationFlag.kt create mode 100644 app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 16c0fd53e..9ecb4b44e 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -35,6 +35,7 @@ import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extensionrepo.service.ExtensionRepoService +import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.domain.upcoming.interactor.GetUpcomingManga import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.chapter.ChapterRepositoryImpl @@ -134,6 +135,11 @@ class DomainModule : InjektModule { addFactory { SetMangaCategories(get()) } addFactory { GetExcludedScanlators(get()) } addFactory { SetExcludedScanlators(get()) } + addFactory { + MigrateMangaUseCase( + get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), + ) + } addSingletonFactory { ReleaseServiceImpl(get(), get()) } addFactory { GetApplicationRelease(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index a84767aea..663c504db 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -2,6 +2,7 @@ package eu.kanade.domain.source.service import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.tachiyomi.util.system.LocaleHelper +import mihon.domain.migration.models.MigrationFlag import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.getEnum @@ -12,7 +13,7 @@ class SourcePreferences( private val preferenceStore: PreferenceStore, ) { - fun sourceDisplayMode() = preferenceStore.getObject( + fun sourceDisplayMode() = preferenceStore.getObjectFromString( "pref_display_mode_catalogue", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, @@ -58,4 +59,11 @@ class SourcePreferences( Preference.appStateKey("has_filters_toggle_state"), false, ) + + fun migrationFlags() = preferenceStore.getObjectFromInt( + key = "migrate_flags", + defaultValue = MigrationFlag.entries.toSet(), + serializer = { MigrationFlag.toBit(it) }, + deserializer = { value: Int -> MigrationFlag.fromBit(value) }, + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt deleted file mode 100644 index 99b5f1543..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt +++ /dev/null @@ -1,89 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.migration - -import dev.icerock.moko.resources.StringResource -import eu.kanade.domain.manga.model.hasCustomCover -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.download.DownloadCache -import tachiyomi.domain.manga.model.Manga -import tachiyomi.i18n.MR -import uy.kohesive.injekt.injectLazy - -data class MigrationFlag( - val flag: Int, - val isDefaultSelected: Boolean, - val titleId: StringResource, -) { - companion object { - fun create(flag: Int, defaultSelectionMap: Int, titleId: StringResource): MigrationFlag { - return MigrationFlag( - flag = flag, - isDefaultSelected = defaultSelectionMap and flag != 0, - titleId = titleId, - ) - } - } -} - -object MigrationFlags { - - private const val CHAPTERS = 0b00001 - private const val CATEGORIES = 0b00010 - private const val CUSTOM_COVER = 0b01000 - private const val DELETE_DOWNLOADED = 0b10000 - private const val NOTES = 0b100000 - - private val coverCache: CoverCache by injectLazy() - private val downloadCache: DownloadCache by injectLazy() - - fun hasChapters(value: Int): Boolean { - return value and CHAPTERS != 0 - } - - fun hasCategories(value: Int): Boolean { - return value and CATEGORIES != 0 - } - - fun hasCustomCover(value: Int): Boolean { - return value and CUSTOM_COVER != 0 - } - - fun hasDeleteDownloaded(value: Int): Boolean { - return value and DELETE_DOWNLOADED != 0 - } - - fun hasNotes(value: Int): Boolean { - return value and NOTES != 0 - } - - /** Returns information about applicable flags with default selections. */ - fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List { - val flags = mutableListOf() - flags += MigrationFlag.create(CHAPTERS, defaultSelectedBitMap, MR.strings.chapters) - flags += MigrationFlag.create(CATEGORIES, defaultSelectedBitMap, MR.strings.categories) - - if (manga != null) { - if (manga.hasCustomCover(coverCache)) { - flags += MigrationFlag.create(CUSTOM_COVER, defaultSelectedBitMap, MR.strings.custom_cover) - } - if (downloadCache.getDownloadCount(manga) > 0) { - flags += MigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, MR.strings.delete_downloaded) - } - if (manga.notes.isNotBlank()) { - flags += MigrationFlag.create(NOTES, defaultSelectedBitMap, MR.strings.action_notes) - } - } - return flags - } - - /** Returns a bit map of selected flags. */ - fun getSelectedFlagsBitMap( - selectedFlags: List, - flags: List, - ): Int { - return selectedFlags - .zip(flags) - .filter { (isSelected, _) -> isSelected } - .map { (_, flag) -> flag.flag } - .reduceOrNull { acc, mask -> acc or mask } ?: 0 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt deleted file mode 100644 index bf50d677d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt +++ /dev/null @@ -1,312 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.migration.search - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Modifier -import cafe.adriel.voyager.core.model.StateScreenModel -import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.domain.manga.model.hasCustomCover -import eu.kanade.domain.manga.model.toSManga -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.track.EnhancedTracker -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags -import kotlinx.coroutines.flow.update -import tachiyomi.core.common.preference.Preference -import tachiyomi.core.common.preference.PreferenceStore -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withUIContext -import tachiyomi.domain.category.interactor.GetCategories -import tachiyomi.domain.category.interactor.SetMangaCategories -import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId -import tachiyomi.domain.chapter.interactor.UpdateChapter -import tachiyomi.domain.chapter.model.toChapterUpdate -import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.manga.model.MangaUpdate -import tachiyomi.domain.source.service.SourceManager -import tachiyomi.domain.track.interactor.GetTracks -import tachiyomi.domain.track.interactor.InsertTrack -import tachiyomi.i18n.MR -import tachiyomi.presentation.core.components.LabeledCheckbox -import tachiyomi.presentation.core.components.material.padding -import tachiyomi.presentation.core.i18n.stringResource -import tachiyomi.presentation.core.screens.LoadingScreen -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.time.Instant - -@Composable -internal fun MigrateDialog( - oldManga: Manga, - newManga: Manga, - screenModel: MigrateDialogScreenModel, - onDismissRequest: () -> Unit, - onClickTitle: () -> Unit, - onPopScreen: () -> Unit, -) { - val scope = rememberCoroutineScope() - val state by screenModel.state.collectAsState() - - val flags = remember { MigrationFlags.getFlags(oldManga, screenModel.migrateFlags.get()) } - val selectedFlags = remember { flags.map { it.isDefaultSelected }.toMutableStateList() } - - if (state.isMigrating) { - LoadingScreen( - modifier = Modifier - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)), - ) - } else { - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Text(text = stringResource(MR.strings.migration_dialog_what_to_include)) - }, - text = { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - flags.forEachIndexed { index, flag -> - LabeledCheckbox( - label = stringResource(flag.titleId), - checked = selectedFlags[index], - onCheckedChange = { selectedFlags[index] = it }, - ) - } - } - }, - confirmButton = { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), - ) { - TextButton( - onClick = { - onDismissRequest() - onClickTitle() - }, - ) { - Text(text = stringResource(MR.strings.action_show_manga)) - } - - Spacer(modifier = Modifier.weight(1f)) - - TextButton( - onClick = { - scope.launchIO { - screenModel.migrateManga( - oldManga, - newManga, - false, - MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags), - ) - withUIContext { onPopScreen() } - } - }, - ) { - Text(text = stringResource(MR.strings.copy)) - } - TextButton( - onClick = { - scope.launchIO { - screenModel.migrateManga( - oldManga, - newManga, - true, - MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags), - ) - - withUIContext { onPopScreen() } - } - }, - ) { - Text(text = stringResource(MR.strings.migrate)) - } - } - }, - ) - } -} - -internal class MigrateDialogScreenModel( - private val sourceManager: SourceManager = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val updateManga: UpdateManga = Injekt.get(), - private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), - private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), - private val updateChapter: UpdateChapter = Injekt.get(), - private val getCategories: GetCategories = Injekt.get(), - private val setMangaCategories: SetMangaCategories = Injekt.get(), - private val getTracks: GetTracks = Injekt.get(), - private val insertTrack: InsertTrack = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val preferenceStore: PreferenceStore = Injekt.get(), -) : StateScreenModel(State()) { - - val migrateFlags: Preference by lazy { - preferenceStore.getInt("migrate_flags", Int.MAX_VALUE) - } - - private val enhancedServices by lazy { - Injekt.get().trackers.filterIsInstance() - } - - suspend fun migrateManga( - oldManga: Manga, - newManga: Manga, - replace: Boolean, - flags: Int, - ) { - migrateFlags.set(flags) - val source = sourceManager.get(newManga.source) ?: return - val prevSource = sourceManager.get(oldManga.source) - - mutableState.update { it.copy(isMigrating = true) } - - try { - val chapters = source.getChapterList(newManga.toSManga()) - - migrateMangaInternal( - oldSource = prevSource, - newSource = source, - oldManga = oldManga, - newManga = newManga, - sourceChapters = chapters, - replace = replace, - flags = flags, - ) - } catch (_: Throwable) { - // Explicitly stop if an error occurred; the dialog normally gets popped at the end - // anyway - mutableState.update { it.copy(isMigrating = false) } - } - } - - private suspend fun migrateMangaInternal( - oldSource: Source?, - newSource: Source, - oldManga: Manga, - newManga: Manga, - sourceChapters: List, - replace: Boolean, - flags: Int, - ) { - val migrateChapters = MigrationFlags.hasChapters(flags) - val migrateCategories = MigrationFlags.hasCategories(flags) - val migrateCustomCover = MigrationFlags.hasCustomCover(flags) - val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags) - val migrateNotes = MigrationFlags.hasNotes(flags) - - try { - syncChaptersWithSource.await(sourceChapters, newManga, newSource) - } catch (_: Exception) { - // Worst case, chapters won't be synced - } - - // Update chapters read, bookmark and dateFetch - if (migrateChapters) { - val prevMangaChapters = getChaptersByMangaId.await(oldManga.id) - val mangaChapters = getChaptersByMangaId.await(newManga.id) - - val maxChapterRead = prevMangaChapters - .filter { it.read } - .maxOfOrNull { it.chapterNumber } - - val updatedMangaChapters = mangaChapters.map { mangaChapter -> - var updatedChapter = mangaChapter - if (updatedChapter.isRecognizedNumber) { - val prevChapter = prevMangaChapters - .find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber } - - if (prevChapter != null) { - updatedChapter = updatedChapter.copy( - dateFetch = prevChapter.dateFetch, - bookmark = prevChapter.bookmark, - ) - } - - if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) { - updatedChapter = updatedChapter.copy(read = true) - } - } - - updatedChapter - } - - val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() } - updateChapter.awaitAll(chapterUpdates) - } - - // Update categories - if (migrateCategories) { - val categoryIds = getCategories.await(oldManga.id).map { it.id } - setMangaCategories.await(newManga.id, categoryIds) - } - - // Update track - getTracks.await(oldManga.id).mapNotNull { track -> - val updatedTrack = track.copy(mangaId = newManga.id) - - val service = enhancedServices - .firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) } - - if (service != null) { - service.migrateTrack(updatedTrack, newManga, newSource) - } else { - updatedTrack - } - } - .takeIf { it.isNotEmpty() } - ?.let { insertTrack.awaitAll(it) } - - // Delete downloaded - if (deleteDownloaded) { - if (oldSource != null) { - downloadManager.deleteManga(oldManga, oldSource) - } - } - - if (replace) { - updateManga.awaitUpdateFavorite(oldManga.id, favorite = false) - } - - // Update custom cover (recheck if custom cover exists) - if (migrateCustomCover && oldManga.hasCustomCover()) { - coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream()) - } - - updateManga.await( - MangaUpdate( - id = newManga.id, - favorite = true, - chapterFlags = oldManga.chapterFlags, - viewerFlags = oldManga.viewerFlags, - dateAdded = if (replace) oldManga.dateAdded else Instant.now().toEpochMilli(), - notes = if (migrateNotes) oldManga.notes else null, - ), - ) - } - - @Immutable - data class State( - val isMigrating: Boolean = false, - ) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt index fd0d4a0e8..2115574e5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt @@ -8,7 +8,9 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.browse.MigrateSearchScreen import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel import eu.kanade.tachiyomi.ui.manga.MangaScreen +import mihon.feature.migration.dialog.MigrateMangaDialog class MigrateSearchScreen(private val mangaId: Long) : Screen() { @@ -19,42 +21,35 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() { val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) } val state by screenModel.state.collectAsState() - val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) } - val dialogState by dialogScreenModel.state.collectAsState() - MigrateSearchScreen( state = state, - fromSourceId = dialogState.manga?.source, + fromSourceId = state.from?.source, navigateUp = navigator::pop, onChangeSearchQuery = screenModel::updateSearchQuery, onSearch = { screenModel.search() }, getManga = { screenModel.getManga(it) }, onChangeSearchFilter = screenModel::setSourceFilter, onToggleResults = screenModel::toggleFilterResults, - onClickSource = { - navigator.push(SourceSearchScreen(dialogState.manga!!, it.id, state.searchQuery)) - }, - onClickItem = { dialogScreenModel.setDialog(MigrateSearchScreenDialogScreenModel.Dialog.Migrate(it)) }, + onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) }, + onClickItem = { screenModel.setMigrateDialog(mangaId, it) }, onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, ) - when (val dialog = dialogState.dialog) { - is MigrateSearchScreenDialogScreenModel.Dialog.Migrate -> { - MigrateDialog( - oldManga = dialogState.manga!!, - newManga = dialog.manga, - screenModel = rememberScreenModel { MigrateDialogScreenModel() }, - onDismissRequest = { dialogScreenModel.setDialog(null) }, - onClickTitle = { - navigator.push(MangaScreen(dialog.manga.id, true)) - }, - onPopScreen = { + when (val dialog = state.dialog) { + is SearchScreenModel.Dialog.Migrate -> { + MigrateMangaDialog( + current = dialog.current, + target = dialog.target, + // Initiated from the context of [dialog.current] so we show [dialog.target]. + onClickTitle = { navigator.push(MangaScreen(dialog.target.id, true)) }, + onDismissRequest = { screenModel.clearDialog() }, + onComplete = { if (navigator.lastItem is MangaScreen) { val lastItem = navigator.lastItem navigator.popUntil { navigator.items.contains(lastItem) } - navigator.push(MangaScreen(dialog.manga.id)) + navigator.push(MangaScreen(dialog.target.id)) } else { - navigator.replace(MangaScreen(dialog.manga.id)) + navigator.replace(MangaScreen(dialog.target.id)) } }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenDialogScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenDialogScreenModel.kt deleted file mode 100644 index a053d45ba..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenDialogScreenModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.migration.search - -import androidx.compose.runtime.Immutable -import cafe.adriel.voyager.core.model.StateScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import tachiyomi.domain.manga.interactor.GetManga -import tachiyomi.domain.manga.model.Manga -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class MigrateSearchScreenDialogScreenModel( - val mangaId: Long, - getManga: GetManga = Injekt.get(), -) : StateScreenModel(State()) { - - init { - screenModelScope.launch { - val manga = getManga.await(mangaId)!! - - mutableState.update { - it.copy(manga = manga) - } - } - } - - fun setDialog(dialog: Dialog?) { - mutableState.update { - it.copy(dialog = dialog) - } - } - - @Immutable - data class State( - val manga: Manga? = null, - val dialog: Dialog? = null, - ) - - sealed interface Dialog { - data class Migrate(val manga: Manga) : Dialog - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt index b01212415..87f8a074d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt @@ -33,7 +33,7 @@ class MigrateSearchScreenModel( val manga = getManga.await(mangaId)!! mutableState.update { it.copy( - fromSourceId = manga.source, + from = manga, searchQuery = manga.title, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt index 58f4a91bb..18edbe6db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt @@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.ui.home.HomeScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen import kotlinx.coroutines.launch +import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.core.common.Constants import tachiyomi.domain.manga.model.Manga @@ -38,8 +39,8 @@ import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.source.local.LocalSource -data class SourceSearchScreen( - private val oldManga: Manga, +data class MigrateSourceSearchScreen( + private val currentManga: Manga, private val sourceId: Long, private val query: String?, ) : Screen() { @@ -82,7 +83,7 @@ data class SourceSearchScreen( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { paddingValues -> val openMigrateDialog: (Manga) -> Unit = { - screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(newManga = it, oldManga = oldManga)) + screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga)) } BrowseSourceContent( source = screenModel.source, @@ -120,17 +121,17 @@ data class SourceSearchScreen( ) } is BrowseSourceScreenModel.Dialog.Migrate -> { - MigrateDialog( - oldManga = oldManga, - newManga = dialog.newManga, - screenModel = rememberScreenModel { MigrateDialogScreenModel() }, + MigrateMangaDialog( + current = currentManga, + target = dialog.target, + // Initiated from the context of [currentManga] so we show [dialog.target]. + onClickTitle = { navigator.push(MangaScreen(dialog.target.id)) }, onDismissRequest = onDismissRequest, - onClickTitle = { navigator.push(MangaScreen(dialog.newManga.id)) }, - onPopScreen = { + onComplete = { scope.launch { navigator.popUntilRoot() HomeScreen.openTab(HomeScreen.Tab.Browse()) - navigator.push(MangaScreen(dialog.newManga.id)) + navigator.push(MangaScreen(dialog.target.id)) } }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt index 217b5b6f4..1153b3025 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt @@ -47,8 +47,6 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen -import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog -import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen @@ -56,6 +54,7 @@ import eu.kanade.tachiyomi.ui.webview.WebViewScreen import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow +import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.core.common.Constants import tachiyomi.core.common.util.lang.launchIO @@ -260,15 +259,12 @@ data class BrowseSourceScreen( } is BrowseSourceScreenModel.Dialog.Migrate -> { - MigrateDialog( - oldManga = dialog.oldManga, - newManga = dialog.newManga, - screenModel = MigrateDialogScreenModel(), + MigrateMangaDialog( + current = dialog.current, + target = dialog.target, + // Initiated from the context of [dialog.target] so we show [dialog.current]. + onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) }, onDismissRequest = onDismissRequest, - onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) }, - onPopScreen = { - onDismissRequest() - }, ) } is BrowseSourceScreenModel.Dialog.RemoveManga -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 017b77191..2cf676b14 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -341,7 +341,7 @@ class BrowseSourceScreenModel( val manga: Manga, val initialSelection: ImmutableList>, ) : Dialog - data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog + data class Migrate(val target: Manga, val current: Manga) : Dialog } @Immutable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt index 9d90411a3..08c6669ea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mihon.domain.manga.model.toDomainManga import tachiyomi.core.common.preference.toggle +import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.model.Manga @@ -201,18 +202,34 @@ abstract class SearchScreenModel( updateItems(newItems) } + fun setMigrateDialog(currentId: Long, target: Manga) { + screenModelScope.launchIO { + val current = getManga.await(currentId) ?: return@launchIO + mutableState.update { it.copy(dialog = Dialog.Migrate(target, current)) } + } + } + + fun clearDialog() { + mutableState.update { it.copy(dialog = null) } + } + @Immutable data class State( - val fromSourceId: Long? = null, + val from: Manga? = null, val searchQuery: String? = null, val sourceFilter: SourceFilter = SourceFilter.PinnedOnly, val onlyShowHasResults: Boolean = false, val items: PersistentMap = persistentMapOf(), + val dialog: Dialog? = null, ) { val progress: Int = items.count { it.value !is SearchItemResult.Loading } val total: Int = items.size val filteredItems = items.filter { (_, result) -> result.isVisible(onlyShowHasResults) } } + + sealed interface Dialog { + data class Migrate(val target: Manga, val current: Manga) : Dialog + } } enum class SourceFilter { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt index 515100bf6..c123a1ff1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt @@ -216,9 +216,9 @@ class HistoryScreenModel( } } - fun showMigrateDialog(currentManga: Manga, duplicate: Manga) { + fun showMigrateDialog(target: Manga, current: Manga) { mutableState.update { currentState -> - currentState.copy(dialog = Dialog.Migrate(newManga = currentManga, oldManga = duplicate)) + currentState.copy(dialog = Dialog.Migrate(target = target, current = current)) } } @@ -252,7 +252,7 @@ class HistoryScreenModel( val manga: Manga, val initialSelection: ImmutableList>, ) : Dialog - data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog + data class Migrate(val target: Manga, val current: Manga) : Dialog } sealed interface Event { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt index 14a7ccc00..8eeb86b34 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt @@ -23,8 +23,6 @@ import eu.kanade.presentation.history.components.HistoryDeleteDialog import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog -import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaScreen @@ -32,6 +30,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow +import mihon.feature.migration.dialog.MigrateMangaDialog import tachiyomi.core.common.i18n.stringResource import tachiyomi.domain.chapter.model.Chapter import tachiyomi.i18n.MR @@ -100,9 +99,7 @@ data object HistoryTab : Tab { DuplicateMangaDialog( duplicates = dialog.duplicates, onDismissRequest = onDismissRequest, - onConfirm = { - screenModel.addFavorite(dialog.manga) - }, + onConfirm = { screenModel.addFavorite(dialog.manga) }, onOpenManga = { navigator.push(MangaScreen(it.id)) }, onMigrate = { screenModel.showMigrateDialog(dialog.manga, it) }, ) @@ -118,13 +115,12 @@ data object HistoryTab : Tab { ) } is HistoryScreenModel.Dialog.Migrate -> { - MigrateDialog( - oldManga = dialog.oldManga, - newManga = dialog.newManga, - screenModel = MigrateDialogScreenModel(), + MigrateMangaDialog( + current = dialog.current, + target = dialog.target, + // Initiated from the context of [dialog.target] so we show [dialog.current]. + onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) }, onDismissRequest = onDismissRequest, - onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) }, - onPopScreen = onDismissRequest, ) } null -> {} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index be3acbb13..0b0d9f515 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -43,8 +43,6 @@ import eu.kanade.presentation.util.isTabletUi import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog -import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -60,6 +58,7 @@ import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.launch import logcat.LogPriority import mihon.feature.migration.config.MigrationConfigScreen +import mihon.feature.migration.dialog.MigrateMangaDialog import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat @@ -212,13 +211,12 @@ class MangaScreen( } is MangaScreenModel.Dialog.Migrate -> { - MigrateDialog( - oldManga = dialog.oldManga, - newManga = dialog.newManga, - screenModel = MigrateDialogScreenModel(), + MigrateMangaDialog( + current = dialog.current, + target = dialog.target, + // Initiated from the context of [dialog.target] so we show [dialog.current]. + onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) }, onDismissRequest = onDismissRequest, - onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) }, - onPopScreen = onDismissRequest, ) } MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index 94ef3c5e1..33e7f051e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -1071,7 +1071,7 @@ class MangaScreenModel( ) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicates: List) : Dialog - data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog + data class Migrate(val target: Manga, val current: Manga) : Dialog data class SetFetchInterval(val manga: Manga) : Dialog data object SettingsSheet : Dialog data object TrackSheet : Dialog @@ -1100,7 +1100,7 @@ class MangaScreenModel( fun showMigrateDialog(duplicate: Manga) { val manga = successState?.manga ?: return - updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) } + updateSuccessState { it.copy(dialog = Dialog.Migrate(target = manga, current = duplicate)) } } fun setExcludedScanlators(excludedScanlators: Set) { diff --git a/app/src/main/java/mihon/domain/migration/models/MigrationFlag.kt b/app/src/main/java/mihon/domain/migration/models/MigrationFlag.kt new file mode 100644 index 000000000..101fb281f --- /dev/null +++ b/app/src/main/java/mihon/domain/migration/models/MigrationFlag.kt @@ -0,0 +1,28 @@ +package mihon.domain.migration.models + +enum class MigrationFlag(val flag: Int) { + CHAPTER(0b00001), + CATEGORY(0b00010), + + // 0b00100 was used for manga trackers + CUSTOM_COVER(0b01000), + NOTES(0b100000), + REMOVE_DOWNLOAD(0b10000), + ; + + companion object { + fun fromBit(bit: Int): Set { + return buildSet { + entries.forEach { entry -> + if (bit and entry.flag != 0) add(entry) + } + } + } + + fun toBit(flags: Set): Int { + return flags.map { it.flag } + .reduceOrNull { acc, mask -> acc or mask } + ?: 0 + } + } +} diff --git a/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt b/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt new file mode 100644 index 000000000..15612864b --- /dev/null +++ b/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt @@ -0,0 +1,145 @@ +package mihon.domain.migration.usecases + +import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.hasCustomCover +import eu.kanade.domain.manga.model.toSManga +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.track.EnhancedTracker +import eu.kanade.tachiyomi.data.track.TrackerManager +import kotlinx.coroutines.CancellationException +import mihon.domain.migration.models.MigrationFlag +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.category.interactor.SetMangaCategories +import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId +import tachiyomi.domain.chapter.interactor.UpdateChapter +import tachiyomi.domain.chapter.model.toChapterUpdate +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaUpdate +import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.track.interactor.GetTracks +import tachiyomi.domain.track.interactor.InsertTrack +import java.time.Instant + +class MigrateMangaUseCase( + private val sourcePreferences: SourcePreferences, + private val trackerManager: TrackerManager, + private val sourceManager: SourceManager, + private val downloadManager: DownloadManager, + private val updateManga: UpdateManga, + private val getChaptersByMangaId: GetChaptersByMangaId, + private val syncChaptersWithSource: SyncChaptersWithSource, + private val updateChapter: UpdateChapter, + private val getCategories: GetCategories, + private val setMangaCategories: SetMangaCategories, + private val getTracks: GetTracks, + private val insertTrack: InsertTrack, + private val coverCache: CoverCache, +) { + private val enhancedServices by lazy { trackerManager.trackers.filterIsInstance() } + + suspend operator fun invoke(current: Manga, target: Manga, replace: Boolean) { + val targetSource = sourceManager.get(target.source) ?: return + val currentSource = sourceManager.get(current.source) + val flags = sourcePreferences.migrationFlags().get() + + try { + val chapters = targetSource.getChapterList(target.toSManga()) + + try { + syncChaptersWithSource.await(chapters, target, targetSource) + } catch (_: Exception) { + // Worst case, chapters won't be synced + } + + // Update chapters read, bookmark and dateFetch + if (MigrationFlag.CHAPTER in flags) { + val prevMangaChapters = getChaptersByMangaId.await(current.id) + val mangaChapters = getChaptersByMangaId.await(target.id) + + val maxChapterRead = prevMangaChapters + .filter { it.read } + .maxOfOrNull { it.chapterNumber } + + val updatedMangaChapters = mangaChapters.map { mangaChapter -> + var updatedChapter = mangaChapter + if (updatedChapter.isRecognizedNumber) { + val prevChapter = prevMangaChapters + .find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber } + + if (prevChapter != null) { + updatedChapter = updatedChapter.copy( + dateFetch = prevChapter.dateFetch, + bookmark = prevChapter.bookmark, + ) + } + + if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) { + updatedChapter = updatedChapter.copy(read = true) + } + } + + updatedChapter + } + + val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() } + updateChapter.awaitAll(chapterUpdates) + } + + // Update categories + if (MigrationFlag.CHAPTER in flags) { + val categoryIds = getCategories.await(current.id).map { it.id } + setMangaCategories.await(target.id, categoryIds) + } + + // Update track + getTracks.await(current.id).mapNotNull { track -> + val updatedTrack = track.copy(mangaId = target.id) + + val service = enhancedServices + .firstOrNull { it.isTrackFrom(updatedTrack, current, currentSource) } + + if (service != null) { + service.migrateTrack(updatedTrack, target, targetSource) + } else { + updatedTrack + } + } + .takeIf { it.isNotEmpty() } + ?.let { insertTrack.awaitAll(it) } + + // Delete downloaded + if (MigrationFlag.REMOVE_DOWNLOAD in flags && currentSource != null) { + downloadManager.deleteManga(current, currentSource) + } + + // Update custom cover (recheck if custom cover exists) + if (MigrationFlag.CUSTOM_COVER in flags && current.hasCustomCover()) { + coverCache.setCustomCoverToCache(target, coverCache.getCustomCoverFile(current.id).inputStream()) + } + + val currentMangaUpdate = MangaUpdate( + id = current.id, + favorite = false, + dateAdded = 0, + ) + .takeIf { replace } + val targetMangaUpdate = MangaUpdate( + id = target.id, + favorite = true, + chapterFlags = current.chapterFlags, + viewerFlags = current.viewerFlags, + dateAdded = if (replace) current.dateAdded else Instant.now().toEpochMilli(), + notes = if (MigrationFlag.NOTES in flags) current.notes else null, + ) + + updateManga.awaitAll(listOfNotNull(currentMangaUpdate, targetMangaUpdate)) + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } + } + } +} diff --git a/app/src/main/java/mihon/feature/common/utils/MigrationFlag.kt b/app/src/main/java/mihon/feature/common/utils/MigrationFlag.kt new file mode 100644 index 000000000..77154ca2f --- /dev/null +++ b/app/src/main/java/mihon/feature/common/utils/MigrationFlag.kt @@ -0,0 +1,15 @@ +package mihon.feature.common.utils + +import dev.icerock.moko.resources.StringResource +import mihon.domain.migration.models.MigrationFlag +import tachiyomi.i18n.MR + +fun MigrationFlag.getLabel(): StringResource { + return when (this) { + MigrationFlag.CHAPTER -> MR.strings.chapters + MigrationFlag.CATEGORY -> MR.strings.categories + MigrationFlag.CUSTOM_COVER -> MR.strings.custom_cover + MigrationFlag.NOTES -> MR.strings.action_notes + MigrationFlag.REMOVE_DOWNLOAD -> MR.strings.delete_downloaded + } +} diff --git a/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt b/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt new file mode 100644 index 000000000..df5eaa446 --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt @@ -0,0 +1,167 @@ +package mihon.feature.migration.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastForEach +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import eu.kanade.domain.manga.model.hasCustomCover +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.download.DownloadManager +import kotlinx.coroutines.flow.update +import mihon.domain.migration.models.MigrationFlag +import mihon.domain.migration.usecases.MigrateMangaUseCase +import mihon.feature.common.utils.getLabel +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.lang.withUIContext +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.LabeledCheckbox +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.LoadingScreen +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +@Composable +internal fun Screen.MigrateMangaDialog( + current: Manga, + target: Manga, + onClickTitle: () -> Unit, + onDismissRequest: () -> Unit, + onComplete: () -> Unit = onDismissRequest, +) { + val scope = rememberCoroutineScope() + + val screenModel = rememberScreenModel { MigrateDialogScreenModel(current, target) } + val state by screenModel.state.collectAsState() + + if (state.isMigrating) { + LoadingScreen( + modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)), + ) + return + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(MR.strings.migration_dialog_what_to_include)) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + state.applicableFlags.fastForEach { flag -> + LabeledCheckbox( + label = stringResource(flag.getLabel()), + checked = flag in state.selectedFlags, + onCheckedChange = { screenModel.toggleSelection(flag) }, + ) + } + } + }, + confirmButton = { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), + ) { + TextButton( + onClick = { + onDismissRequest() + onClickTitle() + }, + ) { + Text(text = stringResource(MR.strings.action_show_manga)) + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = { + scope.launchIO { + screenModel.migrateManga(replace = false) + withUIContext { onComplete() } + } + }, + ) { + Text(text = stringResource(MR.strings.copy)) + } + TextButton( + onClick = { + scope.launchIO { + screenModel.migrateManga(replace = true) + withUIContext { onComplete() } + } + }, + ) { + Text(text = stringResource(MR.strings.migrate)) + } + } + }, + ) +} + +private class MigrateDialogScreenModel( + private val current: Manga, + private val target: Manga, + private val sourcePreference: SourcePreferences = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val migrateManga: MigrateMangaUseCase = Injekt.get(), +) : StateScreenModel(State()) { + + init { + val applicableFlags = buildList { + MigrationFlag.entries.forEach { + val applicable = when (it) { + MigrationFlag.CHAPTER -> true + MigrationFlag.CATEGORY -> true + MigrationFlag.CUSTOM_COVER -> current.hasCustomCover(coverCache) + MigrationFlag.NOTES -> current.notes.isNotBlank() + MigrationFlag.REMOVE_DOWNLOAD -> downloadManager.getDownloadCount(current) > 0 + } + if (applicable) add(it) + } + } + val selectedFlags = sourcePreference.migrationFlags().get() + mutableState.update { it.copy(applicableFlags = applicableFlags, selectedFlags = selectedFlags) } + } + + fun toggleSelection(flag: MigrationFlag) { + mutableState.update { + val selectedFlags = it.selectedFlags.toMutableSet() + .apply { if (contains(flag)) remove(flag) else add(flag) } + .toSet() + it.copy(selectedFlags = selectedFlags) + } + } + + suspend fun migrateManga(replace: Boolean) { + sourcePreference.migrationFlags().set(state.value.selectedFlags) + mutableState.update { it.copy(isMigrating = true) } + migrateManga(current, target, replace) + mutableState.update { it.copy(isMigrating = false) } + } + + data class State( + val applicableFlags: List = emptyList(), + val selectedFlags: Set = emptySet(), + val isMigrating: Boolean = false, + ) +} diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreference.kt b/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreference.kt index 577d83687..026245d82 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreference.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreference.kt @@ -171,13 +171,13 @@ sealed class AndroidPreference( } } - class Object( + class ObjectAsString( preferences: SharedPreferences, keyFlow: Flow, key: String, defaultValue: T, - val serializer: (T) -> String, - val deserializer: (String) -> T, + private val serializer: (T) -> String, + private val deserializer: (String) -> T, ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T { return try { @@ -191,4 +191,25 @@ sealed class AndroidPreference( putString(key, serializer(value)) } } + + class ObjectAsInt( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: T, + private val serializer: (T) -> Int, + private val deserializer: (Int) -> T, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T { + return try { + if (preferences.contains(key)) preferences.getInt(key, 0).let(deserializer) else defaultValue + } catch (e: Exception) { + defaultValue + } + } + + override fun write(key: String, value: T): Editor.() -> Unit = { + putInt(key, serializer(value)) + } + } } diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreferenceStore.kt b/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreferenceStore.kt index 6bdb120cd..78f98f204 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreferenceStore.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/preference/AndroidPreferenceStore.kt @@ -9,7 +9,8 @@ import tachiyomi.core.common.preference.AndroidPreference.BooleanPrimitive import tachiyomi.core.common.preference.AndroidPreference.FloatPrimitive import tachiyomi.core.common.preference.AndroidPreference.IntPrimitive import tachiyomi.core.common.preference.AndroidPreference.LongPrimitive -import tachiyomi.core.common.preference.AndroidPreference.Object +import tachiyomi.core.common.preference.AndroidPreference.ObjectAsInt +import tachiyomi.core.common.preference.AndroidPreference.ObjectAsString import tachiyomi.core.common.preference.AndroidPreference.StringPrimitive import tachiyomi.core.common.preference.AndroidPreference.StringSetPrimitive @@ -44,13 +45,29 @@ class AndroidPreferenceStore( return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue) } - override fun getObject( + override fun getObjectFromString( key: String, defaultValue: T, serializer: (T) -> String, deserializer: (String) -> T, ): Preference { - return Object( + return ObjectAsString( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): Preference { + return ObjectAsInt( preferences = sharedPreferences, keyFlow = keyFlow, key = key, diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/preference/InMemoryPreferenceStore.kt b/core/common/src/main/kotlin/tachiyomi/core/common/preference/InMemoryPreferenceStore.kt index 96e8644ad..1dae7fff9 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/preference/InMemoryPreferenceStore.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/preference/InMemoryPreferenceStore.kt @@ -52,7 +52,7 @@ class InMemoryPreferenceStore( } @Suppress("UNCHECKED_CAST") - override fun getObject( + override fun getObjectFromString( key: String, defaultValue: T, serializer: (T) -> String, @@ -63,6 +63,18 @@ class InMemoryPreferenceStore( return if (data == null) default else InMemoryPreference(key, data, defaultValue) } + @Suppress("UNCHECKED_CAST") + override fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: T? = preferences[key]?.get() as? T + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + override fun getAll(): Map { return preferences } diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/preference/PreferenceStore.kt b/core/common/src/main/kotlin/tachiyomi/core/common/preference/PreferenceStore.kt index 6407224a6..2016f3d44 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/preference/PreferenceStore.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/preference/PreferenceStore.kt @@ -14,13 +14,20 @@ interface PreferenceStore { fun getStringSet(key: String, defaultValue: Set = emptySet()): Preference> - fun getObject( + fun getObjectFromString( key: String, defaultValue: T, serializer: (T) -> String, deserializer: (String) -> T, ): Preference + fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): Preference + fun getAll(): Map } @@ -28,7 +35,7 @@ fun PreferenceStore.getLongArray( key: String, defaultValue: List, ): Preference> { - return getObject( + return getObjectFromString( key = key, defaultValue = defaultValue, serializer = { it.joinToString(",") }, @@ -40,7 +47,7 @@ inline fun > PreferenceStore.getEnum( key: String, defaultValue: T, ): Preference { - return getObject( + return getObjectFromString( key = key, defaultValue = defaultValue, serializer = { it.name }, diff --git a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt index a0c77d05b..7ddecc512 100644 --- a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt @@ -12,14 +12,14 @@ class LibraryPreferences( private val preferenceStore: PreferenceStore, ) { - fun displayMode() = preferenceStore.getObject( + fun displayMode() = preferenceStore.getObjectFromString( "pref_display_mode_library", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::deserialize, ) - fun sortingMode() = preferenceStore.getObject( + fun sortingMode() = preferenceStore.getObjectFromString( "library_sorting_mode", LibrarySort.default, LibrarySort.Serializer::serialize,