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
44 changes: 20 additions & 24 deletions AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.ichi2.anki.CrashReportData.HelpAction
import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink
import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions
import com.ichi2.anki.common.annotations.UseContextParameter
import com.ichi2.anki.dialogs.ProgressCompat
import com.ichi2.anki.exception.StorageAccessException
import com.ichi2.anki.libanki.Collection
import com.ichi2.anki.pages.DeckOptionsDestination
Expand Down Expand Up @@ -417,29 +418,28 @@ suspend fun <T> Fragment.withProgress(
block: suspend () -> T,
): T = requireActivity().withProgress(messageId, block)

@Suppress("Deprecation") // ProgressDialog deprecation
suspend fun <T> withProgressDialog(
suspend fun <T> withBlockingProgress(
context: Activity,
onCancel: (() -> Unit)?,
delayMillis: Long = 600,
@StringRes manualCancelButton: Int? = null,
op: suspend (android.app.ProgressDialog) -> T,
op: suspend (ProgressCompat) -> T,
): T =
coroutineScope {
val dialog =
android.app.ProgressDialog(context, R.style.AppCompatProgressDialogStyle).apply {
setCancelable(onCancel != null)
ProgressCompat(context).apply {
if (manualCancelButton != null) {
setCancelable(false)
setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(manualCancelButton)) { _, _ ->
Timber.i("Progress dialog cancelled via cancel button")
onCancel?.let { it() }
onCancel?.invoke()
}
} else {
onCancel?.let {
setCancelable(onCancel != null)
onCancel?.let { action ->
setOnCancelListener {
Timber.i("Progress dialog cancelled via cancel listener")
it()
action()
}
}
}
Expand All @@ -454,38 +454,34 @@ suspend fun <T> withProgressDialog(
launch {
delay(delayMillis)
if (!AnkiDroidApp.instance.progressDialogShown) {
Timber.i(
"""Displaying progress dialog: ${delayMillis}ms elapsed;
|cancellable: ${onCancel != null};
|manualCancel: ${manualCancelButton != null}
|
""".trimMargin(),
)
Timber.i("Displaying progress dialog: ${delayMillis}ms elapsed")
dialog.show()
AnkiDroidApp.instance.progressDialogShown = true
dialogIsOurs = true
} else {
Timber.w(
"""A progress dialog is already displayed, not displaying progress dialog:
|cancellable: ${onCancel != null};
|manualCancel: ${manualCancelButton != null}
|
""".trimMargin(),
)
Timber.w("A progress dialog is already displayed; skipping new dialog.")
}
}
try {
op(dialog)
} finally {
dialogJob.cancel()
dismissDialogIfShowing(dialog)
dialog.dismiss()
context.runOnUiThread { context.window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) }
if (dialogIsOurs) {
AnkiDroidApp.instance.progressDialogShown = false
}
}
}

suspend fun <T> withProgressDialog(
context: Activity,
onCancel: (() -> Unit)?,
delayMillis: Long = 600,
@StringRes manualCancelButton: Int? = null,
op: suspend (ProgressCompat) -> T,
): T = withBlockingProgress(context, onCancel, delayMillis, manualCancelButton, op)

private fun dismissDialogIfShowing(dialog: Dialog) {
try {
if (dialog.isShowing) {
Expand Down Expand Up @@ -533,7 +529,7 @@ data class ProgressContext(
)

@Suppress("Deprecation") // ProgressDialog deprecation
private fun ProgressContext.updateDialog(dialog: android.app.ProgressDialog) {
private fun ProgressContext.updateDialog(dialog: ProgressCompat) {
// ideally this would show a progress bar, but MaterialDialog does not support
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this not fixed?

// setting progress after starting with indeterminate progress, so we just use
// this for now
Expand Down
159 changes: 159 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProgressCompat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Shaan Narendran <[email protected]>
*
* 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.dialogs

import android.content.Context
import android.content.DialogInterface
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.ichi2.anki.R

/**
* Replacement for [android.app.ProgressDialog] deprecation on API 26+. This class should be used
* instead of the platform [android.app.ProgressDialog].
*
* @see android.app.ProgressDialog
*/
class ProgressCompat(
private val context: Context,
) {
companion object {
private val PROGRESS_PATTERN = Regex("(\\d+)\\s*/\\s*(\\d+)")
}

private var dialog: AlertDialog? = null
private var circularIndicator: CircularProgressIndicator? = null
private var messageView: TextView? = null
private var pendingMessage: CharSequence? = null
private var negativeButtonAction: Pair<String, (DialogInterface, Int) -> Unit>? = null
private var onCancelListener: DialogInterface.OnCancelListener? = null
private var isCancelable = false
private var max = 100
private var progress = 0

private val handler by lazy { Handler(Looper.getMainLooper()) }

private inline fun runOnUi(crossinline action: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) action() else handler.post { action() }
}

fun setCancelable(flag: Boolean) {
isCancelable = flag
}

fun setOnCancelListener(listener: DialogInterface.OnCancelListener?) {
onCancelListener = listener
}

fun setButton(
whichButton: Int,
text: CharSequence,
listener: (DialogInterface, Int) -> Unit,
) {
if (whichButton == DialogInterface.BUTTON_NEGATIVE) {
negativeButtonAction = text.toString() to listener
}
}

/**
* Updates the message text.
*
* This method attempts to parse progress numbers from the [message] string.
* If the message contains a pattern like "50 / 100", the progress bar will
* automatically update to match those values.
*
* @param message The text to display, or null to clear.
*/
fun setMessage(message: CharSequence?) =
runOnUi {
pendingMessage = message
messageView?.text = message

message?.let { msg ->
PROGRESS_PATTERN.find(msg)?.destructured?.let { (curr, total) ->
runCatching {
val t = total.toInt()
if (max != t) setMaxInternal(t)
setProgressInternal(curr.toInt())
}
return@runOnUi
}
// If no digits found, ensure we are in indeterminate mode (spinner)
if (circularIndicator?.isIndeterminate == false && progress == 0) {
circularIndicator?.isIndeterminate = true
}
}
}

fun setMax(max: Int) = runOnUi { setMaxInternal(max) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused variable


private fun setMaxInternal(max: Int) {
this.max = max
circularIndicator?.max = max
}

fun setProgress(value: Int) = runOnUi { setProgressInternal(value) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused variable


private fun setProgressInternal(value: Int) {
progress = value
circularIndicator?.apply {
if (isIndeterminate) isIndeterminate = false
setProgressCompat(value, true)
}
}

fun incrementProgressBy(diff: Int) = runOnUi { setProgressInternal(progress + diff) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused variable


var isIndeterminate: Boolean
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused variable

get() = circularIndicator?.isIndeterminate ?: false
set(value) = runOnUi { circularIndicator?.isIndeterminate = value }

fun show() =
runOnUi {
if (dialog?.isShowing == true) return@runOnUi

val view = LayoutInflater.from(context).inflate(R.layout.dialog_circular_progress, null)
circularIndicator =
view.findViewById<CircularProgressIndicator>(R.id.circular_progress).apply {
isIndeterminate = false
max = this@ProgressCompat.max
setProgressCompat(progress, false)
}
messageView = view.findViewById<TextView>(R.id.progress_message).apply { text = pendingMessage }

dialog =
MaterialAlertDialogBuilder(context)
.setView(view)
.setCancelable(isCancelable)
.setOnCancelListener(onCancelListener)
.apply {
negativeButtonAction?.let { (text, listener) -> setNegativeButton(text, listener) }
}.create()

dialog?.show()
}

fun dismiss() =
runOnUi {
runCatching { dialog?.dismiss() }
}
}
42 changes: 42 additions & 0 deletions AnkiDroid/src/main/res/layout/dialog_circular_progress.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025 Shaan Narendran <[email protected]>
~
~ 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/>.
-->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="24dp"
android:gravity="center_vertical">

<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/circular_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
app:indicatorSize="40dp"
app:trackThickness="4dp"
android:max="100" />

<TextView
android:id="@+id/progress_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dialog_processing"
android:textAppearance="?attr/textAppearanceBody1" />

</LinearLayout>