Compare commits

...

10 Commits

Author SHA1 Message Date
Álison Fernandes
605e0ef023 Rendering konami code QRCode for testing purposes; UI cleanup; 2025-03-21 00:44:41 +00:00
Álison Fernandes
6d196b5214 Render QRCode with bitwarden colours 2025-03-21 00:43:09 +00:00
Álison Fernandes
58a2812b74 Functional dynamic dropbox selection; Auto-map first draft 2025-03-20 20:46:42 +00:00
Álison Fernandes
8f345234d9 Refactored QRCode fields to List; Started wiring FieldValueChange 2025-03-19 23:29:32 +00:00
Álison Fernandes
0407972926 Functional UI dropdowns 2025-03-19 22:44:35 +00:00
Álison Fernandes
a6f4717b35 Functional QRCode Type dropdown; Simplified State 2025-03-19 17:24:06 +00:00
Álison Fernandes
89fa10749c Merge branch 'qrcode/1-page' into qrcode/2-ui-fields
# Conflicts:
#	app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/model/QrCodeType.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/util/QrCodeGenerator.kt
2025-03-19 17:21:26 +00:00
Álison Fernandes
d65e027210 Refactor QRCodeType 2025-03-19 15:24:32 +00:00
Álison Fernandes
225cb24ac1 Add ViewAsQrCode first draft 2025-03-19 15:15:26 +00:00
Álison Fernandes
60913e1a94 Add ViewAsQrCode first draft 2025-03-18 18:28:42 +00:00
12 changed files with 1055 additions and 2 deletions

View File

@@ -53,6 +53,8 @@ import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.navigateToVaultMo
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.vaultMoveToOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestination
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.navigateToViewAsQrCode
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.viewAsQrCodeDestination
const val VAULT_UNLOCKED_GRAPH_ROUTE: String = "vault_unlocked_graph"
@@ -165,6 +167,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = it),
)
},
onNavigateToViewAsQrCode = { navController.navigateToViewAsQrCode(it) },
)
vaultQrCodeScanDestination(
onNavigateToManualCodeEntryScreen = {
@@ -228,5 +231,8 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() },
onNavigateBack = { navController.popBackStack() },
)
viewAsQrCodeDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}

View File

