diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/GplayServiceUnavailableException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/GplayServiceUnavailableException.kt
index 6ad7b3af..2997ae6a 100644
--- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/GplayServiceUnavailableException.kt
+++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/GplayServiceUnavailableException.kt
@@ -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"
+ }
}
diff --git a/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt b/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt
index 7b2c5d9f..54906c90 100644
--- a/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt
+++ b/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt
@@ -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
+ },
)
}
diff --git a/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt b/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt
index e87ba4f9..84192194 100644
--- a/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt
+++ b/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt
@@ -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"
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ecb948dd..7b4e8f61 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -11,6 +11,7 @@
Donate
Check
Close
+ Dismiss
Edit
Save
Guide
diff --git a/app/src/testGplay/java/eu/darken/capod/common/error/ComposeErrorDialogTest.kt b/app/src/testGplay/java/eu/darken/capod/common/error/ComposeErrorDialogTest.kt
new file mode 100644
index 00000000..01cc58d2
--- /dev/null
+++ b/app/src/testGplay/java/eu/darken/capod/common/error/ComposeErrorDialogTest.kt
@@ -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()
+
+ private val context: Context
+ get() = ApplicationProvider.getApplicationContext()
+
+ private class FakeErrorSource : ErrorEventSource2 {
+ override val errorEvents = SingleEventFlow()
+ }
+
+ 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()
+ }
+}