fix(error): Let the error dialog dismiss instead of only acknowledging

LocalizedError can now carry a fix action, and the shared error dialog shows
it next to a Dismiss button when one is present; errors without a fix keep
today's OK-only shape. The Google Play billing-unavailable error uses it to
open Play's app info as a generic troubleshooting affordance.
This commit is contained in:
darken
2026-08-03 20:28:01 +02:00
committed by Matthias Urhahn
parent 65f7cbe8b7
commit 375af943ae
5 changed files with 176 additions and 3 deletions
@@ -1,7 +1,14 @@
package eu.darken.capod.common.upgrade.core.billing
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
@@ -12,5 +19,28 @@ class GplayServiceUnavailableException(cause: Throwable) :
throwable = this,
label = context.getString(R.string.upgrades_gplay_unavailable_error),
description = context.getString(R.string.upgrades_gplay_unavailable_error_description),
// Deliberately untranslated brand name.
fixActionLabel = "Google Play",
// BillingManager also maps transient timeout/network failures onto this exception, so the
// action is a GENERIC troubleshooting affordance (open Play's app info), not a diagnosis of
// the cause. Harmless for a transient blip, and it matches the fleet's dialog.
fixAction = { activity ->
try {
val intent = Intent().apply {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
data = Uri.fromParts("package", GPLAY_PKG, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(intent)
} catch (e: ActivityNotFoundException) {
log(ERROR) { "Can't launch settings intent for Google Play: $e" }
Toast.makeText(activity, "Google Play is not installed", Toast.LENGTH_SHORT).show()
}
},
)
companion object {
private const val GPLAY_PKG = "com.android.vending"
}
}
@@ -1,5 +1,6 @@
package eu.darken.capod.common.error
import android.app.Activity
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -11,6 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
@Composable
fun ErrorEventHandler(source: ErrorEventSource2) {
@@ -37,14 +39,39 @@ private fun ComposeErrorDialog(
// messages like Play Billing's internal debugMessage.
val localizedError = remember(throwable, context) { throwable.localized(context) }
val activity = context as? Activity
// An error that offers a way out gets its own action plus a dismiss; everything else keeps the
// acknowledge-only shape. The activity is required to launch the fix.
val hasFix = localizedError.fixAction != null && activity != null
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = localizedError.label) },
text = { Text(text = localizedError.description) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.ok))
if (hasFix) {
TextButton(
onClick = {
localizedError.fixAction!!.invoke(activity!!)
onDismiss()
},
) {
Text(text = localizedError.fixActionLabel ?: stringResource(android.R.string.ok))
}
} else {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.ok))
}
}
},
dismissButton = if (hasFix) {
{
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_dismiss_action))
}
}
} else {
null
},
)
}
@@ -1,5 +1,6 @@
package eu.darken.capod.common.error
import android.app.Activity
import android.content.Context
import eu.darken.capod.R
@@ -10,7 +11,9 @@ interface HasLocalizedError {
data class LocalizedError(
val throwable: Throwable,
val label: String,
val description: String
val description: String,
val fixActionLabel: String? = null,
val fixAction: ((Activity) -> Unit)? = null,
) {
fun asText() = "$label:\n$description"
}
+1
View File
@@ -11,6 +11,7 @@
<string name="general_donate_action">Donate</string>
<string name="general_check_action">Check</string>
<string name="general_close_action">Close</string>
<string name="general_dismiss_action">Dismiss</string>
<string name="general_edit_action">Edit</string>
<string name="general_save_action">Save</string>
<string name="general_guide_action">Guide</string>
@@ -0,0 +1,112 @@
package eu.darken.capod.common.error
import android.content.Context
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
/**
* The shared error dialog: an error that offers a way out gets its own action plus a dismiss, and
* everything else must keep the acknowledge-only shape — [ErrorEventHandler] backs every screen.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class ComposeErrorDialogTest : BaseTest() {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private class FakeErrorSource : ErrorEventSource2 {
override val errorEvents = SingleEventFlow<Throwable>()
}
private fun showError(error: Throwable) {
val source = FakeErrorSource()
composeRule.setContent {
PreviewWrapper {
ErrorEventHandler(source)
}
}
// Buffered channel: the event survives until the handler's collector attaches.
source.emitBlocking(error)
composeRule.waitForIdle()
}
@Test
fun `a fixable error offers its action next to a dismiss`() {
showError(GplayServiceUnavailableException(RuntimeException("Play hiccup")))
composeRule.onNodeWithText(context.getString(R.string.upgrades_gplay_unavailable_error)).assertExists()
composeRule.onNodeWithText("Google Play").assertExists()
composeRule.onNodeWithText(context.getString(R.string.general_dismiss_action)).assertExists()
// Nothing is being cancelled or merely acknowledged here.
composeRule.onAllNodesWithText(context.getString(R.string.general_cancel_action)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(android.R.string.ok)).assertCountEquals(0)
}
@Test
fun `the fix action opens Google Play's app info and closes the dialog`() {
showError(GplayServiceUnavailableException(RuntimeException("Play hiccup")))
composeRule.onNodeWithText("Google Play").performClick()
composeRule.waitForIdle()
val started = shadowOf(composeRule.activity).nextStartedActivity.shouldNotBeNull()
started.action shouldBe Settings.ACTION_APPLICATION_DETAILS_SETTINGS
started.data.toString() shouldBe "package:com.android.vending"
// The user acted: leaving the dialog up would greet them again on the way back.
composeRule.onAllNodesWithText("Google Play").assertCountEquals(0)
}
@Test
fun `dismissing closes the dialog without launching anything`() {
showError(GplayServiceUnavailableException(RuntimeException("Play hiccup")))
composeRule.onNodeWithText(context.getString(R.string.general_dismiss_action)).performClick()
composeRule.waitForIdle()
composeRule.onAllNodesWithText(context.getString(R.string.general_dismiss_action)).assertCountEquals(0)
shadowOf(composeRule.activity).nextStartedActivity shouldBe null
}
@Test
fun `an ordinary error keeps the acknowledge-only dialog`() {
// The handler is shared by every screen: the fix/dismiss pair must stay exclusive to errors
// that actually carry a fix action.
showError(RuntimeException("something went wrong"))
composeRule.onNodeWithText(context.getString(android.R.string.ok)).assertExists()
composeRule.onAllNodesWithText(context.getString(R.string.general_dismiss_action)).assertCountEquals(0)
composeRule.onAllNodesWithText("Google Play").assertCountEquals(0)
}
@Test
fun `the unavailable-billing error carries a fix action`() {
val localized = GplayServiceUnavailableException(RuntimeException("Play hiccup")).getLocalizedError(context)
localized.fixActionLabel shouldBe "Google Play"
localized.fixAction.shouldNotBeNull()
}
}