diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeScreen.kt index 681ddbdfe3..7437b271c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeScreen.kt @@ -8,6 +8,7 @@ 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 @@ -32,6 +33,8 @@ 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 @@ -96,10 +99,25 @@ fun ViewAsQrCodeScreen( ) } - Spacer(modifier = Modifier.height(12.dp)) + //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(), @@ -119,7 +137,15 @@ fun ViewAsQrCodeScreen( ) //QR Code Type dropdowns - Spacer(modifier = Modifier.height(8.dp)) + 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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeViewModel.kt index a5e751a485..366b64322d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/ViewAsQrCodeViewModel.kt @@ -27,6 +27,7 @@ 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. @@ -45,13 +46,13 @@ class ViewAsQrCodeViewModel @Inject constructor( ViewAsQrCodeState( cipherId = args.vaultItemId, cipherType = args.vaultItemCipherType, - qrCodeBitmap = QrCodeGenerator.generateQrCodeBitmap("↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,↑, ↑, ↓, ↓, ←, →, ←, →, B, A,"), + 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, ) @@ -61,11 +62,11 @@ class ViewAsQrCodeViewModel @Inject constructor( init { //TODO get args.vaultItemCipherType and auto-map - mutableStateFlow.update { - it.copy( - cipherFields = cipherFieldsFor(it.cipherType, null), - ) - } +// mutableStateFlow.update { +// it.copy( +// cipherFields = cipherFieldsFor(it.cipherType, null), +// ) +// } vaultRepository .getVaultItemStateFlow(args.vaultItemId) .map { ViewAsQrCodeAction.Internal.CipherReceive(it) } @@ -118,61 +119,32 @@ class ViewAsQrCodeViewModel @Inject constructor( 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) { + 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 = autoMapFields( - action.qrCodeType.fields, - state.cipherType, - state.cipher - ) + qrCodeTypeFields = updatedFields, + qrCodeBitmap = updatedQrCodeBitmap, + qrCodeContent = updatedQrCodeContent, ) } } @@ -195,7 +167,7 @@ class ViewAsQrCodeViewModel @Inject constructor( val selectedText = value.asText() //TODO should we transition qrCodeTypeFields to a map again*2 and update using key? - val updatedFields = state.qrCodeTypeFields.map { currentField -> + var updatedFields = state.qrCodeTypeFields.map { currentField -> if (currentField.key == field.key) { currentField.copy(value = selectedText) } else { @@ -203,11 +175,56 @@ class ViewAsQrCodeViewModel @Inject constructor( } } + 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) + it.copy( + qrCodeTypeFields = updatedFields, + qrCodeBitmap = updatedQrCodeBitmap, + qrCodeContent = updatedQrCodeContent + ) } } + private fun setCipherValues( + cipher: CipherView?, + qrCodeType: QrCodeType, + qrCodeTypeFields: List, + ): List { + 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): Boolean { + return true // TODO + } + private fun autoMapField( qrCodeTypeField: QrCodeTypeField, cipherType: VaultItemCipherType, @@ -304,7 +321,8 @@ data class ViewAsQrCodeState( @IgnoredOnParcel val cipherFields: List = emptyList(), @IgnoredOnParcel - val cipher: CipherView? = null, //TODO do we need to use null? + val cipher: CipherView? = null, + val qrCodeContent: String = "", ) : Parcelable /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/model/QrCodeType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/model/QrCodeType.kt index 3c28310ffb..5c46b59c7d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/model/QrCodeType.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/model/QrCodeType.kt @@ -104,5 +104,6 @@ data class QrCodeTypeField( val key: String, val displayName: Text, val isRequired: Boolean = false, - var value: Text = "".asText(), + val value: Text = "".asText(), + val cipherValue: String = "", ) : Parcelable diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/util/QrCodeGenerator.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/util/QrCodeGenerator.kt index 867c01ab44..010053bbd8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/util/QrCodeGenerator.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/viewasqrcode/util/QrCodeGenerator.kt @@ -10,6 +10,8 @@ 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. @@ -19,6 +21,104 @@ object QrCodeGenerator { private const val QR_CODE_SIZE = 512 + 256 private const val UTF_8 = "UTF-8" + + fun createContentFor(qrCodeType: QrCodeType, qrCodeFields: List): 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): Bitmap { + val content = createContentFor(qrCodeType, qrCodeFields) + return generateQrCodeBitmap(content) + } + + fun createContentForContactMecard(qrCodeFields: List): 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): 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): 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): String { + return qrCodeFields.firstOrNull()?.cipherValue ?: "" + } + + fun createContentForEmail(qrCodeFields: List): String { + val value = qrCodeFields.firstOrNull()?.cipherValue ?: "" + return "email:$value" + } + + fun createContentForPhone(qrCodeFields: List): String { + val value = qrCodeFields.firstOrNull()?.cipherValue ?: "" + return "phone:$value" + } // // /** // * Generate a QR code bitmap from the given configuration. @@ -140,7 +240,7 @@ object QrCodeGenerator { 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] diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 525ef95452..3295dd7a85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1231,6 +1231,7 @@ Do you want to switch to this account? Share error details View as QR code + Data to share QR code QR code type URL