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() + } +}