diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/BackendProgressExt.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/BackendProgressExt.kt new file mode 100644 index 000000000000..8888ec21f2a9 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/BackendProgressExt.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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 . + */ +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 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, + ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/HasProgress.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/HasProgress.kt new file mode 100644 index 000000000000..c762a5591bc3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/HasProgress.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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 . + */ +package com.ichi2.anki.progress + +/** + * Interface for ViewModels that expose progress state to the UI. + */ +interface HasProgress { + val progressManager: ProgressManager +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressManager.kt new file mode 100644 index 000000000000..1244e0fe8da6 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressManager.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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 . + */ +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 + field = MutableStateFlow(ViewModelProgress.Idle) + + private val lock = Any() + + /** Keyed by op id, iteration order is start/update order the last entry wins. */ + private val activeOps = linkedMapOf() + 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 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) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt new file mode 100644 index 000000000000..8d3124964573 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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 . + */ +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" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ViewModelProgress.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ViewModelProgress.kt new file mode 100644 index 000000000000..b1c5e1805d39 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ViewModelProgress.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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 . + */ +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 +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressManagerTest.kt new file mode 100644 index 000000000000..d3f03bdc38b4 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressManagerTest.kt @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * 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 . + */ +package com.ichi2.anki.progress + +import com.ichi2.anki.ProgressContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProgressManagerTest { + @Test + fun `initial state is Idle`() { + val manager = ProgressManager() + assertIs(manager.progress.value) + } + + @Test + fun `withProgress transitions to Active and back to Idle`() = + runTest { + val manager = ProgressManager() + + manager.withProgress(message = "Loading...") { + val state = manager.progress.value + assertIs(state) + assertEquals("Loading...", state.message) + } + + assertIs(manager.progress.value) + } + + @Test + fun `withProgress returns block result`() = + runTest { + val manager = ProgressManager() + val result = + manager.withProgress { + 42 + } + assertEquals(42, result) + } + + @Test + fun `withProgress returns to Idle even on exception`() = + runTest { + val manager = ProgressManager() + try { + manager.withProgress { + throw RuntimeException("test error") + } + } catch (_: RuntimeException) { + // expected + } + assertIs(manager.progress.value) + } + + @Test + fun `concurrent operations keep Active until all complete`() = + runTest { + val manager = ProgressManager() + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + + val job1 = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "Op 1") { + deferred1.await() + } + } + + val job2 = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "Op 2") { + deferred2.await() + } + } + + assertIs(manager.progress.value) + + deferred1.complete(Unit) + + assertIs(manager.progress.value) + + deferred2.complete(Unit) + + assertIs(manager.progress.value) + + job1.join() + job2.join() + } + + @Test + fun `updateProgress updates state mid-operation`() = + runTest { + val manager = ProgressManager() + + manager.withProgress(message = "Starting") { + val initialState = manager.progress.value + assertIs(initialState) + assertEquals("Starting", initialState.message) + + val testAmount = ProgressContext.Amount(current = 5, max = 10) + updateProgress(message = "Step 2", amount = testAmount) + + val updatedState = manager.progress.value + assertIs(updatedState) + assertEquals("Step 2", updatedState.message) + assertEquals(testAmount, updatedState.amount) + } + } + + @Test + fun `onCancel makes dialog cancellable`() = + runTest { + val manager = ProgressManager() + + manager.withProgress(onCancel = { }) { + val state = manager.progress.value + assertIs(state) + assertEquals(true, state.cancellable) + } + } + + @Test + fun `null onCancel makes dialog non-cancellable`() = + runTest { + val manager = ProgressManager() + + manager.withProgress { + val state = manager.progress.value + assertIs(state) + assertEquals(false, state.cancellable) + } + } + + @Test + fun `requestCancel invokes onCancel callback`() = + runTest { + val manager = ProgressManager() + var cancelCalled = false + + val job = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { cancelCalled = true }) { + CompletableDeferred().await() + } + } + + manager.requestCancel() + assertTrue(cancelCalled) + job.cancel() + } + + @Test + fun `requestCancel invokes every active cancellable op`() = + runTest { + val manager = ProgressManager() + var firstCalled = false + var secondCalled = false + + val job1 = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { firstCalled = true }) { + CompletableDeferred().await() + } + } + val job2 = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { secondCalled = true }) { + CompletableDeferred().await() + } + } + + manager.requestCancel() + assertTrue(firstCalled) + assertTrue(secondCalled) + + job1.cancel() + job2.cancel() + } + + @Test + fun `requestCancel only fires cancellable ops, non-cancellable keep running`() = + runTest { + val manager = ProgressManager() + var cancellableFired = false + var nonCancellableFired = false + + val cancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { cancellableFired = true }) { + CompletableDeferred().await() + } + } + val nonCancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = null) { + nonCancellableFired = true + CompletableDeferred().await() + } + } + + manager.requestCancel() + assertTrue(cancellableFired) + assertTrue(nonCancellableFired) + + cancellableJob.cancel() + nonCancellableJob.cancel() + } + + @Test + fun `state is cancellable while any cancellable op is active`() = + runTest { + val manager = ProgressManager() + val cancellableDone = CompletableDeferred() + val nonCancellableDone = CompletableDeferred() + + val cancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { }) { + cancellableDone.await() + } + } + val nonCancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress { + nonCancellableDone.await() + } + } + + assertEquals(true, (manager.progress.value as ViewModelProgress.Active).cancellable) + + cancellableDone.complete(Unit) + assertEquals(false, (manager.progress.value as ViewModelProgress.Active).cancellable) + + nonCancellableDone.complete(Unit) + cancellableJob.join() + nonCancellableJob.join() + } + + @Test + fun `updateProgress preserves derived cancellable flag`() = + runTest { + val manager = ProgressManager() + val cancellableDone = CompletableDeferred() + val nonCancellableDone = CompletableDeferred() + + val cancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { }) { + cancellableDone.await() + } + } + val nonCancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress { + updateProgress(message = "mid-update") + nonCancellableDone.await() + } + } + + val state = manager.progress.value as ViewModelProgress.Active + assertEquals("mid-update", state.message) + assertEquals(true, state.cancellable) + + cancellableDone.complete(Unit) + nonCancellableDone.complete(Unit) + cancellableJob.join() + nonCancellableJob.join() + } + + @Test + fun `newer op overrides previous ops message`() = + runTest { + val manager = ProgressManager() + val aDone = CompletableDeferred() + val bDone = CompletableDeferred() + + val jobA = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "A") { aDone.await() } + } + val jobB = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "B") { bDone.await() } + } + + val state = manager.progress.value as ViewModelProgress.Active + assertEquals("B", state.message, "newest op wins") + + aDone.complete(Unit) + bDone.complete(Unit) + jobA.join() + jobB.join() + } + + @Test + fun `when latest op ends remaining ops message is shown`() = + runTest { + val manager = ProgressManager() + val aDone = CompletableDeferred() + val bDone = CompletableDeferred() + + val jobA = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "A") { aDone.await() } + } + val jobB = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "B") { bDone.await() } + } + + assertEquals("B", (manager.progress.value as ViewModelProgress.Active).message) + + bDone.complete(Unit) + jobB.join() + assertEquals("A", (manager.progress.value as ViewModelProgress.Active).message) + + aDone.complete(Unit) + jobA.join() + assertIs(manager.progress.value) + } + + @Test + fun `updateProgress re-promotes an older op to latest`() = + runTest { + val manager = ProgressManager() + val aDone = CompletableDeferred() + val bDone = CompletableDeferred() + val aUpdated = CompletableDeferred() + + val jobA = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "A") { + aUpdated.await() + updateProgress(message = "A-updated") + aDone.await() + } + } + val jobB = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(message = "B") { bDone.await() } + } + + assertEquals("B", (manager.progress.value as ViewModelProgress.Active).message) + + aUpdated.complete(Unit) + assertEquals("A-updated", (manager.progress.value as ViewModelProgress.Active).message) + + aDone.complete(Unit) + bDone.complete(Unit) + jobA.join() + jobB.join() + } + + @Test + fun `cancellability drops when last cancellable op ends`() = + runTest { + val manager = ProgressManager() + val cancellableDone = CompletableDeferred() + val nonCancellableDone = CompletableDeferred() + + val cancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { }) { cancellableDone.await() } + } + val nonCancellableJob = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress { nonCancellableDone.await() } + } + + assertEquals(true, (manager.progress.value as ViewModelProgress.Active).cancellable) + + cancellableDone.complete(Unit) + cancellableJob.join() + assertEquals(false, (manager.progress.value as ViewModelProgress.Active).cancellable) + + nonCancellableDone.complete(Unit) + nonCancellableJob.join() + } + + @Test + fun `cancellability stays true when one of many cancellable ops ends`() = + runTest { + val manager = ProgressManager() + val done1 = CompletableDeferred() + val done2 = CompletableDeferred() + + val job1 = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { }) { done1.await() } + } + val job2 = + launch(UnconfinedTestDispatcher(testScheduler)) { + manager.withProgress(onCancel = { }) { done2.await() } + } + + done1.complete(Unit) + job1.join() + + assertEquals(true, (manager.progress.value as ViewModelProgress.Active).cancellable) + + done2.complete(Unit) + job2.join() + } +}