mirror of
https://github.com/bitwarden/android.git
synced 2026-05-10 05:49:44 -05:00
PM-21110: Add a generate crash button to the debug menu (#5125)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user