@@ -9,6 +9,7 @@ import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.ViewAsQrCodeArgs
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
private const val LOGIN: String = "login"
@@ -47,12 +48,18 @@ fun NavGraphBuilder.vaultItemDestination(
onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit,
onNavigateToAttachments: (vaultItemId: String) -> Unit,
onNavigateToPasswordHistory: (vaultItemId: String) -> Unit,
onNavigateToViewAsQrCode: (args: ViewAsQrCodeArgs) -> Unit,
) {
composableWithSlideTransitions(
route = VAULT_ITEM_ROUTE,
arguments = listOf(
navArgument(VAULT_ITEM_ID) { type = NavType.StringType },
navArgument(VAULT_ITEM_CIPHER_TYPE) { type = NavType.StringType },
navArgument(VAULT_ITEM_ID) {
type = NavType.StringType
},
navArgument(VAULT_ITEM_CIPHER_TYPE) {
type = NavType.StringType
defaultValue = LOGIN
},
),
) {
VaultItemScreen(
@@ -61,6 +68,7 @@ fun NavGraphBuilder.vaultItemDestination(
onNavigateToMoveToOrganization = onNavigateToMoveToOrganization,
onNavigateToAttachments = onNavigateToAttachments,
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
onNavigateToViewAsQrCode = onNavigateToViewAsQrCode,
)
}
}

View File

@@ -46,7 +46,9 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHan
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.ViewAsQrCodeArgs
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
/**
* Displays the vault item screen.
@@ -62,6 +64,7 @@ fun VaultItemScreen(
onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit,
onNavigateToAttachments: (vaultItemId: String) -> Unit,
onNavigateToPasswordHistory: (vaultItemId: String) -> Unit,
onNavigateToViewAsQrCode: (args: ViewAsQrCodeArgs) -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -100,6 +103,15 @@ fun VaultItemScreen(
is VaultItemEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri())
is VaultItemEvent.NavigateToViewAsQrCode -> {
onNavigateToViewAsQrCode(
ViewAsQrCodeArgs(
vaultItemId = event.itemId,
vaultItemCipherType = event.type,
),
)
}
is VaultItemEvent.NavigateToAttachments -> onNavigateToAttachments(event.itemId)
is VaultItemEvent.NavigateToMoveToOrganization -> {
@@ -182,6 +194,16 @@ fun VaultItemScreen(
}
BitwardenOverflowActionItem(
menuItemDataList = persistentListOfNotNull(
OverflowMenuItemData(
text = stringResource(id = R.string.view_as_qr_code),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemAction.Common.ViewAsQrCodeClick,
)
}
},
),
OverflowMenuItemData(
text = stringResource(id = R.string.attachments),
onClick = remember(viewModel) {

View File

@@ -228,6 +228,7 @@ class VaultItemViewModel @Inject constructor(
handleNoAttachmentFileLocationReceive()
}
is VaultItemAction.Common.ViewAsQrCodeClick -> handleViewAsQrCodeClick()
is VaultItemAction.Common.AttachmentsClick -> handleAttachmentsClick()
is VaultItemAction.Common.CloneClick -> handleCloneClick()
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
@@ -439,6 +440,16 @@ class VaultItemViewModel @Inject constructor(
)
}
private fun handleViewAsQrCodeClick() {
// TODO - do we need onContent?
sendEvent(
event = VaultItemEvent.NavigateToViewAsQrCode(
itemId = state.vaultItemId,
type = state.cipherType,
),
)
}
private fun handleAttachmentsClick() {
onContent { content ->
if (content.common.requiresReprompt) {
@@ -1927,6 +1938,14 @@ sealed class VaultItemEvent {
val uri: String,
) : VaultItemEvent()
/**
* Navigate to view as QR code screen.
*/
data class NavigateToViewAsQrCode(
val itemId: String,
val type: VaultItemCipherType,
) : VaultItemEvent()
/**
* Navigates to the attachments screen.
*/
@@ -2098,6 +2117,11 @@ sealed class VaultItemAction {
* The user has clicked the password history text.
*/
data object PasswordHistoryClick : Common()
/**
* User clicked the View as QR code button.
*/
data object ViewAsQrCodeClick : Common()
}
/**

View File

@@ -0,0 +1,96 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
private const val VAULT_ITEM_ID = "vault_item_id"
private const val LOGIN: String = "login"
private const val CARD: String = "card"
private const val IDENTITY: String = "identity"
private const val SECURE_NOTE: String = "secure_note"
private const val SSH_KEY: String = "ssh_key"
private const val CIPHER_TYPE: String = "vault_item_type"
private const val VIEW_AS_QR_CODE_PREFIX: String = "view_as_qr_code"
private const val VIEW_AS_QR_CODE_ROUTE: String =
VIEW_AS_QR_CODE_PREFIX +
"/{$VAULT_ITEM_ID}" +
"?$CIPHER_TYPE={$CIPHER_TYPE}"
/**
* Class to retrieve view as QR code arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class ViewAsQrCodeArgs(
val vaultItemId: String,
val vaultItemCipherType: VaultItemCipherType,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
vaultItemId = checkNotNull(savedStateHandle.get<String>(VAULT_ITEM_ID)),
vaultItemCipherType = requireNotNull(savedStateHandle.get<String>(CIPHER_TYPE))
.toVaultItemCipherType(),
)
}
/**
* Add the view as QR code screen to the nav graph.
*/
fun NavGraphBuilder.viewAsQrCodeDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = VIEW_AS_QR_CODE_ROUTE,
arguments = listOf(
navArgument(VAULT_ITEM_ID) { type = NavType.StringType },
navArgument(CIPHER_TYPE) { type = NavType.StringType },
),
) {
ViewAsQrCodeScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the view as QR code screen.
*/
fun NavController.navigateToViewAsQrCode(
args: ViewAsQrCodeArgs,
navOptions: NavOptions? = null,
) {
this.navigate(
route = "$VIEW_AS_QR_CODE_PREFIX/${args.vaultItemId}" +
"?$CIPHER_TYPE=${args.vaultItemCipherType.toTypeString()}",
navOptions = navOptions,
)
}
private fun VaultItemCipherType.toTypeString(): String =
when (this) {
VaultItemCipherType.LOGIN -> LOGIN
VaultItemCipherType.CARD -> CARD
VaultItemCipherType.IDENTITY -> IDENTITY
VaultItemCipherType.SECURE_NOTE -> SECURE_NOTE
VaultItemCipherType.SSH_KEY -> SSH_KEY
}
private fun String.toVaultItemCipherType(): VaultItemCipherType =
when (this) {
LOGIN -> VaultItemCipherType.LOGIN
CARD -> VaultItemCipherType.CARD
IDENTITY -> VaultItemCipherType.IDENTITY
SECURE_NOTE -> VaultItemCipherType.SECURE_NOTE
SSH_KEY -> VaultItemCipherType.SSH_KEY
else -> throw IllegalStateException(
"Cipher Type string arguments for ViewAsQrCodeNavigation must match!",
)
}

View File

@@ -0,0 +1,157 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.handlers.ViewAsQrCodeHandlers
import kotlinx.collections.immutable.toImmutableList
/**
* Displays the view as QR code screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewAsQrCodeScreen(
viewModel: ViewAsQrCodeViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val viewAsQrCodeHandlers = remember(viewModel) { ViewAsQrCodeHandlers.create(viewModel) }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ViewAsQrCodeEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.view_as_qr_code),
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(ViewAsQrCodeAction.BackClick) }
},
scrollBehavior = scrollBehavior,
)
},
) {
val viewState = state
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(height = 12.dp))
// QR Code display
Box(
modifier = Modifier
.standardHorizontalMargin()
.cardStyle(CardStyle.Full)
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center,
) {
Image(
bitmap = state.qrCodeBitmap.asImageBitmap(),
contentDescription = stringResource(id = R.string.qr_code),
modifier = Modifier.fillMaxSize(),
)
}
Spacer(modifier = Modifier.height(12.dp))
// QR Code type selector
val resources = LocalContext.current.resources
BitwardenMultiSelectButton(
label = stringResource(id = R.string.qr_code_type),
options = viewState.qrCodeTypes.map { it.displayName() }.toImmutableList(),
selectedOption = viewState.selectedQrCodeType.displayName(),
onOptionSelected = { selectedOption ->
val selectedType = viewState.qrCodeTypes.first {
it.displayName.toString(resources) == selectedOption
}
viewModel.trySendAction(ViewAsQrCodeAction.QrCodeTypeSelect(selectedType))
},
//supportingText = stringResource(id = R.string.default_uri_match_detection_description),
cardStyle = CardStyle.Full,
modifier = Modifier
.testTag("QRCodeType")
.standardHorizontalMargin()
.fillMaxWidth(),
)
//QR Code Type dropdowns
Spacer(modifier = Modifier.height(8.dp))
viewState.qrCodeTypeFields.forEachIndexed { i, field ->
val cipherFieldsTextList =
viewState.cipherFields.map { it() }.toImmutableList()
val fieldsListSize = viewState.qrCodeTypeFields.size
val lastFieldIndex = fieldsListSize - 1;
BitwardenMultiSelectButton(
label = field.displayName(),
options = cipherFieldsTextList,
selectedOption = field.value(),
onOptionSelected = { selectedOption ->
viewModel.trySendAction(
ViewAsQrCodeAction.FieldValueChange(
field, //TODO memory leak?
selectedOption
)
)
},
cardStyle = when (i) {
0 -> when (fieldsListSize) {
1 -> CardStyle.Full
else -> CardStyle.Top()
}
lastFieldIndex -> CardStyle.Bottom
else -> CardStyle.Middle()
},
modifier = Modifier
.testTag("QRCodeField_${field.key}")
.standardHorizontalMargin()
.fillMaxWidth(),
)
}
}
}
}

View File

@@ -0,0 +1,351 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode
//import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util.toViewState
import android.graphics.Bitmap
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.SELECT_TEXT
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeType
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeTypeField
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util.QrCodeGenerator
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* ViewModel responsible for handling user interactions in the attachments screen.
*/
@HiltViewModel
class ViewAsQrCodeViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<ViewAsQrCodeState, ViewAsQrCodeEvent, ViewAsQrCodeAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = ViewAsQrCodeArgs(savedStateHandle)
val qrCodeTypes = QrCodeType.entries
val selectedQrCodeType = qrCodeTypes.first()
ViewAsQrCodeState(
cipherId = args.vaultItemId,
cipherType = args.vaultItemCipherType,
qrCodeBitmap = QrCodeGenerator.generateQrCodeBitmap("↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,"),
selectedQrCodeType = selectedQrCodeType,
qrCodeTypes = qrCodeTypes,
qrCodeTypeFields = selectedQrCodeType.fields,
cipherFields = emptyList(),
cipher = null,
// viewState = ViewAsQrCodeState.ViewState.Loading,
// dialogState = null,
)
},
) {
private val args = ViewAsQrCodeArgs(savedStateHandle)
init {
//TODO get args.vaultItemCipherType and auto-map
mutableStateFlow.update {
it.copy(
cipherFields = cipherFieldsFor(it.cipherType, null),
)
}
vaultRepository
.getVaultItemStateFlow(args.vaultItemId)
.map { ViewAsQrCodeAction.Internal.CipherReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ViewAsQrCodeAction) {
when (action) {
ViewAsQrCodeAction.BackClick -> handleBackClick()
is ViewAsQrCodeAction.QrCodeTypeSelect -> handleQrCodeTypeSelect(action)
is ViewAsQrCodeAction.FieldValueChange -> handleFieldValueChange(action)
is ViewAsQrCodeAction.Internal.CipherReceive -> handleInternalAction(action)
}
}
private fun handleBackClick() {
sendEvent(ViewAsQrCodeEvent.NavigateBack)
}
private fun handleInternalAction(action: ViewAsQrCodeAction.Internal) {
when (action) {
is ViewAsQrCodeAction.Internal.CipherReceive -> handleCipherReceive(action)
}
}
private fun handleCipherReceive(action: ViewAsQrCodeAction.Internal.CipherReceive) {
when (val dataState = action.cipherDataState) {
is DataState.Loaded -> {
val cipher = dataState.data
val cipherFields = cipherFieldsFor(state.cipherType, cipher)
val updatedQrCodeFields = autoMapFields(
state.qrCodeTypeFields,
state.cipherType,
cipher
)
mutableStateFlow.update {
it.copy(
cipher = cipher,
cipherFields = cipherFields,
qrCodeTypeFields = updatedQrCodeFields
)
}
}
//TODO do we need to handle these?
is DataState.Error -> {}
is DataState.Loading -> {}
is DataState.NoNetwork -> {}
is DataState.Pending -> {}
// is DataState.Error -> {
// mutableStateFlow.update {
// it.copy(
// viewState = ViewAsQrCodeState.ViewState.Error(
// message = R.string.generic_error_message.asText(),
// ),
// )
// }
// }
// is DataState.Loaded -> {
// mutableStateFlow.update {
// it.copy(
// viewState = dataState
// .data
// ?.toViewState()
// ?: ViewAsQrCodeState.ViewState.Error(
// message = R.string.generic_error_message.asText(),
// ),
// )
// }
// }
// DataState.Loading -> {
// mutableStateFlow.update {
// it.copy(viewState = ViewAsQrCodeState.ViewState.Loading)
// }
// }
//
// is DataState.Pending -> {
// mutableStateFlow.update {
// it.copy(
// viewState = dataState
// .data
// ?.toViewState()
// ?: ViewAsQrCodeState.ViewState.Error(
// message = R.string.generic_error_message.asText(),
// ),
// )
// }
// }
}
}
private fun handleQrCodeTypeSelect(action: ViewAsQrCodeAction.QrCodeTypeSelect) {
mutableStateFlow.update {
it.copy(
selectedQrCodeType = action.qrCodeType,
qrCodeTypeFields = autoMapFields(
action.qrCodeType.fields,
state.cipherType,
state.cipher
)
)
}
}
private fun autoMapFields(
qrCodeTypeFields: List<QrCodeTypeField>,
cipherType: VaultItemCipherType,
cipher: CipherView?,
): List<QrCodeTypeField> {
return qrCodeTypeFields.map { field ->
field.copy(value = autoMapField(field, cipherType, cipher))
}
}
private fun handleFieldValueChange(action: ViewAsQrCodeAction.FieldValueChange) {
val field = action.field
val value = action.value
// Create a Text object from the selected value
val selectedText = value.asText()
//TODO should we transition qrCodeTypeFields to a map again*2 and update using key?
val updatedFields = state.qrCodeTypeFields.map { currentField ->
if (currentField.key == field.key) {
currentField.copy(value = selectedText)
} else {
currentField
}
}
mutableStateFlow.update {
it.copy(qrCodeTypeFields = updatedFields)
}
}
private fun autoMapField(
qrCodeTypeField: QrCodeTypeField,
cipherType: VaultItemCipherType,
cipher: CipherView?,
): Text {
val defaultText = SELECT_TEXT
return when (qrCodeTypeField.key) {
"ssid" -> when (cipherType) {
VaultItemCipherType.LOGIN -> automapSsidToLoginItem(cipher, defaultText)
else -> defaultText
}
"password" -> when (cipherType) {
VaultItemCipherType.LOGIN -> automapPasswordToLoginItem(cipher, defaultText)
else -> defaultText
}
//TODO automap everything else
else -> defaultText
}
}
private fun automapPasswordToLoginItem(cipher: CipherView?, defaultText: Text): Text =
if (cipher?.login?.password?.isNotEmpty() == true) R.string.password.asText() else defaultText
private fun automapSsidToLoginItem(
cipher: CipherView?,
defaultText: Text,
): Text = if (cipher?.login?.username?.isNotEmpty() == true)
R.string.username.asText() //TODO transition cipherFieldsFor to a map and get field with key?
else {
val customSsid = cipher?.fields?.find { it.name == "Custom: SSID" }
if (customSsid?.value?.isNotEmpty() == true)
"Custom: SSID".asText()
else
defaultText
}
//TODO create list with common fields first like SELECT_TEXT
private fun cipherFieldsFor(cipherType: VaultItemCipherType, cipher: CipherView?): List<Text> {
//TODO add additional cipher fields like web links and custom fields
//TODO filter base list depending on the cipher data
return when (cipherType) {
VaultItemCipherType.LOGIN -> listOf(
SELECT_TEXT,
R.string.name.asText(),
R.string.username.asText(),
R.string.password.asText(),
R.string.notes.asText(),
)
VaultItemCipherType.CARD -> listOf(
SELECT_TEXT,
R.string.cardholder_name.asText(),
R.string.number.asText(),
//TODO finish
)
VaultItemCipherType.IDENTITY -> listOf(
SELECT_TEXT,
R.string.title.asText(),
R.string.first_name.asText(),
//TODO finish
)
VaultItemCipherType.SECURE_NOTE -> listOf(
SELECT_TEXT,
R.string.name.asText(),
R.string.notes.asText(),
)
VaultItemCipherType.SSH_KEY -> listOf(
SELECT_TEXT,
R.string.public_key.asText(),
//TODO finish
)
}
}
}
/**
* Represents the state for viewing attachments.
*/
@Parcelize
data class ViewAsQrCodeState(
val cipherId: String,
val cipherType: VaultItemCipherType,
val qrCodeBitmap: Bitmap,
val selectedQrCodeType: QrCodeType,
val qrCodeTypes: List<QrCodeType>,
val qrCodeTypeFields: List<QrCodeTypeField>,
@IgnoredOnParcel
val cipherFields: List<Text> = emptyList(),
@IgnoredOnParcel
val cipher: CipherView? = null, //TODO do we need to use null?
) : Parcelable
/**
* Models events for the [ViewAsQrCodeScreen].
*/
sealed class ViewAsQrCodeEvent {
/**
* Navigate back.
*/
data object NavigateBack : ViewAsQrCodeEvent()
}
/**
* Represents a set of actions for [ViewAsQrCodeScreen].
*/
sealed class ViewAsQrCodeAction {
/**
* User clicked the back button.
*/
data object BackClick : ViewAsQrCodeAction()
/**
* User selected a QR code type.
*/
data class QrCodeTypeSelect(val qrCodeType: QrCodeType) : ViewAsQrCodeAction()
/**
* User changed a field value.
*/
data class FieldValueChange(val field: QrCodeTypeField, val value: String) :
ViewAsQrCodeAction()
/**
* Internal ViewModel actions.
*/
sealed class Internal : ViewAsQrCodeAction() {
/**
* The cipher data has been received.
*/
data class CipherReceive(
val cipherDataState: DataState<CipherView?>,
) : Internal()
}
}

View File

@@ -0,0 +1,23 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.handlers
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.ViewAsQrCodeAction
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.ViewAsQrCodeViewModel
/**
* A collection of handler functions for managing actions within the context of viewing as QR code.
*/
data class ViewAsQrCodeHandlers(
val onBackClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates the [ViewAsQrCodeHandlers] using the [ViewAsQrCodeViewModel] to send desired
* actions.
*/
fun create(viewModel: ViewAsQrCodeViewModel): ViewAsQrCodeHandlers =
ViewAsQrCodeHandlers(
onBackClick = { viewModel.trySendAction(ViewAsQrCodeAction.BackClick) },
)
}
}

