mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 05:20:24 -05:00
Compare commits
11 Commits
pr-6572
...
qrcode/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec84fbd46 | ||
|
|
605e0ef023 | ||
|
|
6d196b5214 | ||
|
|
58a2812b74 | ||
|
|
8f345234d9 | ||
|
|
0407972926 | ||
|
|
a6f4717b35 | ||
|
|
89fa10749c | ||
|
|
d65e027210 | ||
|
|
225cb24ac1 | ||
|
|
60913e1a94 |
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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!",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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.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.field.BitwardenTextField
|
||||
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
//TODO debug - remove this
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
BitwardenTextField(
|
||||
label = "Debug - QRCode Content",
|
||||
value = state.qrCodeContent,
|
||||
onValueChange = { },
|
||||
readOnly = true,
|
||||
singleLine = false,
|
||||
textFieldTestTag = "LoginUsernameEntry",
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.testTag("QRCodeType")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// QR Code type selector
|
||||
val resources = LocalContext.current.resources
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
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(height = 8.dp))
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.data_to_share),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
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"
|
||||
private const val DEFAULT_QR_CODE_DATA = "https://bitwarden.com"
|
||||
|
||||
/**
|
||||
* 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(DEFAULT_QR_CODE_DATA),
|
||||
selectedQrCodeType = selectedQrCodeType,
|
||||
qrCodeTypes = qrCodeTypes,
|
||||
qrCodeTypeFields = selectedQrCodeType.fields,
|
||||
cipherFields = emptyList(),
|
||||
cipher = null,
|
||||
qrCodeContent = DEFAULT_QR_CODE_DATA,
|
||||
// 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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQrCodeTypeSelect(action: ViewAsQrCodeAction.QrCodeTypeSelect) {
|
||||
|
||||
val updatedFields = autoMapFields(
|
||||
action.qrCodeType.fields,
|
||||
state.cipherType,
|
||||
state.cipher
|
||||
)
|
||||
|
||||
setCipherValues(state.cipher, state.selectedQrCodeType, updatedFields)
|
||||
|
||||
val updatedQrCodeContent = if (validateRequiredFields(updatedFields))
|
||||
QrCodeGenerator.createContentFor(state.selectedQrCodeType, updatedFields)
|
||||
else
|
||||
DEFAULT_QR_CODE_DATA
|
||||
|
||||
val updatedQrCodeBitmap = QrCodeGenerator.generateQrCodeBitmap(updatedQrCodeContent)
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
selectedQrCodeType = action.qrCodeType,
|
||||
qrCodeTypeFields = updatedFields,
|
||||
qrCodeBitmap = updatedQrCodeBitmap,
|
||||
qrCodeContent = updatedQrCodeContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
var updatedFields = state.qrCodeTypeFields.map { currentField ->
|
||||
if (currentField.key == field.key) {
|
||||
currentField.copy(value = selectedText)
|
||||
} else {
|
||||
currentField
|
||||
}
|
||||
}
|
||||
|
||||
updatedFields = setCipherValues(state.cipher, state.selectedQrCodeType, updatedFields)
|
||||
|
||||
val updatedQrCodeContent = if (validateRequiredFields(updatedFields))
|
||||
QrCodeGenerator.createContentFor(state.selectedQrCodeType, updatedFields)
|
||||
else
|
||||
DEFAULT_QR_CODE_DATA
|
||||
|
||||
val updatedQrCodeBitmap = QrCodeGenerator.generateQrCodeBitmap(updatedQrCodeContent)
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
qrCodeTypeFields = updatedFields,
|
||||
qrCodeBitmap = updatedQrCodeBitmap,
|
||||
qrCodeContent = updatedQrCodeContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCipherValues(
|
||||
cipher: CipherView?,
|
||||
qrCodeType: QrCodeType,
|
||||
qrCodeTypeFields: List<QrCodeTypeField>,
|
||||
): List<QrCodeTypeField> {
|
||||
if (cipher == null) {
|
||||
return qrCodeTypeFields
|
||||
}
|
||||
|
||||
//TODO figure a way to reuse autoMapField code, by returning (field Text + Cipher value) perhaps
|
||||
return qrCodeTypeFields.map { field ->
|
||||
field.copy(cipherValue = getCipherValueForField(cipher, field))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCipherValueForField(cipher: CipherView, qrCodeField: QrCodeTypeField): String {
|
||||
//TODO refactor - currently failing because asText() creates a different object
|
||||
return when (qrCodeField.value) {
|
||||
R.string.name.asText() -> cipher.name
|
||||
R.string.username.asText() -> cipher.login?.username ?: ""
|
||||
R.string.password.asText() -> cipher.login?.username ?: ""
|
||||
R.string.notes.asText() -> cipher.notes ?: ""
|
||||
"Custom: SSID".asText() -> cipher.fields?.find { it.name == "Custom: SSID" }?.value
|
||||
?: ""
|
||||
|
||||
else -> "TODO()" //TODO
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateRequiredFields(qrCodeTypeFields: List<QrCodeTypeField>): Boolean {
|
||||
return true // TODO
|
||||
}
|
||||
|
||||
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,
|
||||
val qrCodeContent: String = "",
|
||||
) : 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()
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
val value: Text = "".asText(),
|
||||
val cipherValue: String = "",
|
||||
) : Parcelable
|
||||
@@ -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,
|
||||
// )
|
||||
@@ -0,0 +1,308 @@
|
||||
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
|
||||
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.viewasqrcode.model.QrCodeTypeField
|
||||
|
||||
/**
|
||||
* Utility class for generating QR codes.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
object QrCodeGenerator {
|
||||
|
||||
private const val QR_CODE_SIZE = 512 + 256
|
||||
private const val UTF_8 = "UTF-8"
|
||||
|
||||
fun createContentFor(qrCodeType: QrCodeType, qrCodeFields: List<QrCodeTypeField>): String {
|
||||
return when (qrCodeType) {
|
||||
QrCodeType.WIFI -> createContentForWifi(qrCodeFields)
|
||||
QrCodeType.URL -> createContentForText(qrCodeFields)
|
||||
QrCodeType.PLAIN_TEXT -> createContentForText(qrCodeFields)
|
||||
QrCodeType.EMAIL -> createContentForEmail(qrCodeFields)
|
||||
QrCodeType.PHONE -> createContentForPhone(qrCodeFields)
|
||||
QrCodeType.CONTACT_VCARD -> createContentForContactVcard(qrCodeFields)
|
||||
QrCodeType.CONTACT_MECARD -> createContentForContactMecard(qrCodeFields)
|
||||
}
|
||||
}
|
||||
|
||||
fun generateQrCode(qrCodeType: QrCodeType, qrCodeFields: List<QrCodeTypeField>): Bitmap {
|
||||
val content = createContentFor(qrCodeType, qrCodeFields)
|
||||
return generateQrCodeBitmap(content)
|
||||
}
|
||||
|
||||
fun createContentForContactMecard(qrCodeFields: List<QrCodeTypeField>): String {
|
||||
val firstName = "firstName"
|
||||
val lastName = "lastName"
|
||||
val phone = "phone"
|
||||
val email = "email"
|
||||
val address = "address"
|
||||
|
||||
val addName = lastName.isNotEmpty() || firstName.isNotEmpty()
|
||||
return buildString {
|
||||
append("MECARD:")
|
||||
if (addName) {
|
||||
append("N:")
|
||||
if (lastName.isNotEmpty()) append(lastName)
|
||||
if (firstName.isNotEmpty()) append(",$firstName")
|
||||
append(";")
|
||||
}
|
||||
|
||||
if (phone.isNotEmpty()) append("TEL:$phone;")
|
||||
if (email.isNotEmpty()) append("EMAIL:$email;")
|
||||
if (address.isNotEmpty()) append("ADR:$address;")
|
||||
append(";")
|
||||
}
|
||||
}
|
||||
|
||||
fun createContentForContactVcard(qrCodeFields: List<QrCodeTypeField>): String {
|
||||
val name = "name"
|
||||
val phone = "phone"
|
||||
val email = "email"
|
||||
val organization = "organization"
|
||||
val address = "address"
|
||||
|
||||
return 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")
|
||||
}
|
||||
}
|
||||
|
||||
fun createContentForWifi(qrCodeFields: List<QrCodeTypeField>): String {
|
||||
var ssid = String()
|
||||
var password = String()
|
||||
var additionalOptions = String()
|
||||
|
||||
qrCodeFields.forEach {
|
||||
when (it.key) {
|
||||
"ssid" -> ssid = it.cipherValue
|
||||
"password" -> password = it.cipherValue
|
||||
"additionalOptions" -> additionalOptions = it.cipherValue
|
||||
}
|
||||
}
|
||||
|
||||
return buildString {
|
||||
append("WIFI:")
|
||||
if (password.isNotEmpty() && !additionalOptions.contains("T:")) append("T:WPA;")
|
||||
append("ssid:$ssid;")
|
||||
if (password.isNotEmpty()) append("P:$password;")
|
||||
if (additionalOptions.isNotEmpty()) append(additionalOptions)
|
||||
append(";")
|
||||
}
|
||||
}
|
||||
|
||||
fun createContentForText(qrCodeFields: List<QrCodeTypeField>): String {
|
||||
return qrCodeFields.firstOrNull()?.cipherValue ?: ""
|
||||
}
|
||||
|
||||
fun createContentForEmail(qrCodeFields: List<QrCodeTypeField>): String {
|
||||
val value = qrCodeFields.firstOrNull()?.cipherValue ?: ""
|
||||
return "email:$value"
|
||||
}
|
||||
|
||||
fun createContentForPhone(qrCodeFields: List<QrCodeTypeField>): String {
|
||||
val value = qrCodeFields.firstOrNull()?.cipherValue ?: ""
|
||||
return "phone:$value"
|
||||
}
|
||||
//
|
||||
// /**
|
||||
// * 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
|
||||
}
|
||||
}
|
||||
@@ -1229,4 +1229,20 @@ 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="data_to_share">Data to share</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>
|
||||
|
||||
Reference in New Issue
Block a user