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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.progress

import com.ichi2.anki.ProgressContext
import com.ichi2.anki.withProgress
import kotlinx.coroutines.CoroutineScope
import net.ankiweb.rsdroid.Backend

/**
* Bridges the backend progress polling system into [ProgressScope].
*
* @param backend the Anki backend instance to poll for progress
* @param extractProgress lambda to extract progress data from the backend
* @param block the operation to execute
*/
suspend fun <T> ProgressScope.withBackendProgress(
backend: Backend,
progressContext: ProgressContext = ProgressContext(),
extractProgress: ProgressContext.() -> Unit,
block: suspend CoroutineScope.() -> T,
): T =
backend.withProgress(
progressContext = progressContext,
extractProgress = extractProgress,
updateUi = {
updateProgress(
message = text,
amount = amount,
)
},
block = block,
)
23 changes: 23 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/progress/HasProgress.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.progress

/**
* Interface for ViewModels that expose progress state to the UI.
*/
interface HasProgress {
val progressManager: ProgressManager
}
135 changes: 135 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.progress

import com.ichi2.anki.ProgressContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicLong

/**
* Progress state shared by a ViewModel and its UI.
*
* Concurrent [withProgress] calls are supported: the flow stays [Active][ViewModelProgress.Active]
* until every call finishes. The displayed message/amount comes from whichever op was last to
* start or update (one dialog, last write wins). The dialog is cancellable if any active op
* passed an `onCancel`, and [requestCancel] fires all of those callbacks.
*/
class ProgressManager {
val progress: StateFlow<ViewModelProgress>
field = MutableStateFlow<ViewModelProgress>(ViewModelProgress.Idle)

private val lock = Any()

/** Keyed by op id, iteration order is start/update order the last entry wins. */
private val activeOps = linkedMapOf<Long, Op>()
private val nextOpId = AtomicLong(0)

private data class Op(
val message: String?,
val amount: ProgressContext.Amount?,
val onCancel: (() -> Unit)?,
val formatAmount: (ProgressContext.Amount) -> String,
val separator: String,
)

/**
* Run [block] while a progress dialog is shown.
*
* @param message initial message, or null for no text.
* @param onCancel if non-null, the dialog becomes cancellable and this runs when dismissed.
* @param formatAmount / [separator] control how [ProgressContext.Amount] is rendered.
* See the class KDoc for how these combine across concurrent ops.
*/
suspend fun <T> withProgress(
message: String? = null,
onCancel: (() -> Unit)? = null,
formatAmount: (ProgressContext.Amount) -> String =
{ (current, max) -> "$current/$max" },
separator: String = " ",
block: suspend ProgressScope.() -> T,
): T {
val opId = nextOpId.incrementAndGet()
synchronized(lock) {
activeOps[opId] =
Op(
message = message,
amount = null,
onCancel = onCancel,
formatAmount = formatAmount,
separator = separator,
)
publishLocked()
}
try {
return ProgressScope(this, opId).block()
} finally {
synchronized(lock) {
activeOps.remove(opId)
publishLocked()
}
}
}

/** Updates [opId] and moves it to the end so it becomes the displayed op. */
internal fun updateOp(
opId: Long,
message: String?,
amount: ProgressContext.Amount?,
) {
synchronized(lock) {
val existing = activeOps.remove(opId) ?: return
activeOps[opId] = existing.copy(message = message, amount = amount)
publishLocked()
}
}

/** Called by the UI when the user dismisses the dialog. Fires every active `onCancel`. */
fun requestCancel() {
val callbacks = synchronized(lock) { activeOps.values.mapNotNull { it.onCancel } }
callbacks.forEach { it.invoke() }
}

/** Must be called under [lock]. */
private fun publishLocked() {
progress.value =
if (activeOps.isEmpty()) {
ViewModelProgress.Idle
} else {
val latest = activeOps.values.last()
ViewModelProgress.Active(
message = latest.message,
amount = latest.amount,
cancellable = activeOps.values.any { it.onCancel != null },
formatAmount = latest.formatAmount,
separator = latest.separator,
)
}
}
}

/** Receiver inside [ProgressManager.withProgress] for mid-operation updates. */
class ProgressScope internal constructor(
private val manager: ProgressManager,
private val opId: Long,
) {
fun updateProgress(
message: String? = null,
amount: ProgressContext.Amount? = null,
) {
manager.updateOp(opId, message = message, amount = amount)
}
}
107 changes: 107 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.progress

import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.ichi2.anki.AnkiActivity
import com.ichi2.anki.dialogs.LoadingDialogFragment
import com.ichi2.anki.dialogs.dismissLoadingDialog
import com.ichi2.anki.dialogs.showLoadingDialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

/**
* Shows/dismisses a loading dialog driven by [viewModel]'s progress flow.
* [delayMillis] defers showing so quick operations don't flash a dialog.
*/
fun AnkiActivity.observeProgress(
viewModel: HasProgress,
delayMillis: Duration = 600.milliseconds,
) {
var dialogVisible =
supportFragmentManager.findFragmentByTag(LoadingDialogFragment.TAG) != null

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.progressManager.progress.collectLatest { state ->
when (state) {
is ViewModelProgress.Idle -> {
dialogVisible = false
dismissLoadingDialog()
}
is ViewModelProgress.Active -> {
if (!dialogVisible) {
delay(delayMillis)
}
showLoadingDialog(
message = state.formatMessage(),
cancellable = state.cancellable,
)
dialogVisible = true
if (state.cancellable) {
// show() commits async; flush so fragment.dialog is available.
supportFragmentManager.executePendingTransactions()
val fragment =
supportFragmentManager.findFragmentByTag(
LoadingDialogFragment.TAG,
) as? LoadingDialogFragment
fragment?.dialog?.setOnCancelListener {
viewModel.progressManager.requestCancel()
}
}
}
}
}
}
}
}

/**
* Fragment wrapper that delegates to the host activity.
*
* TODO: relax [showLoadingDialog]/[dismissLoadingDialog] to `FragmentActivity`
* and drop this cast.
*/
fun Fragment.observeProgress(
viewModel: HasProgress,
delayMillis: Duration = 600.milliseconds,
) {
val activity = requireActivity() as? AnkiActivity
if (activity == null) {
Timber.w(
"observeProgress called from a Fragment hosted by %s, which is not an AnkiActivity; skipping.",
requireActivity().javaClass.simpleName,
)
return
}
activity.observeProgress(viewModel, delayMillis)
}

private fun ViewModelProgress.Active.formatMessage(): String? {
val amount = amount ?: return message
val formattedAmount = formatAmount(amount)
return when {
message.isNullOrEmpty() -> formattedAmount
else -> "$message$separator$formattedAmount"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.progress

import com.ichi2.anki.ProgressContext

/** Progress state observed by the UI. See [ProgressManager] for concurrent-op semantics. */
sealed interface ViewModelProgress {
data object Idle : ViewModelProgress

data class Active(
val message: String? = null,
val amount: ProgressContext.Amount? = null,
val cancellable: Boolean = false,
val formatAmount: (ProgressContext.Amount) -> String =
{ (current, max) -> "$current/$max" },
val separator: String = " ",
) : ViewModelProgress
}
Loading
Loading