View File

@@ -0,0 +1,108 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model
import android.os.Parcelable
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import kotlinx.parcelize.Parcelize
/**
* Represents the different types of QR codes that can be generated.
*/
enum class QrCodeType(val displayName: Text) {
/**
* WiFi network QR code.
*/
WIFI(R.string.wifi.asText()),
/**
* URL QR code.
*/
URL(R.string.url.asText()),
/**
* Plain text QR code.
*/
PLAIN_TEXT(R.string.text.asText()),
/**
* Email QR code.
*/
EMAIL(R.string.email.asText()),
/**
* Phone number QR code.
*/
PHONE(R.string.phone.asText()),
/**
* vCard contact QR code.
*/
CONTACT_VCARD(R.string.contact_vcard.asText()),
/**
* meCard contact QR code.
*/
CONTACT_MECARD(R.string.contact_mecard.asText());
/**
* List of field definitions for this QR code type.
*/
val fields: List<QrCodeTypeField>
get() = when (this) {
WIFI -> listOf(
QrCodeTypeField("ssid", R.string.ssid.asText(), isRequired = true),
QrCodeTypeField("password", R.string.password.asText(), isRequired = false),
//QrCodeTypeField("options", R.string.password.asText(), isRequired = false),
)
URL -> listOf(
QrCodeTypeField("url", R.string.url.asText(), isRequired = true)
)
PLAIN_TEXT -> listOf(
QrCodeTypeField("text", R.string.text.asText(), isRequired = true)
)
EMAIL -> listOf(
QrCodeTypeField("email", R.string.email.asText(), isRequired = true),
QrCodeTypeField("subject", R.string.subject.asText(), isRequired = false),
QrCodeTypeField("body", R.string.body.asText(), isRequired = false)
)
PHONE -> listOf(
QrCodeTypeField("phone", R.string.phone.asText(), isRequired = true)
)
CONTACT_VCARD, CONTACT_MECARD -> listOf(
QrCodeTypeField("name", R.string.name.asText(), isRequired = true),
QrCodeTypeField("phone", R.string.phone.asText(), isRequired = false),
QrCodeTypeField("email", R.string.email.asText(), isRequired = false),
QrCodeTypeField(
key = "organization",
displayName = R.string.organization.asText(),
isRequired = false
),
QrCodeTypeField("address", R.string.address.asText(), isRequired = false),
QrCodeTypeField("website", R.string.url.asText(), isRequired = false)
)
}
}
/**
* Defines a field for a QR code type.
*
* @property key The unique identifier for this field
* @property displayName The human-readable label for this field
* @property isRequired Whether this field is required
* @property options List of valid options if this is a selection field
* @property defaultValue Default value for this field
* @property selectedOption Display text for the selected option (for dropdown fields)
*/
@Parcelize
data class QrCodeTypeField(
val key: String,
val displayName: Text,
val isRequired: Boolean = false,
var value: Text = "".asText(),
) : Parcelable

