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/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)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 46f7c3527d..77b1a37c54 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -67,7 +67,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/MainActivity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt
index a804e5750e..8c63b4d0aa 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt
@@ -73,7 +73,7 @@ class MainActivity : AppCompatActivity() {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
-
+ window.decorView.filterTouchesWhenObscured = true
if (savedInstanceState == null) {
mainViewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = intent))
}
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/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
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/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(),
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(),
)
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
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)