From 628cb120813ca373c26e56eda26bb354bb196874 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 18 Jul 2025 09:56:01 -0500 Subject: [PATCH 1/8] VULN-261: Filter out send intents that use our own content provider (#5539) --- .../manager/intent/IntentManagerImpl.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index 3e32a01880..a3a2991dfe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -119,7 +119,7 @@ class IntentManagerImpl( try { val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE) .apply { - data = Uri.parse("package:${context.packageName}") + data = "package:${context.packageName}".toUri() } context.startActivity(intent) true @@ -129,7 +129,7 @@ class IntentManagerImpl( override fun startApplicationDetailsSettingsActivity() { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.parse("package:" + context.packageName) + intent.data = "package:${context.packageName}".toUri() startActivity(intent = intent) } @@ -210,8 +210,21 @@ class IntentManagerImpl( return if (uri != null) getLocalFileData(uri) else getCameraFileData() } - override fun getFileDataFromIntent(intent: Intent): IntentManager.FileData? = - intent.clipData?.getItemAt(0)?.uri?.let { getLocalFileData(it) } + override fun getFileDataFromIntent( + intent: Intent, + ): IntentManager.FileData? = intent + .clipData + ?.getItemAt(0) + ?.uri + ?.takeUnless { uri -> + val uriString = uri.toString() + context + .packageManager + .getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PROVIDERS) + .providers + ?.any { uriString.contains(other = it.authority) } == true + } + ?.let { getLocalFileData(it) } override fun getShareDataFromIntent(intent: Intent): IntentManager.ShareData? { if (intent.action != Intent.ACTION_SEND) return null From 25680f92555d88f6ca7450a3fdc7c5038633c2ed Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 18 Jul 2025 10:19:55 -0500 Subject: [PATCH 2/8] PM-18405: Update the AboutScreen copy info (#5538) --- app/build.gradle.kts | 7 +++- .../data/platform/util/BuildConfigUtils.kt | 5 +++ .../feature/settings/about/AboutScreen.kt | 2 ++ .../feature/settings/about/AboutViewModel.kt | 32 +++++++++++++++---- app/src/main/res/values/strings.xml | 1 - .../feature/settings/about/AboutScreenTest.kt | 2 ++ .../settings/about/AboutViewModelTest.kt | 13 ++++++++ 7 files changed, 54 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ad12f5b0f..4ecadf6df0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,7 +65,12 @@ android { buildConfigField( type = "String", name = "CI_INFO", - value = "${ciProperties.getOrDefault("ci.info", "\"local\"")}", + value = "${ciProperties.getOrDefault("ci.info", "\"\uD83D\uDCBB local\"")}", + ) + buildConfigField( + type = "String", + name = "SDK_VERSION", + value = "\"${libs.versions.bitwardenSdk.get()}\"", ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/BuildConfigUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/BuildConfigUtils.kt index a354d4ce43..f5bf969d6b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/BuildConfigUtils.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/BuildConfigUtils.kt @@ -21,6 +21,11 @@ val isDevBuild: Boolean val versionData: String get() = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" +/** + * A string that represents a displayable SDK version. + */ +val sdkData: String get() = BuildConfig.SDK_VERSION + /** * A string that represents device data. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt index 4574a59e7b..f47320c1bb 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt @@ -340,6 +340,8 @@ private fun AboutScreenContent_preview() { AboutScreenContent( state = AboutState( version = "Version: 1.0.0 (1)".asText(), + sdkVersion = "\uD83E\uDD80 SDK: 1.0.0-20250708.105256-238".asText(), + serverData = "\uD83C\uDF29 Server: 2025.7.1 @ US".asText(), deviceData = "device_data".asText(), ciData = "ci_data".asText(), isSubmitCrashLogsEnabled = false, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt index b92a40893e..e603342cd3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt @@ -3,12 +3,12 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.data.repository.ServerConfigRepository import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat -import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.util.ciBuildInfo import com.x8bit.bitwarden.data.platform.util.deviceData import com.x8bit.bitwarden.data.platform.util.isFdroid +import com.x8bit.bitwarden.data.platform.util.sdkData import com.x8bit.bitwarden.data.platform.util.versionData import com.x8bit.bitwarden.ui.platform.feature.settings.about.util.getStopsLoggingStringForActiveLog import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,10 +44,22 @@ class AboutViewModel @Inject constructor( private val logsManager: LogsManager, private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, + serverConfigRepository: ServerConfigRepository, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] - ?: AboutState( - version = R.string.version.asText().concat(": $versionData".asText()), + initialState = savedStateHandle[KEY_STATE] ?: run { + val serverData = serverConfigRepository.serverConfigStateFlow.value?.serverData + AboutState( + version = "Version: $versionData".asText(), + sdkVersion = "\uD83E\uDD80 SDK: $sdkData".asText(), + serverData = StringBuilder() + .append("\uD83C\uDF29 Server:") + .apply { + serverData?.server?.name?.let { append(" $it") } + serverData?.version?.let { append(" $it") } + serverData?.environment?.cloudRegion?.let { append(" @ $it") } + } + .toString() + .asText(), deviceData = deviceData.asText(), ciData = ciBuildInfo?.let { "\n$it" }.orEmpty().asText(), isSubmitCrashLogsEnabled = logsManager.isEnabled, @@ -58,7 +71,8 @@ class AboutViewModel @Inject constructor( .flightRecorderData .getStopsLoggingStringForActiveLog(clock = clock), copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(clock).value}".asText(), - ), + ) + }, ) { init { stateFlow @@ -136,7 +150,11 @@ class AboutViewModel @Inject constructor( .concat(state.version) .concat("\n".asText()) .concat(state.deviceData) - .concat(state.ciData), + .concat(state.ciData) + .concat("\n".asText()) + .concat(state.sdkVersion) + .concat("\n".asText()) + .concat(state.serverData), ) } @@ -171,6 +189,8 @@ class AboutViewModel @Inject constructor( @Parcelize data class AboutState( val version: Text, + val sdkVersion: Text, + val serverData: Text, val deviceData: Text, val ciData: Text, val isSubmitCrashLogsEnabled: Boolean, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ac1860717..e6d04bb10b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,7 +68,6 @@ %1$s copied Verify master password Verify PIN - Version View Yes Account diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt index bbb8e30bab..4be712b2ad 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt @@ -324,6 +324,8 @@ class AboutScreenTest : BitwardenComposeTest() { private val DEFAULT_STATE = AboutState( version = "Version: 1.0.0 (1)".asText(), + sdkVersion = "\uD83E\uDD80 SDK: 1.0.0-20250708.105256-238".asText(), + serverData = "\uD83C\uDF29 Server: 2025.7.1 @ US".asText(), deviceData = "device_data".asText(), ciData = "ci_data".asText(), isSubmitCrashLogsEnabled = false, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt index 7124a51d8d..ffdd874b89 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.data.datasource.disk.model.ServerConfig +import com.bitwarden.data.repository.ServerConfigRepository import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.util.asText @@ -45,6 +47,10 @@ class AboutViewModelTest : BaseViewModelTest() { every { flightRecorderDataFlow } returns mutableFlightRecorderFlow every { flightRecorderData } returns FlightRecorderDataSet(emptySet()) } + private val mutableServerConfigStateFlow = MutableStateFlow(null) + private val serverConfigRepository: ServerConfigRepository = mockk { + every { serverConfigStateFlow } returns mutableServerConfigStateFlow + } @AfterEach fun tearDown() { @@ -185,6 +191,10 @@ class AboutViewModelTest : BaseViewModelTest() { .concat("\n".asText()) .concat(state.deviceData) .concat(state.ciData) + .concat("\n".asText()) + .concat(state.sdkVersion) + .concat("\n".asText()) + .concat(state.serverData) every { clipboardManager.setText(expectedText, true, null) } just runs @@ -222,6 +232,7 @@ class AboutViewModelTest : BaseViewModelTest() { environmentRepository = environmentRepository, logsManager = logsManager, settingsRepository = settingsRepository, + serverConfigRepository = serverConfigRepository, ) } @@ -231,6 +242,8 @@ private val FIXED_CLOCK = Clock.fixed( ) private val DEFAULT_ABOUT_STATE: AboutState = AboutState( version = "Version: ()".asText(), + sdkVersion = "\uD83E\uDD80 SDK: 1.0.0-20250708.105256-238".asText(), + serverData = "\uD83C\uDF29 Server: 2025.7.1".asText(), deviceData = "".asText(), ciData = "".asText(), copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(FIXED_CLOCK).value}".asText(), From e75d7844de7480e5def8db1debca5b678e5b274f Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 18 Jul 2025 15:44:52 -0500 Subject: [PATCH 3/8] PM-23910: Disallow file sends for non-premium users (#5544) --- .../send/addedit/AddEditSendViewModel.kt | 17 +++++ .../itemlisting/VaultItemListingViewModel.kt | 25 +++++-- .../send/addedit/AddEditSendScreenTest.kt | 1 + .../send/addedit/AddEditSendViewModelTest.kt | 67 +++++++++++++++++++ .../VaultItemListingViewModelTest.kt | 46 ++++++++++++- 5 files changed, 149 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index a094028f60..0a53d49c66 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -15,6 +15,7 @@ import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -65,6 +66,7 @@ private const val MAX_FILE_SIZE_BYTES: Long = 100 * 1024 * 1024 @HiltViewModel class AddEditSendViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + authRepo: AuthRepository, private val clock: Clock, private val clipboardManager: BitwardenClipboardManager, private val environmentRepo: EnvironmentRepository, @@ -131,6 +133,7 @@ class AddEditSendViewModel @Inject constructor( policyDisablesSend = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) .any(), + isPremium = authRepo.userStateFlow.value?.activeAccount?.isPremium == true, ) }, ) { @@ -499,6 +502,19 @@ class AddEditSendViewModel @Inject constructor( } (content.selectedType as? AddEditSendState.ViewState.Content.SendType.File) ?.let { fileType -> + if (!state.isPremium) { + // We should never get here without a premium account, but we do one last + // check just in case. + mutableStateFlow.update { + it.copy( + dialogState = AddEditSendState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + ) + } + return@onContent + } if (fileType.name.isNullOrBlank()) { mutableStateFlow.update { it.copy( @@ -686,6 +702,7 @@ data class AddEditSendState( val isShared: Boolean, val baseWebSendUrl: String, val policyDisablesSend: Boolean, + val isPremium: Boolean, ) : Parcelable { /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index b6cf668628..a9d70c040f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -838,11 +838,26 @@ class VaultItemListingViewModel @Inject constructor( } is VaultItemListingState.ItemListingType.Send -> { - sendEvent( - VaultItemListingEvent.NavigateToAddSendItem( - sendType = itemListingType.toSendItemType(), - ), - ) + when (val sendType = itemListingType.toSendItemType()) { + SendItemType.FILE -> { + if (state.isPremium) { + sendEvent(VaultItemListingEvent.NavigateToAddSendItem(sendType)) + } else { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + ) + } + } + } + + SendItemType.TEXT -> { + sendEvent(VaultItemListingEvent.NavigateToAddSendItem(sendType)) + } + } } } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt index b63e539323..106b55f07d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt @@ -881,4 +881,5 @@ private val DEFAULT_STATE = AddEditSendState( baseWebSendUrl = "https://vault.bitwarden.com/#/send/", policyDisablesSend = false, sendType = SendItemType.TEXT, + isPremium = true, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt index 62fa94dd17..7e43ebeb71 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt @@ -12,10 +12,14 @@ import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView @@ -66,6 +70,10 @@ class AddEditSendViewModelTest : BaseViewModelTest() { private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(any(), toastDescriptorOverride = any()) } just runs } + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } private val environmentRepository: EnvironmentRepository = mockk { every { environment } returns Environment.Us } @@ -386,6 +394,38 @@ class AddEditSendViewModelTest : BaseViewModelTest() { ) } + @Test + fun `SaveClick for file without premium show error dialog`() { + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = false)), + ) + val initialState = DEFAULT_STATE.copy( + isPremium = false, + viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "test"), + selectedType = AddEditSendState.ViewState.Content.SendType.File( + uri = null, + name = null, + displaySize = null, + sizeBytes = null, + ), + ), + ) + val viewModel = createViewModel(initialState) + + viewModel.trySendAction(AddEditSendAction.SaveClick) + + assertEquals( + initialState.copy( + dialogState = AddEditSendState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `SaveClick with file missing should show error dialog`() { val initialState = DEFAULT_STATE.copy( @@ -966,6 +1006,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() { toAddEditSendArgs() } returns AddEditSendArgs(sendType = sendType, addEditSendType = addEditSendType) }, + authRepo = authRepository, environmentRepo = environmentRepository, specialCircumstanceManager = specialCircumstanceManager, clock = clock, @@ -1013,4 +1054,30 @@ private val DEFAULT_STATE = AddEditSendState( baseWebSendUrl = DEFAULT_ENVIRONMENT_URL, policyDisablesSend = false, sendType = SendItemType.TEXT, + isPremium = true, +) + +private val DEFAULT_ACCOUNT = UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState(showImportLoginsCard = true), +) + +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf(DEFAULT_ACCOUNT), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index a371715e35..d0da8a56bd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -1422,7 +1422,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `AddVaultItemClick for send item should emit NavigateToAddVaultItem`() = runTest { + fun `AddVaultItemClick for text send item should emit NavigateToAddVaultItem`() = runTest { val viewModel = createVaultItemListingViewModel( createSavedStateHandleWithVaultItemListingType(VaultItemListingType.SendText), ) @@ -1435,6 +1435,47 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Test + fun `AddVaultItemClick for file send item with premium should emit NavigateToAddVaultItem`() = + runTest { + val viewModel = createVaultItemListingViewModel( + createSavedStateHandleWithVaultItemListingType(VaultItemListingType.SendFile), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + assertEquals( + VaultItemListingEvent.NavigateToAddSendItem(sendType = SendItemType.FILE), + awaitItem(), + ) + } + } + + @Test + fun `AddVaultItemClick for file send item without premium should display error dialog`() = + runTest { + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = false)), + ) + val viewModel = createVaultItemListingViewModel( + createSavedStateHandleWithVaultItemListingType(VaultItemListingType.SendFile), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + expectNoEvents() + } + assertEquals( + createVaultItemListingState( + itemListingType = VaultItemListingState.ItemListingType.Send.SendFile, + dialogState = VaultItemListingState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + isPremium = false, + ), + viewModel.stateFlow.value, + ) + } + @Test fun `ItemTypeToAddSelected sends NavigateToAddFolder for folder selection`() = runTest { val viewModel = createVaultItemListingViewModel( @@ -5331,6 +5372,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { itemListingType: VaultItemListingState.ItemListingType = VaultItemListingState.ItemListingType.Vault.Login, viewState: VaultItemListingState.ViewState = VaultItemListingState.ViewState.Loading, dialogState: VaultItemListingState.DialogState? = null, + isPremium: Boolean = true, ): VaultItemListingState = VaultItemListingState( itemListingType = itemListingType, @@ -5348,7 +5390,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { policyDisablesSend = false, hasMasterPassword = true, createCredentialRequest = null, - isPremium = true, + isPremium = isPremium, isRefreshing = false, restrictItemTypesPolicyOrgIds = persistentListOf(), ) From 411e359600c5e555f9fa14812acbc7e81c0c7972 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 18 Jul 2025 15:45:12 -0500 Subject: [PATCH 4/8] PM-23878: Add filter touches when obscured (#5545) --- app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt index a804e5750e..7035a2e786 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt @@ -69,6 +69,7 @@ class MainActivity : AppCompatActivity() { lateinit var debugLaunchManager: DebugMenuLaunchManager override fun onCreate(savedInstanceState: Bundle?) { + window.decorView.filterTouchesWhenObscured = true intent = intent.validate() var shouldShowSplashScreen = true installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } From 6454dc1a589b9305537fe466bc2d1d72d8eb2a99 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 18 Jul 2025 17:14:06 -0500 Subject: [PATCH 5/8] PM-23878: Move filterTouchesWhenObscured to avoid actionbar issues (#5546) --- app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt index 7035a2e786..8c63b4d0aa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt @@ -69,12 +69,11 @@ class MainActivity : AppCompatActivity() { lateinit var debugLaunchManager: DebugMenuLaunchManager override fun onCreate(savedInstanceState: Bundle?) { - window.decorView.filterTouchesWhenObscured = true intent = intent.validate() var shouldShowSplashScreen = true installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } super.onCreate(savedInstanceState) - + window.decorView.filterTouchesWhenObscured = true if (savedInstanceState == null) { mainViewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = intent)) } From f22643fec1f55b488b125c12b11b8fbf0eed2bbf Mon Sep 17 00:00:00 2001 From: Amy Galles <9685081+AmyLGalles@users.noreply.github.com> Date: Mon, 21 Jul 2025 10:11:30 -0400 Subject: [PATCH 6/8] [BRE-768] Automate Google Play publishing (#5256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Álison Fernandes --- .github/workflows/publish-store.yml | 144 +++++++++++++++++- fastlane/Fastfile | 73 +++++++++ fastlane/patches/supply_custom_promote.rb | 97 ++++++++++++ .../patches/supply_custom_promote_config.rb | 38 +++++ 4 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 fastlane/patches/supply_custom_promote.rb create mode 100644 fastlane/patches/supply_custom_promote_config.rb diff --git a/.github/workflows/publish-store.yml b/.github/workflows/publish-store.yml index cd654fc64c..db39b4848b 100644 --- a/.github/workflows/publish-store.yml +++ b/.github/workflows/publish-store.yml @@ -1,16 +1,146 @@ - -name: Publish - +name: Publish to Google Play +run-name: "Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}" on: workflow_dispatch: + inputs: + product: + description: "Which app is being released." + type: choice + options: + - Password Manager + - Authenticator + version-name: + description: "Version name to promote to production ex 2025.1.1" + type: string + version-code: + description: "Build number to promote to production." + required: true + type: string + rollout-percentage: + description: "Percentage of users who will receive this version update." + required: true + type: choice + options: + - 10% + - 30% + - 50% + - 100% + default: 10% + release-notes: + description: "Change notes to be included with this release." + type: string + default: "Bug fixes." + required: true + track-from: + description: "Track to promote from." + type: choice + options: + - internal + - Fastlane Automation Source + required: true + default: "internal" + track-target: + description: "Track to promote to." + type: choice + options: + - production + - Fastlane Automation Target + required: true +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" -permissions: {} +permissions: + contents: read + packages: read jobs: - publish: + promote: runs-on: ubuntu-24.04 name: Promote build to Production in Play Store steps: - - name: TEST STEP - run: exit 0 + - name: Log inputs to job summary + run: | + echo "
Job Inputs" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + echo '${{ toJson(inputs) }}' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Configure Ruby + uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0 + with: + bundler-cache: true + + - name: Install Fastlane + run: | + gem install bundler:2.2.27 + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + + - name: Log in to Azure + uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve secrets + env: + ACCOUNT_NAME: bitwardenci + CONTAINER_NAME: mobile + run: | + mkdir -p ${{ github.workspace }}/secrets + mkdir -p ${{ github.workspace }}/app/src/standardRelease + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none + + az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + --name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none + + - name: Format Release Notes + run: | + FORMATTED_MESSAGE="$(echo "${{ inputs.release-notes }}" | sed 's/ /\n/g')" + echo "RELEASE_NOTES<> $GITHUB_ENV + echo "$FORMATTED_MESSAGE" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Promote Play Store version to production + env: + PLAY_KEYSTORE_PASSWORD: ${{ secrets.PLAY_BETA_KEYSTORE_PASSWORD }} + PLAY_KEY_PASSWORD: ${{ secrets.PLAY_BETA_KEY_PASSWORD }} + VERSION_CODE_INPUT: ${{ inputs.version-code }} + VERSION_NAME: ${{inputs.version-name}} + ROLLOUT_PERCENTAGE: ${{ inputs.rollout-percentage }} + PRODUCT: ${{ inputs.product }} + TRACK_FROM: ${{ inputs.track-from }} + TRACK_TARGET: ${{ inputs.track-target }} + run: | + if [ "$PRODUCT" = "Password Manager" ]; then + PACKAGE_NAME="com.x8bit.bitwarden" + elif [ "$PRODUCT" = "Authenticator" ]; then + PACKAGE_NAME="com.bitwarden.authenticator" + else + echo "Unsupported product: $PRODUCT" + exit 1 + fi + + VERSION_CODE=$(echo "${VERSION_CODE_INPUT}" | tr -d ',') + + decimal=$(echo "scale=2; ${ROLLOUT_PERCENTAGE/\%/} / 100" | bc) + + bundle exec fastlane updateReleaseNotes \ + releaseNotes:"$RELEASE_NOTES" \ + versionCode:"$VERSION_CODE" + + bundle exec fastlane promoteToProduction \ + versionCode:"$VERSION_CODE" \ + versionName:"$VERSION_NAME" \ + rolloutPercentage:"$decimal" \ + packageName:"$PACKAGE_NAME" \ + releaseNotes:"$RELEASE_NOTES" \ + track:"$TRACK_FROM" \ + trackPromoteTo:"$TRACK_TARGET" diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 454b806547..803c0be24e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -13,6 +13,9 @@ # Uncomment the line if you want fastlane to automatically update itself # update_fastlane +require_relative 'patches/supply_custom_promote_config' +require_relative 'patches/supply_custom_promote' + default_platform(:android) platform :android do @@ -418,4 +421,74 @@ platform :android do mapping: "authenticator/build/outputs/mapping/release/mapping.txt", ) end + + desc "Retrieve build from Github releases" + lane :retrieveBuildFromGithub do |options| + + version_codes = Actions.lane_context[SharedValues::GOOGLE_PLAY_TRACK_VERSION_CODES] + UI.message("Version codes in beta track: #{version_codes}") + end + + desc "Update release notes for all locales." + lane :updateReleaseNotes do |options| + changelog = options[:releaseNotes] + version_code = options[:versionCode] + + auth_locales = ["en-US"] + pw_manager_locales = ["ca", "cs-CZ", "da-DK", "de-DE", "en-US", "es-ES", "et", "fr-FR", "hr", "hu-HU", "id", "it-IT", "iw-IL", "ja-JP", "nl-NL", "pl-PL", "pt-BR", "pt-PT", "ro", "ru-RU", "sk", "sv-SE", "tr-TR", "uk", "vi", "zh-CN", "zh-TW"] + if options[:packageName] == "com.bitwarden.authenticator" + locales = auth_locales + else + locales = pw_manager_locales + end + locales.each do |locale| + dir = "metadata/android/#{locale}/changelogs" + FileUtils.mkdir_p(dir) + File.write("#{dir}/#{version_code}.txt", changelog) + end + end + + desc "Promote to production." + lane :promoteToProduction do |options| + release_options = { + package_name: options[:packageName], + version_code: options[:versionCode].to_i, + version_name: options[:versionName], + track: options[:track], + track_promote_to: options[:trackPromoteTo], + skip_release_verification: true, + skip_upload_apk: true, + skip_upload_aab: true, + } + + if options[:releaseNotes].nil? or options[:releaseNotes].to_s.empty? + release_options[:skip_upload_metadata] = true + else + release_options[:skip_upload_metadata] = false + end + + if options[:rolloutPercentage].to_f < 1 + release_options[:track_promote_release_status] = "inProgress" + release_options[:rollout] = options[:rolloutPercentage] + else + release_options[:release_status] = "completed" + end + + begin + UI.message("🚀 Starting release to #{options[:trackPromoteTo]}...") + + supply(release_options) + + rescue => error + message = error.to_s + + if message.include?("You cannot rollout this release because it does not allow any existing users to upgrade to the newly added APKs") + UI.error("❌ App Store Error: Cannot rollout release because no existing users can upgrade to the new build.") + UI.error("This might mean the version is older than what is currently available on the track, pick a newer build.") + else + UI.error("❌ Unexpected error during release: #{message}") + end + raise + end + end end diff --git a/fastlane/patches/supply_custom_promote.rb b/fastlane/patches/supply_custom_promote.rb new file mode 100644 index 0000000000..e684dbc56a --- /dev/null +++ b/fastlane/patches/supply_custom_promote.rb @@ -0,0 +1,97 @@ +# Patch Description: +# Fixes issue where Fastlane 'Supply' doesn't recognize previous builds +# when promoting to another track. +# +# Source: https://github.com/artsy/eigen/pull/10262 +# Author: Brian Beckerle (@brainbicycle) +# + +module Supply + class Uploader + alias_method :original_promote_track, :promote_track + + def promote_track + if Supply.config[:skip_release_verification] + custom_promote_track + else + original_promote_track + end + end + + def custom_promote_track + UI.message("Using custom promotion logic") + track_from = client.tracks(Supply.config[:track]).first + unless track_from + UI.user_error!("Cannot promote from track '#{Supply.config[:track]}' - track doesn't exist") + end + + releases = track_from.releases + UI.message("Track contents: #{track_from.to_json}") + + version_code = Supply.config[:version_code].to_s + if !Supply.config[:skip_release_verification] + if version_code != "" + releases = releases.select do |release| + release.version_codes.include?(version_code) + end + else + releases = releases.select do |release| + release.status == Supply.config[:release_status] + end + end + + if releases.size == 0 + if version_code != "" + UI.user_error!("Cannot find release with version code '#{version_code}' to promote in track '#{Supply.config[:track]}'") + else + UI.user_error!("Track '#{Supply.config[:track]}' doesn't have any releases") + end + elsif releases.size > 1 + UI.user_error!("Track '#{Supply.config[:track]}' has more than one release - use :version_code to filter the release to promote") + end + else + UI.message("Skipping release verification as per configuration.") + if version_code == "" + UI.user_error!("Must provide a version code when release verification is skipped.") + end + if Supply.config[:version_name].nil? + UI.user_error!("To force promote a :version_code, it is mandatory to enter the :version_name") + end + release = AndroidPublisher::TrackRelease.new( + name: Supply.config[:version_name], + version_codes: [version_code], + status: Supply.config[:track_promote_release_status] || Supply::ReleaseStatus::COMPLETED + ) + end + + release = releases.first unless Supply.config[:skip_release_verification] + track_to = client.tracks(Supply.config[:track_promote_to]).first || AndroidPublisher::Track.new( + track: Supply.config[:track_promote_to], + releases: [] + ) + + rollout = (Supply.config[:rollout] || 0).to_f + if rollout > 0 && rollout < 1 + release.status = Supply::ReleaseStatus::IN_PROGRESS + release.user_fraction = rollout + else + release.status = Supply.config[:track_promote_release_status] + release.user_fraction = nil + end + + if track_to + # Its okay to set releases to an array containing the newest release + # Google Play will keep previous releases there this release is a partial rollout + track_to.releases = [release] + else + track_to = AndroidPublisher::Track.new( + track: Supply.config[:track_promote_to], + releases: [release] + ) + end + + client.update_track(Supply.config[:track_promote_to], track_to) + UI.message("confirmed that update_track was reached: #{Supply.config[:track_promote_to]} #{release}") + end + end +end diff --git a/fastlane/patches/supply_custom_promote_config.rb b/fastlane/patches/supply_custom_promote_config.rb new file mode 100644 index 0000000000..dc50163451 --- /dev/null +++ b/fastlane/patches/supply_custom_promote_config.rb @@ -0,0 +1,38 @@ +# Patch Description: +# Fixes issue where Fastlane 'Supply' doesn't recognize previous builds +# when promoting to another track. +# +# Source: https://github.com/artsy/eigen/pull/10262 +# Author: Brian Beckerle (@brainbicycle) +# + +module Supply + class Options + class << self + alias_method :original_available_options, :available_options + + def available_options + original_options = original_available_options + custom_options = [ + FastlaneCore::ConfigItem.new( + key: :skip_release_verification, + env_name: "SUPPLY_SKIP_RELEASE_VERIFICATION", + description: "If set to true, skips checking if the version code exists in the track before promoting", + type: Boolean, + default_value: false, + optional: true + ) + ] + + # Only add custom options if they aren't already present + custom_options.each do |custom_option| + unless original_options.any? { |option| option.key == custom_option.key } + original_options << custom_option + end + end + + original_options + end + end + end +end From e4935318de906c2b933ff7987330a5893afc9eb0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:38:12 -0500 Subject: [PATCH 7/8] [deps]: Lock file maintenance (#5548) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 799609e1fa..c4e28c6e83 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,8 +11,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1125.0) - aws-sdk-core (3.226.2) + aws-partitions (1.1131.0) + aws-sdk-core (3.226.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -22,7 +22,7 @@ GEM aws-sdk-kms (1.106.0) aws-sdk-core (~> 3, >= 3.225.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.192.0) + aws-sdk-s3 (1.193.0) aws-sdk-core (~> 3, >= 3.225.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -167,13 +167,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.12.2) + json (2.13.0) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -181,7 +181,7 @@ GEM nkf (0.2.0) optparse (0.6.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.3) plist (3.7.2) public_suffix (6.0.2) rake (13.3.0) From 17287680d99886552ffb02cbc89879ef6b53ad33 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:49:16 -0400 Subject: [PATCH 8/8] Allow asterisk in email validation (#5549) --- .../com/bitwarden/ui/platform/base/util/StringExtensions.kt | 4 ++-- .../bitwarden/ui/platform/base/util/StringExtensionsTest.kt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt index 200eac234c..20cdeec2c2 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -45,14 +45,14 @@ fun String?.orZeroWidthSpace(): String = this.orNullIfBlank() ?: ZERO_WIDTH_CHAR * * This validates that the email is valid by asserting that: * * The string starts with a string of characters including periods, underscores, percent symbols, - * plus's, minus's, forward slash's, and alphanumeric characters. + * plus's, minus's, forward slash's, asterisks, and alphanumeric characters. * * Followed by an '@' symbol. * * Followed by a string of characters including periods, minus's, and alphanumeric characters. * * Followed by a period. * * Followed by at least 2 more alphanumeric characters. */ fun String.isValidEmail(): Boolean = - this.matches(regex = "^[A-Za-z0-9._%+-/]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".toRegex()) + this.matches(regex = "^[A-Za-z0-9._%+-/*]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".toRegex()) /** * Returns `true` if the given [String] is a non-blank, valid URI and `false` otherwise. diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/base/util/StringExtensionsTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/base/util/StringExtensionsTest.kt index 65c960b1a5..018506e367 100644 --- a/ui/src/test/kotlin/com/bitwarden/ui/platform/base/util/StringExtensionsTest.kt +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/base/util/StringExtensionsTest.kt @@ -28,6 +28,7 @@ class StringExtensionsTest { "test.test@test.test.com" to true, "test/test@test.test.com" to true, "test.test@test/test.com" to false, + "test*test@test.com" to true, ) invalidEmails.forEach { assertEquals(it.first.isValidEmail(), it.second)