View File

@@ -0,0 +1,35 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.ViewAsQrCodeState
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeType
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeTypeField
/**
* Converts the [CipherView] into a [ViewAsQrCodeState.ViewState.Content].
*/
//fun CipherView.toViewState(): ViewAsQrCodeState.ViewState.Content =
// ViewAsQrCodeState.ViewState.Content(
// //TODO map to Content
// selectedQrCodeType = QrCodeType.PLAIN_TEXT,
// qrCodeTypes = emptyList(),
// qrCodeTypeFields = emptyMap<String, QrCodeTypeField>(),
// cipherFields = emptyList(), //TODO UPDATE CIPHER LIST
// //title = "From viewasqrcode.CipherViewExtensions.kt",
// //TODO set fields list
// originalCipher = this,
// attachments = this
// .attachments
// .orEmpty()
// .mapNotNull {
// val id = it.id ?: return@mapNotNull null
// AttachmentsState.AttachmentItem(
// id = id,
// title = it.fileName.orEmpty(),
// displaySize = it.sizeName.orEmpty(),
// )
// },
// newAttachment = null,
// )

View File

@@ -0,0 +1,208 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.core.graphics.toColorInt
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Utility class for generating QR codes.
*/
@OmitFromCoverage
object QrCodeGenerator {
private const val QR_CODE_SIZE = 512 + 256
private const val UTF_8 = "UTF-8"
//
// /**
// * Generate a QR code bitmap from the given configuration.
// *
// * @param config The QR code configuration.
// * @return A bitmap containing the generated QR code.
// */
// fun generateQrCode(config: QrCodeConfig): Bitmap {
// val content = formatQrCodeContent(config)
// return generateQrCodeBitmap(content)
// }
//
// /**
// * Format the content for the QR code based on the configuration.
// *
// * @param config The QR code configuration.
// * @return The formatted content string for the QR code.
// */
// private fun formatQrCodeContent(config: QrCodeConfig): String {
// return when (config.type) {
// QrCodeType.PlainText -> config.fields["text"] ?: ""
// QrCodeType.Url -> config.fields["url"] ?: ""
// QrCodeType.Email -> {
// val email = config.fields["email"] ?: ""
// val subject = config.fields["subject"] ?: ""
// val body = config.fields["body"] ?: ""
//
// if (subject.isNotEmpty() || body.isNotEmpty()) {
// val encodedSubject = URLEncoder.encode(subject, UTF_8)
// val encodedBody = URLEncoder.encode(body, UTF_8)
// "mailto:$email?subject=$encodedSubject&body=$encodedBody"
// } else {
// "mailto:$email"
// }
// }
// QrCodeType.Phone -> "tel:${config.fields["phone"] ?: ""}"
// QrCodeType.SMS -> {
// val phone = config.fields["phone"] ?: ""
// val message = config.fields["message"] ?: ""
//
// if (message.isNotEmpty()) {
// val encodedMessage = URLEncoder.encode(message, UTF_8)
// "smsto:$phone:$encodedMessage"
// } else {
// "smsto:$phone"
// }
// }
// QrCodeType.WiFi -> {
// val ssid = config.fields["ssid"] ?: ""
// val password = config.fields["password"] ?: ""
// val type = config.fields["type"] ?: "WPA"
// val hidden = config.fields["hidden"] == "true"
//
// "WIFI:S:$ssid;T:$type;P:$password;H:$hidden;;"
// }
// QrCodeType.Contact -> {
// val name = config.fields["name"] ?: ""
// val phone = config.fields["phone"] ?: ""
// val email = config.fields["email"] ?: ""
// val organization = config.fields["organization"] ?: ""
// val address = config.fields["address"] ?: ""
//
// buildString {
// append("BEGIN:VCARD\n")
// append("VERSION:3.0\n")
// if (name.isNotEmpty()) append("N:$name\n")
// if (name.isNotEmpty()) append("FN:$name\n")
// if (organization.isNotEmpty()) append("ORG:$organization\n")
// if (phone.isNotEmpty()) append("TEL:$phone\n")
// if (email.isNotEmpty()) append("EMAIL:$email\n")
// if (address.isNotEmpty()) append("ADR:;;$address\n")
// append("END:VCARD")
// }
// }
// }
// }
/**
* Generate a QR code bitmap from the given content string.
*
* @param content The content to encode in the QR code.
* @return A bitmap containing the generated QR code.
*/
fun generateQrCodeBitmap(
content: String,
barcodeFormat: BarcodeFormat = BarcodeFormat.QR_CODE,
): Bitmap {
val hints = mapOf(
EncodeHintType.CHARACTER_SET to UTF_8,
EncodeHintType.MARGIN to 0
)
val bitMatrix = MultiFormatWriter().encode(
content,
barcodeFormat,
QR_CODE_SIZE,
QR_CODE_SIZE,
hints
)
return createBitmapFromBitMatrix(bitMatrix, barcodeFormat)
}
/**
* Create a bitmap from a ZXing BitMatrix.
*
* @param bitMatrix The BitMatrix to convert.
* @return A bitmap representation of the BitMatrix.
*/
private fun createBitmapFromBitMatrix(
bitMatrix: BitMatrix,
barcodeFormat: BarcodeFormat,
): Bitmap {
val width = bitMatrix.width
val height = bitMatrix.height
val bitmap = createBitmap(width, height)
val finderSize = findFinderPatternSize(bitMatrix, width, height)
val contentColor = "#165DDC".toColorInt() // bitwarden blue
val finderPatternColor = "#030E65".toColorInt() // dark blue
val backgroundColor = Color.WHITE
for (x in 0 until width) {
for (y in 0 until height) {
val bit = bitMatrix[x, y]
val color = if (barcodeFormat == BarcodeFormat.QR_CODE) {
val drawingFinderPattern =
isInsideFinderPattern(x, y, width, height, finderSize)
when {
bit && drawingFinderPattern -> finderPatternColor
bit && !drawingFinderPattern -> contentColor
else -> backgroundColor
}
} else if (bit) finderPatternColor else backgroundColor
bitmap[x, y] = color
}
}
return bitmap
}
private fun findFinderPatternSize(bitMatrix: BitMatrix, width: Int, height: Int): Int {
var firstBit = false
var bitCount = 0
for (x in 0 until width) {
for (y in 0 until height) {
val bit = bitMatrix[x, y]
when {
!bit && firstBit -> return bitCount + (y - bitCount)
!bit && !firstBit -> continue
bit && !firstBit -> firstBit = true
}
bitCount++
}
}
return bitCount //finder pattern wasn't found
}
private fun isInsideFinderPattern(
x: Int,
y: Int,
width: Int,
height: Int,
squareSize: Int,
): Boolean {
// Top-left square
if (x < squareSize && y < squareSize) {
return true
}
// Top-right square
if (x > width - squareSize && y < squareSize) {
return true
}
// Bottom-left square
if (x < squareSize && y > height - squareSize) {
return true
}
return false
}
}

View File

@@ -1229,4 +1229,19 @@ Do you want to switch to this account?</string>
<string name="add_field">Add field</string>
<string name="x_ellipses">%s...</string>
<string name="share_error_details">Share error details</string>
<string name="view_as_qr_code">View as QR code</string>
<string name="qr_code">QR code</string>
<string name="qr_code_type">QR code type</string>
<string name="url">URL</string>
<string name="subject">Subject</string>
<string name="body">Body</string>
<string name="message">Message</string>
<string name="ssid">SSID</string>
<string name="encryption_type">Encryption Type</string>
<string name="hidden">Hidden</string>
<string name="sms">SMS</string>
<string name="wifi">WiFi</string>
<string name="contact_vcard">Contact (vCard)</string>
<string name="contact_mecard">Contact (MeCard)</string>
</resources>