mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
+30
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user