PM-21110: Add a generate crash button to the debug menu (#5125)

This commit is contained in:
David Perez
2025-05-06 13:51:03 -05:00
committed by GitHub
parent e1f432ea5d
commit f932682949
8 changed files with 202 additions and 6 deletions

View File

@@ -15,7 +15,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_USER_DICTIONARY"/>
<uses-permission android:name="android.permission.READ_USER_DICTIONARY" />
<!-- Protect access to AuthenticatorBridgeService using this custom permission.
Note that each build type uses a different value for knownCerts.
@@ -310,6 +310,14 @@
android:exported="true"
android:permission="${applicationId}.permission.AUTHENTICATOR_BRIDGE_SERVICE" />
<!-- Firebase SDK initOrder is 100. We use a higher order to initialize first -->
<provider
android:name=".data.platform.contentprovider.UncaughtErrorLoggingContentProvider"
android:authorities="${applicationId}"
android:exported="false"
android:grantUriPermissions="false"
android:initOrder="101" />
</application>
<queries>

View File

@@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.platform.contentprovider
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import com.bitwarden.core.annotation.OmitFromCoverage
import timber.log.Timber
/**
* [ContentProvider] for setting up uncaught error logging.
*
* This allows us to play nice with Crashlytics since it is also instantiated as
* a content provider and has it's own uncaught exception handler.
*/
@OmitFromCoverage
class UncaughtErrorLoggingContentProvider : ContentProvider() {
override fun onCreate(): Boolean {
val defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
Timber.e(exception, "Uncaught exception")
defaultUncaughtExceptionHandler?.uncaughtException(thread, exception)
}
return true
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
}

View File

@@ -127,6 +127,37 @@ fun DebugMenuScreen(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(R.string.error_reports),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(R.string.generate_error_report),
onClick = remember(viewModel) {
{ viewModel.trySendAction(DebugMenuAction.GenerateErrorReportClick) }
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(R.string.generate_crash),
onClick = remember(viewModel) {
{ viewModel.trySendAction(DebugMenuAction.GenerateCrashClick) }
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@@ -27,6 +28,7 @@ class DebugMenuViewModel @Inject constructor(
featureFlagManager: FeatureFlagManager,
private val debugMenuRepository: DebugMenuRepository,
private val authRepository: AuthRepository,
private val logsManager: LogsManager,
) : BaseViewModel<DebugMenuState, DebugMenuEvent, DebugMenuAction>(
initialState = DebugMenuState(featureFlags = persistentMapOf()),
) {
@@ -52,6 +54,8 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.RestartOnboarding -> handleResetOnboardingStatus()
DebugMenuAction.RestartOnboardingCarousel -> handleResetOnboardingCarousel()
DebugMenuAction.ResetCoachMarkTourStatuses -> handleResetCoachMarkTourStatuses()
DebugMenuAction.GenerateCrashClick -> handleCrashClick()
DebugMenuAction.GenerateErrorReportClick -> handleErrorReportClick()
}
}
@@ -59,6 +63,16 @@ class DebugMenuViewModel @Inject constructor(
debugMenuRepository.resetCoachMarkTourStatuses()
}
private fun handleCrashClick(): Nothing {
throw IllegalStateException("User has clicked the generate crash button")
}
private fun handleErrorReportClick() {
logsManager.trackNonFatalException(
throwable = IllegalStateException("User has clicked the generate error report button"),
)
}
private fun handleResetOnboardingCarousel() {
debugMenuRepository.modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger = {
@@ -118,8 +132,10 @@ sealed class DebugMenuAction {
/**
* Updates a feature flag for the given [FlagKey] to the given [newValue].
*/
data class UpdateFeatureFlag<T : Any>(val flagKey: FlagKey<T>, val newValue: T) :
DebugMenuAction()
data class UpdateFeatureFlag<T : Any>(
val flagKey: FlagKey<T>,
val newValue: T,
) : DebugMenuAction()
/**
* The user has clicked "back" button.
@@ -146,6 +162,16 @@ sealed class DebugMenuAction {
*/
data object ResetCoachMarkTourStatuses : DebugMenuAction()
/**
* The user has clicked generate crash button.
*/
data object GenerateCrashClick : DebugMenuAction()
/**
* The user has clicked generate error report button.
*/
data object GenerateErrorReportClick : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/

View File

@@ -28,5 +28,8 @@
<string name="enable_flight_recorder">Enable flight recorder</string>
<string name="restrict_item_deletion">Restrict item deletion</string>
<string name="enable_pre_auth_settings">Enabled pre-auth settings</string>
<string name="generate_crash">Generate crash</string>
<string name="generate_error_report">Generate error report</string>
<string name="error_reports">Error reports</string>
<!-- /Debug Menu -->
</resources>

View File

@@ -6,8 +6,13 @@ import io.mockk.every
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.assertThrows
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* Helper method for comparing JSON string and ignoring the formatting.
@@ -60,3 +65,38 @@ fun TestDispatcher.advanceTimeByAndRunCurrent(delayTimeMillis: Long) {
scheduler.advanceTimeBy(delayTimeMillis = delayTimeMillis)
scheduler.runCurrent()
}
/**
* This is for testing exceptions that are thrown in a different coroutine.
*
* You cannot wrap this in a `runTest`. The `runTest` invocation catches all uncaught exceptions
* and rethrows them. The [assertCoroutineThrows]'s tests will pass, but the outer `runTest`
* will throw an exception and cause the test to fail.
*
* Never do this:
* ```
* @Test
* fun test() = runTest {
* assertCoroutineThrows(Exception::class.java) {
* throw Exception("Something is wrong.")
* }
* }
* ```
*
* Always do this:
* @Test
* fun test() {
* assertCoroutineThrows(Exception::class.java) {
* throw Exception("Something is wrong.")
* }
* }
* ```
*
* Check this issue for more info: https://github.com/Kotlin/kotlinx.coroutines/issues/3889
*/
inline fun <reified T : Throwable> assertCoroutineThrows(
context: CoroutineContext = EmptyCoroutineContext,
noinline block: suspend TestScope.() -> Unit,
): T = assertThrows<T> {
runTest(context = context, testBody = block)
}

View File

@@ -4,10 +4,8 @@ import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.printToLog
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
@@ -49,7 +47,6 @@ class DebugMenuScreenTest : BaseComposeTest() {
@Test
fun `onNavigateBack should send action to viewModel`() {
composeTestRule.onRoot().printToLog("djf")
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
@@ -57,6 +54,26 @@ class DebugMenuScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(DebugMenuAction.NavigateBack) }
}
@Test
fun `on generate crash click should send GenerateCrashClick action`() {
composeTestRule
.onNodeWithText(text = "Generate crash")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.GenerateCrashClick) }
}
@Test
fun `on generate error report click should send GenerateErrorReportClick action`() {
composeTestRule
.onNodeWithText(text = "Generate error report")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.GenerateErrorReportClick) }
}
@Test
fun `feature flag content should not display if the state is empty`() {
composeTestRule

View File

@@ -3,8 +3,10 @@ package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.util.assertCoroutineThrows
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
@@ -42,6 +44,10 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
}
}
private val logsManager = mockk<LogsManager> {
every { trackNonFatalException(throwable = any()) } just runs
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
@@ -74,6 +80,23 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `GenerateCrashClick should throw an IllegalStateException`() {
val viewModel = createViewModel()
assertCoroutineThrows<IllegalStateException> {
viewModel.trySendAction(DebugMenuAction.GenerateCrashClick)
}
}
@Test
fun `GenerateErrorReportClick should log an IllegalStateException`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.GenerateErrorReportClick)
verify(exactly = 1) {
logsManager.trackNonFatalException(throwable = any())
}
}
@Test
fun `handleUpdateFeatureFlag should update the feature flag via the repository`() {
val viewModel = createViewModel()
@@ -116,6 +139,7 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository,
authRepository = mockAuthRepository,
logsManager = logsManager,
)
}