diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5972f16e33..f5e4eaa918 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,7 +15,7 @@
-
+
+
+
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/contentprovider/UncaughtErrorLoggingContentProvider.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/contentprovider/UncaughtErrorLoggingContentProvider.kt
new file mode 100644
index 0000000000..b024dfe3b2
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/contentprovider/UncaughtErrorLoggingContentProvider.kt
@@ -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?,
+ selection: String?,
+ selectionArgs: Array?,
+ 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?): Int = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = 0
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt
index 469a7ac4ad..be9dbec23d 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt
@@ -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())
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt
index eca9c602cb..d3ed80bf9e 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt
@@ -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(
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(val flagKey: FlagKey, val newValue: T) :
- DebugMenuAction()
+ data class UpdateFeatureFlag(
+ val flagKey: FlagKey,
+ 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.
*/
diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml
index 212c0c69e8..2b9ba447b9 100644
--- a/app/src/main/res/values/strings_non_localized.xml
+++ b/app/src/main/res/values/strings_non_localized.xml
@@ -28,5 +28,8 @@
Enable flight recorder
Restrict item deletion
Enabled pre-auth settings
+ Generate crash
+ Generate error report
+ Error reports
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt
index 3bad034fe2..a69773edb9 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt
@@ -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 assertCoroutineThrows(
+ context: CoroutineContext = EmptyCoroutineContext,
+ noinline block: suspend TestScope.() -> Unit,
+): T = assertThrows {
+ runTest(context = context, testBody = block)
+}
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt
index b85576eb1d..dbe8716549 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt
@@ -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
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
index 8aa1aaac96..a89a2bae7d 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
@@ -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 {
+ 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 {
+ 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,
)
}