Add ViewAsQrCode first draft

This commit is contained in:
Álison Fernandes 2025-03-18 18:28:42 +00:00
parent f4f669683e
commit 225cb24ac1
No known key found for this signature in database
GPG Key ID: B8CE98903DFC87BC
12 changed files with 1155 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,369 @@
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.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
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.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.attachments.handlers.AttachmentsHandlers
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.handlers.ViewAsQrCodeHandlers
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeType
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,
)
},
) {
when (val viewState = state.viewState) {
is ViewAsQrCodeState.ViewState.Loading -> BitwardenLoadingContent(
modifier = Modifier.fillMaxSize(),
)
is ViewAsQrCodeState.ViewState.Error -> BitwardenErrorContent(
message = "ERROR",
onTryAgainClick = remember(viewModel) {
{ viewModel.trySendAction(ViewAsQrCodeAction.BackClick) }
},
modifier = Modifier.fillMaxSize(),
)
is ViewAsQrCodeState.ViewState.Content -> {
//TODO add ViewAsQrCodeContent
val contentState = state
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// QR Code display
Box(
modifier = Modifier
.size(250.dp)
.background(Color.White)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Image(
//TODO set qrcode image
painter = rememberVectorPainter(id = R.drawable.bitwarden_logo),
colorFilter = ColorFilter.tint(BitwardenTheme.colorScheme.icon.secondary),
//bitmap = contentState.qrCodeBitmap.asImageBitmap(),
contentDescription = stringResource(id = R.string.qr_code),
modifier = Modifier.fillMaxSize(),
)
}
Spacer(modifier = Modifier.height(24.dp))
//
// // QR Code type selector
// BitwardenMultiSelectButton(
// label = stringResource(id = R.string.qr_code_type),
// options = contentState.qrCodeTypes.map { it.displayName() }.toImmutableList(),
// selectedOption = contentState.selectedQrCodeType.displayName(),
// onOptionSelected = { selectedOption ->
// val selectedType = contentState.qrCodeTypes.first {
// it.displayName() == selectedOption
// }
// viewModel.trySendAction(ViewAsQrCodeAction.QrCodeTypeSelect(selectedType))
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(16.dp))
//
// // Dynamic fields based on selected QR code type
// when (contentState.selectedQrCodeType) {
// QrCodeType.Text -> {
// BitwardenTextField(
// label = stringResource(id = R.string.text),
// value = contentState.fields["text"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("text", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
//
// QrCodeType.Url -> {
// BitwardenTextField(
// label = stringResource(id = R.string.url),
// value = contentState.fields["url"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("url", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
//
// QrCodeType.Email -> {
// BitwardenTextField(
// label = stringResource(id = R.string.email),
// value = contentState.fields["email"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("email", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.subject),
// value = contentState.fields["subject"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("subject", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.body),
// value = contentState.fields["body"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("body", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
//
// QrCodeType.Phone -> {
// BitwardenTextField(
// label = stringResource(id = R.string.phone),
// value = contentState.fields["phone"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("phone", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
//
// QrCodeType.SMS -> {
// BitwardenTextField(
// label = stringResource(id = R.string.phone),
// value = contentState.fields["phone"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("phone", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.message),
// value = contentState.fields["message"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("message", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
//
// QrCodeType.WiFi -> {
// BitwardenTextField(
// label = stringResource(id = R.string.ssid),
// value = contentState.fields["ssid"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("ssid", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.password),
// value = contentState.fields["password"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("password", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenMultiSelectButton(
// label = stringResource(id = R.string.encryption_type),
// options = listOf("WPA", "WEP", "None").toImmutableList(),
// selectedOption = contentState.fields["type"] ?: "WPA",
// onOptionSelected = { selectedOption ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("type", selectedOption)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenMultiSelectButton(
// label = stringResource(id = R.string.hidden),
// options = listOf("true", "false").toImmutableList(),
// selectedOption = contentState.fields["hidden"] ?: "false",
// onOptionSelected = { selectedOption ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("hidden", selectedOption)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
//
// QrCodeType.Contact -> {
// BitwardenTextField(
// label = stringResource(id = R.string.name),
// value = contentState.fields["name"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("name", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.phone),
// value = contentState.fields["phone"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("phone", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.email),
// value = contentState.fields["email"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("email", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.organization),
// value = contentState.fields["organization"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("organization", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(modifier = Modifier.height(8.dp))
//
// BitwardenTextField(
// label = stringResource(id = R.string.address),
// value = contentState.fields["address"] ?: "",
// onValueChange = { newValue ->
// viewModel.trySendAction(
// ViewAsQrCodeAction.FieldValueChange("address", newValue)
// )
// },
// modifier = Modifier.fillMaxWidth(),
// )
// }
// }
}
}
}
}
}

