Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
android:name="org.draken.usagi.alternatives.ui.AutoFixService"
android:foregroundServiceType="dataSync"
android:label="@string/fixing_manga" />
<service
android:name="org.draken.usagi.alternatives.ui.MigrateSourceService"
android:foregroundServiceType="dataSync"
android:label="@string/migrating_favourites" />
<service
android:name="org.draken.usagi.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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@MigrateSourceService, 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@MigrateSourceService,
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<Long>,
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 :
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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<Long>,
) {
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<Long>,
) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,10 @@ class FavouritesContainerMenuProvider(
router.openFavoriteCategories()
}

R.id.action_migrate -> {
onMigrate()
}

else -> {
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ 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
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
Expand All @@ -28,6 +31,7 @@ class FavouritesContainerViewModel
constructor(
val settings: AppSettings,
private val favouritesRepository: FavouritesRepository,
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()

Expand Down Expand Up @@ -84,6 +88,10 @@ class FavouritesContainerViewModel
}
}

suspend fun favouritesBySource(): Map<MangaSource, List<Long>> = favouritesRepository.getAllManga().groupBy({ it.source }, { it.id })

fun migrationTargets(excluded: MangaSource): List<MangaSource> = sourcesRepository.allMangaSources.filter { it.name != excluded.name && it.isManageableSource() }

private fun observeAllFavouritesVisibility() =
settings.observeAsFlow(
key = AppSettings.KEY_ALL_FAVOURITES_VISIBLE,
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/menu/opt_favourites_container.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
<menu
xmlns:android="http://schemas.android.com/apk/res/android">

<item
android:id="@+id/action_migrate"
android:orderInCategory="47"
android:title="@string/migrate_sources" />

<item
android:id="@+id/action_manage"
android:orderInCategory="48"
Expand Down
Loading
Loading