From af67609399ab78d8fb3bf5142d3f9cddfe76b68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A0=20Ti=E1=BA=BFn=20S=C3=A1ng?= Date: Sat, 17 Oct 2026 06:44:49 +0700 Subject: [PATCH 1/2] feat(favourites): allow migrating all favourites to another source --- app/src/main/AndroidManifest.xml | 4 + .../domain/MigrateToSourceUseCase.kt | 34 ++++ .../alternatives/ui/MigrateSourceService.kt | 185 ++++++++++++++++++ .../container/FavouritesContainerFragment.kt | 63 +++++- .../FavouritesContainerMenuProvider.kt | 5 + .../container/FavouritesContainerViewModel.kt | 8 + .../res/menu/opt_favourites_container.xml | 5 + app/src/main/res/values-vi/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + 9 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/org/draken/usagi/alternatives/domain/MigrateToSourceUseCase.kt create mode 100644 app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c780cd88..d16492bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -376,6 +376,10 @@ android:name="org.draken.usagi.alternatives.ui.AutoFixService" android:foregroundServiceType="dataSync" android:label="@string/fixing_manga" /> + diff --git a/app/src/main/kotlin/org/draken/usagi/alternatives/domain/MigrateToSourceUseCase.kt b/app/src/main/kotlin/org/draken/usagi/alternatives/domain/MigrateToSourceUseCase.kt new file mode 100644 index 00000000..106b66a2 --- /dev/null +++ b/app/src/main/kotlin/org/draken/usagi/alternatives/domain/MigrateToSourceUseCase.kt @@ -0,0 +1,34 @@ +package org.draken.usagi.alternatives.domain + +import org.draken.usagi.core.parser.MangaDataRepository +import org.draken.usagi.search.domain.SearchKind +import org.draken.usagi.search.domain.SearchV2Helper +import tsuki.model.MangaSource +import javax.inject.Inject + +class MigrateToSourceUseCase + @Inject + constructor( + private val mangaDataRepository: MangaDataRepository, + private val searchHelperFactory: SearchV2Helper.Factory, + private val migrateUseCase: MigrateUseCase, + ) { + /** Returns true when a matching manga was found on [target] and migrated. */ + suspend operator fun invoke( + mangaId: Long, + target: MangaSource, + ): Boolean { + val seed = mangaDataRepository.findMangaById(mangaId, withChapters = true) ?: return false + if (seed.source.name == target.name) { + return false + } + val searchHelper = searchHelperFactory.create(target) + val candidate = + searchHelper(seed.title, SearchKind.TITLE)?.manga?.firstOrNull() + ?: seed.altTitle + ?.takeIf { it.isNotBlank() && it != seed.title } + ?.let { searchHelper(it, SearchKind.TITLE)?.manga?.firstOrNull() } ?: return false + migrateUseCase(seed, candidate) + return true + } + } diff --git a/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt b/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt new file mode 100644 index 00000000..3c2365b8 --- /dev/null +++ b/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt @@ -0,0 +1,185 @@ +package org.draken.usagi.alternatives.ui + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import org.draken.usagi.R +import org.draken.usagi.alternatives.domain.MigrateToSourceUseCase +import org.draken.usagi.core.ErrorReporterReceiver +import org.draken.usagi.core.model.MangaSource +import org.draken.usagi.core.ui.CoroutineIntentService +import org.draken.usagi.core.util.ext.checkNotificationPermission +import org.draken.usagi.core.util.ext.getDisplayMessage +import org.draken.usagi.core.util.ext.printStackTraceDebug +import tsuki.model.MangaSource +import tsuki.util.runCatchingCancellable +import javax.inject.Inject +import androidx.appcompat.R as appcompatR + +@AndroidEntryPoint +class MigrateSourceService : CoroutineIntentService() { + @Inject + lateinit var migrateToSourceUseCase: MigrateToSourceUseCase + + private lateinit var notificationManager: NotificationManagerCompat + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + } + + override suspend fun IntentJobContext.processIntent(intent: Intent) { + val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) + val target = MangaSource(intent.getStringExtra(DATA_TARGET)) + startForeground(this) + var migrated = 0 + var missing = 0 + var failed = 0 + // sequential on purpose: one search per manga keeps the load on sources gentle + ids.forEachIndexed { index, mangaId -> + runCatchingCancellable { migrateToSourceUseCase(mangaId, target) } + .onSuccess { found -> if (found) migrated++ else missing++ } + .onFailure { failed++ } + if (checkNotificationPermission(CHANNEL_ID)) { + notificationManager.notify(TAG, FOREGROUND_NOTIFICATION_ID, buildProgressNotification(this, index + 1, ids.size)) + } + } + if (checkNotificationPermission(CHANNEL_ID)) { + notificationManager.notify(TAG, SUMMARY_NOTIFICATION_ID, buildSummaryNotification(migrated, missing, failed)) + } + } + + override fun IntentJobContext.onError(error: Throwable) { + if (!checkNotificationPermission(CHANNEL_ID)) { + return + } + val builder = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setContentTitle(getString(R.string.error_occurred)) + .setContentText(error.getDisplayMessage(resources)) + .setSmallIcon(android.R.drawable.stat_notify_error) + ErrorReporterReceiver + .getNotificationAction( + context = this, + e = error, + notificationId = startId, + notificationTag = TAG, + )?.let { action -> + builder.addAction(action) + } + notificationManager.notify(TAG, startId, builder.build()) + } + + @SuppressLint("InlinedApi") + private fun startForeground(jobContext: IntentJobContext) { + val title = getString(R.string.migrating_favourites) + val channel = + NotificationChannelCompat + .Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) + .setName(title) + .setShowBadge(false) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + + val notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress(0, 0, true) + .setSmallIcon(R.drawable.ic_stat_auto_fix) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + appcompatR.drawable.abc_ic_clear_material, + getString(android.R.string.cancel), + jobContext.getCancelIntent(), + ).build() + + jobContext.setForeground( + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private fun buildProgressNotification( + jobContext: IntentJobContext, + current: Int, + total: Int, + ): Notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.migrating_favourites)) + .setContentText(getString(R.string.migration_progress, current, total)) + .setProgress(total, current, false) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_stat_auto_fix) + .addAction( + appcompatR.drawable.abc_ic_clear_material, + getString(android.R.string.cancel), + jobContext.getCancelIntent(), + ).build() + + private fun buildSummaryNotification( + migrated: Int, + missing: Int, + failed: Int, + ): Notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setContentTitle(getString(R.string.migration_completed)) + .setContentText(getString(R.string.migration_summary, migrated, missing, failed)) + .setSmallIcon(R.drawable.ic_stat_done) + .build() + + companion object { + private const val DATA_IDS = "ids" + private const val DATA_TARGET = "target" + private const val TAG = "migrate_source" + private const val CHANNEL_ID = "migrate_source" + private const val FOREGROUND_NOTIFICATION_ID = 39 + private const val SUMMARY_NOTIFICATION_ID = 40 + + fun start( + context: Context, + mangaIds: Collection, + target: MangaSource, + ): Boolean = + try { + val intent = Intent(context, MigrateSourceService::class.java) + intent.putExtra(DATA_IDS, mangaIds.toLongArray()) + intent.putExtra(DATA_TARGET, target.name) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerFragment.kt b/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerFragment.kt index 964d03bc..5a6890b4 100644 --- a/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerFragment.kt @@ -11,11 +11,16 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.draken.usagi.R +import org.draken.usagi.alternatives.ui.MigrateSourceService +import org.draken.usagi.core.model.getTitle import org.draken.usagi.core.nav.router import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.prefs.observeAsFlow @@ -33,6 +38,7 @@ import org.draken.usagi.core.util.ext.setTextAndVisible import org.draken.usagi.databinding.FragmentFavouritesContainerBinding import org.draken.usagi.databinding.ItemEmptyStateBinding import org.draken.usagi.favourites.ui.list.FavouritesListFragment +import tsuki.model.MangaSource @AndroidEntryPoint class FavouritesContainerFragment : @@ -77,7 +83,7 @@ class FavouritesContainerFragment : binding.tabs.isGone = !isEnabled || viewModel.isEmpty.value } viewModel.isEmpty.observe(viewLifecycleOwner, ::onEmptyStateChanged) - addMenuProvider(FavouritesContainerMenuProvider(router)) + addMenuProvider(FavouritesContainerMenuProvider(router, ::onMigrateRequested)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager)) } @@ -123,6 +129,61 @@ class FavouritesContainerFragment : } } + private fun onMigrateRequested() { + viewLifecycleOwner.lifecycleScope.launch { + val context = context ?: return@launch + val groups = viewModel.favouritesBySource() + if (groups.isEmpty()) { + return@launch + } + val sources = groups.keys.sortedBy { it.getTitle(context).lowercase() } + MaterialAlertDialogBuilder(context) + .setTitle(R.string.migrate_from_source) + .setItems(sources.map { it.getTitle(context) }.toTypedArray()) { _, which -> + onMigrateSourceSelected(sources[which], groups.getValue(sources[which])) + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + private fun onMigrateSourceSelected( + source: MangaSource, + ids: List, + ) { + val context = context ?: return + val targets = viewModel.migrationTargets(source).sortedBy { it.getTitle(context).lowercase() } + if (targets.isEmpty()) { + return + } + MaterialAlertDialogBuilder(context) + .setTitle(R.string.migrate_to_source) + .setItems(targets.map { it.getTitle(context) }.toTypedArray()) { _, which -> + confirmMigration(source, targets[which], ids) + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun confirmMigration( + source: MangaSource, + target: MangaSource, + ids: List, + ) { + val context = context ?: return + MaterialAlertDialogBuilder(context) + .setTitle(R.string.migrate) + .setMessage( + getString( + R.string.migrate_favourites_prompt, + ids.size, + source.getTitle(context), + target.getTitle(context), + ), + ).setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.migrate) { _, _ -> + MigrateSourceService.start(context, ids, target) + }.show() + } + private fun onEmptyStateChanged(isEmpty: Boolean) { viewBinding?.run { pager.isGone = isEmpty diff --git a/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerMenuProvider.kt b/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerMenuProvider.kt index 1ded0837..606539ea 100644 --- a/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerMenuProvider.kt +++ b/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerMenuProvider.kt @@ -9,6 +9,7 @@ import org.draken.usagi.core.nav.AppRouter class FavouritesContainerMenuProvider( private val router: AppRouter, + private val onMigrate: () -> Unit, ) : MenuProvider { override fun onCreateMenu( menu: Menu, @@ -23,6 +24,10 @@ class FavouritesContainerMenuProvider( router.openFavoriteCategories() } + R.id.action_migrate -> { + onMigrate() + } + else -> { return false } diff --git a/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerViewModel.kt b/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerViewModel.kt index 5fdd2420..ab374a43 100644 --- a/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/favourites/ui/container/FavouritesContainerViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.draken.usagi.R import org.draken.usagi.core.model.FavouriteCategory +import org.draken.usagi.core.model.isManageableSource import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.prefs.observeAsFlow import org.draken.usagi.core.ui.BaseViewModel @@ -18,8 +19,10 @@ import org.draken.usagi.core.ui.util.ReversibleAction import org.draken.usagi.core.ui.util.ReversibleHandle import org.draken.usagi.core.util.ext.MutableEventFlow import org.draken.usagi.core.util.ext.call +import org.draken.usagi.explore.data.MangaSourcesRepository import org.draken.usagi.favourites.domain.FavouritesRepository import org.draken.usagi.favourites.ui.list.FavouritesListFragment.Companion.NO_ID +import tsuki.model.MangaSource import javax.inject.Inject @HiltViewModel @@ -28,6 +31,7 @@ class FavouritesContainerViewModel constructor( val settings: AppSettings, private val favouritesRepository: FavouritesRepository, + private val sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { val onActionDone = MutableEventFlow() @@ -84,6 +88,10 @@ class FavouritesContainerViewModel } } + suspend fun favouritesBySource(): Map> = favouritesRepository.getAllManga().groupBy({ it.source }, { it.id }) + + fun migrationTargets(excluded: MangaSource): List = sourcesRepository.allMangaSources.filter { it.name != excluded.name && it.isManageableSource() } + private fun observeAllFavouritesVisibility() = settings.observeAsFlow( key = AppSettings.KEY_ALL_FAVOURITES_VISIBLE, diff --git a/app/src/main/res/menu/opt_favourites_container.xml b/app/src/main/res/menu/opt_favourites_container.xml index 83433bf3..5369e36c 100644 --- a/app/src/main/res/menu/opt_favourites_container.xml +++ b/app/src/main/res/menu/opt_favourites_container.xml @@ -2,6 +2,11 @@ + + Xoá các chương đã đọc Không có chương nào bị xoá Sáp nhập thành công + Sáp nhập nguồn + Sáp nhập từ nguồn + Sáp nhập vào nguồn + %1$d bộ truyện sẽ được sáp nhập từ \"%2$s\" vào \"%3$s\" cùng với lịch sử và tiến độ đọc. Các bộ truyện không tìm thấy trên nguồn đích sẽ được giữ nguyên + Đang sáp nhập truyện yêu thích + Đã sáp nhập %1$d trên %2$d + Đã sáp nhập: %1$d, không tìm thấy: %2$d, lỗi: %3$d Hành động này sẽ xoá tất cả các chương đã đọc trong bộ nhớ trong. Bạn có thể tải lại sau, nhưng những chương đã xoá có thể mất vĩnh viễn Nhập email và mật khẩu để tiếp tục Không rõ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84e9bfe0..09d62131 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -635,6 +635,13 @@ Manga \"%1$s\" from \"%2$s\" will be replaced with \"%3$s\" from \"%4$s\" in your history and favorites (if present) Manga migration Migration completed + Migrate sources + Migrate from source + Migrate to source + %1$d manga will be moved from \"%2$s\" to \"%3$s\" along with their history and reading progress. Manga that cannot be found on the target source will be left unchanged + Migrating favourites + Migrated %1$d of %2$d + Migrated: %1$d, no match: %2$d, failed: %3$d Delete read chapters No chapters have been deleted Removed %1$s, cleared %2$s From 26305bb2b6531edd4fd96aeb769bd2908cfee1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A0=20Ti=E1=BA=BFn=20S=C3=A1ng?= Date: Sat, 17 Oct 2026 06:51:15 +0700 Subject: [PATCH 2/2] fix(favourites): use service context when building error notification --- .../org/draken/usagi/alternatives/ui/MigrateSourceService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt b/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt index 3c2365b8..5ba30539 100644 --- a/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt +++ b/app/src/main/kotlin/org/draken/usagi/alternatives/ui/MigrateSourceService.kt @@ -62,7 +62,7 @@ class MigrateSourceService : CoroutineIntentService() { } val builder = NotificationCompat - .Builder(this, CHANNEL_ID) + .Builder(this@MigrateSourceService, CHANNEL_ID) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setDefaults(0) .setSilent(true) @@ -72,7 +72,7 @@ class MigrateSourceService : CoroutineIntentService() { .setSmallIcon(android.R.drawable.stat_notify_error) ErrorReporterReceiver .getNotificationAction( - context = this, + context = this@MigrateSourceService, e = error, notificationId = startId, notificationTag = TAG,