View File

@ -0,0 +1,353 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode
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.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemArgs
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeConfig
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeType
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util.QrCodeGenerator
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util.toViewState
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.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)
ViewAsQrCodeState(
cipherId = args.vaultItemId,
cipherType = args.vaultItemCipherType,
viewState = ViewAsQrCodeState.ViewState.Loading,
dialogState = null,
)
},
) {
private val args = ViewAsQrCodeArgs(savedStateHandle)
init {
//TODO get args.vaultItemCipherType and auto-map
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.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.NoNetwork -> mutableStateFlow.update {
it.copy(
viewState = ViewAsQrCodeState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(
" ".asText(),
R.string.internet_connection_required_message.asText(),
),
),
)
}
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) {
// val currentState = state as? ViewAsQrCodeState.Content ?: return
// val cipher = currentState.cipher
//
// // Generate default fields based on the selected QR code type and cipher data
// val fields = when (action.qrCodeType) {
// QrCodeType.Text -> mapOf("text" to cipher.name)
// QrCodeType.Url -> {
// val loginUri = cipher.login?.uris?.firstOrNull()?.uri ?: ""
// mapOf("url" to loginUri)
// }
// QrCodeType.Email -> {
// val email = cipher.login?.username.orEmpty()
// mapOf(
// "email" to email,
// "subject" to "",
// "body" to ""
// )
// }
// QrCodeType.Phone -> {
// val phone = when {
// cipher.identity?.phone != null -> cipher.identity.phone
// cipher.card?.cardholderName != null -> ""
// else -> ""
// }
// mapOf("phone" to phone)
// }
// QrCodeType.SMS -> {
// val phone = when {
// cipher.identity?.phone != null -> cipher.identity.phone
// else -> ""
// }
// mapOf(
// "phone" to phone,
// "message" to ""
// )
// }
// QrCodeType.WiFi -> mapOf(
// "ssid" to "",
// "password" to "",
// "type" to "WPA",
// "hidden" to "false"
// )
// QrCodeType.Contact -> {
// val name = when {
// cipher.identity != null -> "${cipher.identity.firstName} ${cipher.identity.lastName}"
// cipher.card?.cardholderName != null -> cipher.card.cardholderName
// else -> cipher.name
// }
// val email = when {
// cipher.identity?.email != null -> cipher.identity.email
// cipher.login?.username != null -> cipher.login.username
// else -> ""
// }
// val phone = when {
// cipher.identity?.phone != null -> cipher.identity.phone
// else -> ""
// }
// val organization = cipher.identity?.company ?: ""
// val address = when {
// cipher.identity != null -> "${cipher.identity.address1} ${cipher.identity.address2} ${cipher.identity.city} ${cipher.identity.state} ${cipher.identity.postalCode} ${cipher.identity.country}"
// else -> ""
// }
//
// mapOf(
// "name" to name,
// "phone" to phone,
// "email" to email,
// "organization" to organization,
// "address" to address
// )
// }
// }
//
// val config = QrCodeConfig(action.qrCodeType, fields)
// val qrCodeBitmap = QrCodeGenerator.generateQrCode(config)
//
// updateState {
// (it as ViewAsQrCodeState.Content).copy(
// selectedQrCodeType = action.qrCodeType,
// fields = fields,
// qrCodeBitmap = qrCodeBitmap
// )
// }
}
private fun handleFieldValueChange(action: ViewAsQrCodeAction.FieldValueChange) {
// val currentState = state as? ViewAsQrCodeState.Content ?: return
//
// val updatedFields = currentState.fields.toMutableMap().apply {
// put(action.fieldKey, action.value)
// }
//
// val config = QrCodeConfig(currentState.selectedQrCodeType, updatedFields)
// val qrCodeBitmap = QrCodeGenerator.generateQrCode(config)
//
// updateState {
// (it as ViewAsQrCodeState.Content).copy(
// fields = updatedFields,
// qrCodeBitmap = qrCodeBitmap
// )
// }
}
}
/**
* Represents the state for viewing attachments.
*/
@Parcelize
data class ViewAsQrCodeState(
val cipherId: String,
val cipherType: VaultItemCipherType,
val viewState: ViewState,
val dialogState: DialogState?,
) : Parcelable {
/**
* Represents the specific view states for the [ViewAsQrCodeScreen].
*/
sealed class ViewState : Parcelable {
/**
* Represents an error state for the [ViewAsQrCodeScreen].
*/
@Parcelize
data class Error(val message: Text) : ViewState()
/**
* Loading state for the [ViewAsQrCodeScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a loaded content state for the [ViewAsQrCodeScreen].
*/
@Parcelize
data class Content(
val title: String,
// val qrCodeBitmap: Bitmap,
// val selectedQrCodeType: QrCodeType,
// val qrCodeTypes: ImmutableList<QrCodeType>,
// val fields: Map<String, String>,
// val cipher: com.x8bit.bitwarden.data.vault.datasource.model.Cipher
// ) : ViewAsQrCodeState()
//TODO add content?
) : ViewState()
}
//TODO do we need dialogs?
/**
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents a dismissible dialog with the given error [message].
*/
@Parcelize
data class Error(
val title: Text?,
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* 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()
//TODO deleteme
/**
* User selected a QR code type.
*/
data class QrCodeTypeSelect(val qrCodeType: QrCodeType) : ViewAsQrCodeAction()
/**
* User changed a field value.
*/
data class FieldValueChange(val fieldKey: String, 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,71 @@
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.
*/
sealed class QrCodeType(val displayName: Text) : Parcelable {
/**
* Plain text QR code.
*/
@Parcelize
data object PlainText : QrCodeType(R.string.text.asText())
/**
* URL QR code.
*/
@Parcelize
data object Url : QrCodeType(R.string.url.asText())
/**
* Email QR code.
*/
@Parcelize
data object Email : QrCodeType(R.string.email.asText())
/**
* Phone number QR code.
*/
@Parcelize
data object Phone : QrCodeType(R.string.phone.asText())
/**
* SMS QR code.
*/
@Parcelize
data object SMS : QrCodeType(R.string.sms.asText())
/**
* WiFi network QR code.
*/
@Parcelize
data object WiFi : QrCodeType(R.string.wifi.asText())
/**
* vCard contact QR code.
*/
@Parcelize
data object Contact : QrCodeType(R.string.contact.asText())
companion object {
/**
* List of all available QR code types.
*/
val ALL = listOf(PlainText, Url, Email, Phone, SMS, WiFi, Contact)
}
}
/**
* Represents the configuration options for a QR code.
*/
@Parcelize
data class QrCodeConfig(
val type: QrCodeType,
val fields: Map<String, String> = emptyMap()
) : Parcelable

View File

@ -0,0 +1,27 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.ViewAsQrCodeState
/**
* Converts the [CipherView] into a [ViewAsQrCodeState.ViewState.Content].
*/
fun CipherView.toViewState(): ViewAsQrCodeState.ViewState.Content =
ViewAsQrCodeState.ViewState.Content(
//TODO map to Content
title = "From viewasqrcode.CipherViewExtensions.kt"
// 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,140 @@
package com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.util
import android.graphics.Bitmap
import android.graphics.Color
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
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeType
import java.net.URLEncoder
/**
* Utility class for generating QR codes.
*/
@OmitFromCoverage
object QrCodeGenerator {
private const val QR_CODE_SIZE = 512
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.
*/
private fun generateQrCodeBitmap(content: String): Bitmap {
val hints = mapOf(
EncodeHintType.CHARACTER_SET to UTF_8,
EncodeHintType.MARGIN to 1
)
val bitMatrix = MultiFormatWriter().encode(
content,
BarcodeFormat.QR_CODE,
QR_CODE_SIZE,
QR_CODE_SIZE,
hints
)
return createBitmapFromBitMatrix(bitMatrix)
}
/**
* 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): Bitmap {
val width = bitMatrix.width
val height = bitMatrix.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bitmap
}
}

View File

@ -1229,4 +1229,18 @@ 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">Contact</string>
</resources>