diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8c3a65798..d64c7fd87a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,6 +102,12 @@ android { lint { disable.add("MissingTranslation") } + @Suppress("UnstableApiUsage") + testOptions { + // Required for Robolectric + unitTests.isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true + } } kotlin { diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 015242325f..42c1c441da 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.bitwarden.authenticator.ui.platform.feature.settings +import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -102,6 +103,24 @@ fun SettingsScreen( SettingsEvent.NavigateToPrivacyPolicy -> { intentManager.launchUri("https://bitwarden.com/privacy".toUri()) } + + SettingsEvent.NavigateToBitwardenApp -> { + + intentManager.startActivity( + Intent( + Intent.ACTION_VIEW, + "bitwarden://settings/account_security".toUri(), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + + SettingsEvent.NavigateToBitwardenPlayStoreListing -> { + intentManager.launchUri( + "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(), + ) + } } } @@ -150,6 +169,12 @@ fun SettingsScreen( viewModel.trySendAction(SettingsAction.DataClick.BackupClick) } }, + onSyncWithBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) + } + }, + shouldShowSyncWithBitwardenApp = state.showSyncWithBitwarden, ) Spacer(modifier = Modifier.height(16.dp)) AppearanceSettings( @@ -237,11 +262,14 @@ private fun SecuritySettings( //region Data settings @Composable +@Suppress("LongMethod") private fun VaultSettings( modifier: Modifier = Modifier, onExportClick: () -> Unit, onImportClick: () -> Unit, onBackupClick: () -> Unit, + onSyncWithBitwardenClick: () -> Unit, + shouldShowSyncWithBitwardenApp: Boolean, ) { BitwardenListHeaderText( modifier = Modifier.padding(horizontal = 16.dp), @@ -294,6 +322,25 @@ private fun VaultSettings( dialogConfirmButtonText = stringResource(R.string.learn_more), dialogDismissButtonText = stringResource(R.string.ok), ) + if (shouldShowSyncWithBitwardenApp) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextRow( + text = stringResource(id = R.string.sync_with_bitwarden_app), + onClick = onSyncWithBitwardenClick, + modifier = modifier, + withDivider = true, + content = { + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource(id = R.drawable.ic_external_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + } } @Composable diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 7321472282..d6ab6afd7c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -7,7 +7,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.BuildConfig import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult import com.bitwarden.authenticator.ui.platform.base.BaseViewModel @@ -16,6 +18,8 @@ import com.bitwarden.authenticator.ui.platform.base.util.asText import com.bitwarden.authenticator.ui.platform.base.util.concat import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -34,16 +38,21 @@ private const val KEY_STATE = "state" class SettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, clock: Clock, + private val authenticatorBridgeManager: AuthenticatorBridgeManager, private val settingsRepository: SettingsRepository, private val clipboardManager: BitwardenClipboardManager, + featureFlagManager: FeatureFlagManager, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: createInitialState( - clock, - settingsRepository.appLanguage, - settingsRepository.appTheme, - settingsRepository.isUnlockWithBiometricsEnabled, - settingsRepository.isCrashLoggingEnabled, + clock = clock, + appLanguage = settingsRepository.appLanguage, + appTheme = settingsRepository.appTheme, + unlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled, + isSyncWithBitwardenFeatureEnabled = + featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync), + accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value, ), ) { override fun handleAction(action: SettingsAction) { @@ -131,6 +140,17 @@ class SettingsViewModel @Inject constructor( SettingsAction.DataClick.ExportClick -> handleExportClick() SettingsAction.DataClick.ImportClick -> handleImportClick() SettingsAction.DataClick.BackupClick -> handleBackupClick() + SettingsAction.DataClick.SyncWithBitwardenClick -> handleSyncWithBitwardenClick() + } + } + + private fun handleSyncWithBitwardenClick() { + when (authenticatorBridgeManager.accountSyncStateFlow.value) { + AccountSyncState.AppNotInstalled -> { + sendEvent(SettingsEvent.NavigateToBitwardenPlayStoreListing) + } + + else -> sendEvent(SettingsEvent.NavigateToBitwardenApp) } } @@ -228,15 +248,21 @@ class SettingsViewModel @Inject constructor( @Suppress("UndocumentedPublicClass") companion object { + @Suppress("LongParameterList") private fun createInitialState( clock: Clock, appLanguage: AppLanguage, appTheme: AppTheme, unlockWithBiometricsEnabled: Boolean, isSubmitCrashLogsEnabled: Boolean, + accountSyncState: AccountSyncState, + isSyncWithBitwardenFeatureEnabled: Boolean, ): SettingsState { val currentYear = Year.now(clock) val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText() + // Show sync with Bitwarden row if feature is enabled and the OS is supported: + val shouldShowSyncWithBitwarden = isSyncWithBitwardenFeatureEnabled && + accountSyncState != AccountSyncState.OsVersionNotSupported return SettingsState( appearance = SettingsState.Appearance( language = appLanguage, @@ -249,6 +275,7 @@ class SettingsViewModel @Inject constructor( .asText() .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), copyrightInfo = copyrightInfo, + showSyncWithBitwarden = shouldShowSyncWithBitwarden, ) } } @@ -262,6 +289,7 @@ data class SettingsState( val appearance: Appearance, val isUnlockWithBiometricsEnabled: Boolean, val isSubmitCrashLogsEnabled: Boolean, + val showSyncWithBitwarden: Boolean, val dialog: Dialog?, val version: Text, val copyrightInfo: Text, @@ -325,6 +353,16 @@ sealed class SettingsEvent { * Navigate to the privacy policy web page. */ data object NavigateToPrivacyPolicy : SettingsEvent() + + /** + * Navigate to the Bitwarden account settings. + */ + data object NavigateToBitwardenApp : SettingsEvent() + + /** + * Navigate to the Bitwarden Play Store listing. + */ + data object NavigateToBitwardenPlayStoreListing : SettingsEvent() } /** @@ -376,6 +414,11 @@ sealed class SettingsAction( * Indicates the user click backup. */ data object BackupClick : DataClick() + + /** + * Indicates the user clicked sync with Bitwarden. + */ + data object SyncWithBitwardenClick : DataClick() } /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fcab4d7a5..cdf9d6754c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,4 +124,5 @@ Download the Bitwarden app Store all of your logins and sync verification codes directly with the Authenticator app. Download + Sync with Bitwarden app diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseComposeTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseComposeTest.kt new file mode 100644 index 0000000000..583388d75b --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseComposeTest.kt @@ -0,0 +1,59 @@ +package com.bitwarden.authenticator.ui.platform.base + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createComposeRule +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme +import org.junit.Rule + +/** + * A base class that can be used for performing Compose-layer testing using Robolectric, Compose + * Testing, and JUnit 4. + */ +abstract class BaseComposeTest : BaseRobolectricTest() { + + @get:Rule + val composeTestRule = createComposeRule() + + /** + * instance of [OnBackPressedDispatcher] made available if testing using + * + * [setContentWithBackDispatcher] or [runTestWithTheme] + */ + var backDispatcher: OnBackPressedDispatcher? = null + private set + + /** + * Helper for testing a basic Composable function that only requires a Composable environment + * with the [BitwardenTheme]. + */ + protected fun runTestWithTheme( + theme: AppTheme, + test: @Composable () -> Unit, + ) { + composeTestRule.setContent { + AuthenticatorTheme( + theme = theme, + ) { + backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + test() + } + } + } + + /** + * Helper for testing a basic Composable function that provides access to a + * [OnBackPressedDispatcher]. + * + * Use if the [Composable] function being tested uses a [BackHandler] + */ + protected fun setContentWithBackDispatcher(test: @Composable () -> Unit) { + composeTestRule.setContent { + backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + test() + } + } +} diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseRobolectricTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseRobolectricTest.kt new file mode 100644 index 0000000000..745ee9cd8b --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/base/BaseRobolectricTest.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.ui.platform.base + +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +/** + * A base class that can be used for performing tests that use Robolectric and JUnit 4. + */ +@Config( + application = HiltTestApplication::class, + sdk = [Config.NEWEST_SDK], +) +@RunWith(RobolectricTestRunner::class) +abstract class BaseRobolectricTest { + init { + ShadowLog.stream = System.out + } +} diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt new file mode 100644 index 0000000000..761f35265c --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -0,0 +1,128 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings + +import android.content.Intent +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.core.net.toUri +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.concat +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.junit.Test +import org.junit.Before +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals + +class SettingsScreenTest : BaseComposeTest() { + + private var onNavigateToTutorialCalled = false + private var onNaviateToExportCalled = false + private var onNavigateToImportCalled = false + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + val viewModel: SettingsViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + private val biometricsManager: BiometricsManager = mockk { + every { isBiometricsSupported } returns true + } + private val intentManager: IntentManager = mockk() + + @Before + fun setup() { + composeTestRule.setContent { + SettingsScreen( + viewModel = viewModel, + biometricsManager = biometricsManager, + intentManager = intentManager, + onNavigateToTutorial = { onNavigateToTutorialCalled = true }, + onNavigateToExport = { onNaviateToExportCalled = true }, + onNavigateToImport = { onNavigateToImportCalled = true }, + ) + } + } + + @Test + fun `Sync with Bitwarden row should be hidden when showSyncWithBitwarden is false`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + showSyncWithBitwarden = false, + ) + composeTestRule.onNodeWithText("Sync with Bitwarden app").assertDoesNotExist() + } + + @Test + fun `Sync with Bitwarden row click should send SyncWithBitwardenClick action`() { + composeTestRule + .onNodeWithText("Sync with Bitwarden app") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) } + } + + @Test + fun `on NavigateToBitwardenApp receive should launch bitwarden account security deep link`() { + every { intentManager.startActivity(any()) } just runs + val intentSlot = slot() + val expectedIntent = Intent( + Intent.ACTION_VIEW, + "bitwarden://settings/account_security".toUri(), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + mutableEventFlow.tryEmit(SettingsEvent.NavigateToBitwardenApp) + verify { intentManager.startActivity(capture(intentSlot)) } + assertEquals( + expectedIntent.data, + intentSlot.captured.data, + ) + assertEquals( + expectedIntent.flags, + intentSlot.captured.flags, + ) + } + + @Test + fun `on NavigateToBitwardenPlayStoreListing receive launch Bitwarden Play Store URI`() { + every { intentManager.launchUri(any()) } just runs + mutableEventFlow.tryEmit(SettingsEvent.NavigateToBitwardenPlayStoreListing) + verify { + intentManager.launchUri( + "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(), + ) + } + } +} + +private val APP_LANGUAGE = AppLanguage.ENGLISH +private val APP_THEME = AppTheme.DEFAULT +private val DEFAULT_STATE = SettingsState( + appearance = SettingsState.Appearance( + APP_LANGUAGE, + APP_THEME, + ), + isSubmitCrashLogsEnabled = true, + isUnlockWithBiometricsEnabled = true, + showSyncWithBitwarden = true, + dialog = null, + version = R.string.version.asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), +) diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000000..4482dee5c0 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -0,0 +1,156 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager +import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.concat +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class SettingsViewModelTest : BaseViewModelTest() { + + private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk { + every { accountSyncStateFlow } returns MutableStateFlow(AccountSyncState.Loading) + } + private val settingsRepository: SettingsRepository = mockk { + every { appLanguage } returns APP_LANGUAGE + every { appTheme } returns APP_THEME + every { isUnlockWithBiometricsEnabled } returns true + every { isCrashLoggingEnabled } returns true + } + private val clipboardManager: BitwardenClipboardManager = mockk() + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(LocalFeatureFlag.PasswordManagerSync) } returns true + } + + @Test + @Suppress("MaxLineLength") + fun `initialState should be correct when saved state is null and password manager feature flag is off`() { + every { + featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync) + } returns false + val viewModel = createViewModel(savedState = null) + val expectedState = DEFAULT_STATE.copy( + showSyncWithBitwarden = false, + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `initialState should be correct when saved state is null and password manager feature flag is on but OS version is too low`() { + every { + authenticatorBridgeManager.accountSyncStateFlow + } returns MutableStateFlow(AccountSyncState.OsVersionNotSupported) + val viewModel = createViewModel(savedState = null) + val expectedState = DEFAULT_STATE.copy( + showSyncWithBitwarden = false, + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `initialState should be correct when saved state is null and password manager feature flag is on and OS version is supported`() { + every { + authenticatorBridgeManager.accountSyncStateFlow + } returns MutableStateFlow(AccountSyncState.Loading) + every { + featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync) + } returns true + val viewModel = createViewModel(savedState = null) + val expectedState = DEFAULT_STATE.copy( + showSyncWithBitwarden = true, + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `on SyncWithBitwardenClick receive with AccountSyncState AppNotInstalled should emit NavigateToBitwardenPlayStoreListing`() = + runTest { + every { + authenticatorBridgeManager.accountSyncStateFlow + } returns MutableStateFlow(AccountSyncState.AppNotInstalled) + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) + assertEquals( + SettingsEvent.NavigateToBitwardenPlayStoreListing, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SyncWithBitwardenClick receive with AccountSyncState not AppNotInstalled should emit NavigateToBitwardenApp`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) + assertEquals( + SettingsEvent.NavigateToBitwardenApp, + awaitItem(), + ) + } + } + + private fun createViewModel( + savedState: SettingsState? = DEFAULT_STATE, + ) = SettingsViewModel( + savedStateHandle = SavedStateHandle().apply { this["state"] = savedState }, + clock = CLOCK, + authenticatorBridgeManager = authenticatorBridgeManager, + settingsRepository = settingsRepository, + clipboardManager = clipboardManager, + featureFlagManager = featureFlagManager, + ) +} + +private val APP_LANGUAGE = AppLanguage.ENGLISH +private val APP_THEME = AppTheme.DEFAULT +private val CLOCK = Clock.fixed( + Instant.parse("2024-10-12T12:00:00Z"), + ZoneOffset.UTC, +) +private val DEFAULT_STATE = SettingsState( + appearance = SettingsState.Appearance( + APP_LANGUAGE, + APP_THEME, + ), + isSubmitCrashLogsEnabled = true, + isUnlockWithBiometricsEnabled = true, + showSyncWithBitwarden = true, + dialog = null, + version = R.string.version.asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), +)