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, ) }