Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ksp = "2.1.21-2.0.2"
ucrop = "2.2.11"
androidxTestCore = "1.6.1"
constraintLayout = "1.1.0"
qrose= "1.0.1"

[libraries]
kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutines" }
Expand Down Expand Up @@ -76,6 +77,7 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
ucrop = { group = "com.automattic", name = "ucrop", version.ref = "ucrop" }
qrose = { group = "io.github.alexzhirkevich", name = "qrose", version.ref = "qrose" }

# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
Expand Down
1 change: 1 addition & 0 deletions homeUi/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies {
implementation(libs.gravatar.core)
implementation(libs.gravatar.ui)
implementation(libs.ucrop)
implementation(libs.qrose)

testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) {
onAboutAppClicked = {
onEvent(ShareEvent.OnAboutAppClicked)
},
vCardQrCodeData = uiState.vCardQrCodeData.toString(),
modifier = Modifier
.fillMaxWidth(),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.gravatar.app.homeUi.presentation.home.share

import com.gravatar.app.homeUi.presentation.home.share.model.VCard
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
import com.gravatar.restapi.models.Profile
Expand All @@ -12,7 +13,6 @@ internal data class ShareUiState(
val userSharePreferences: UserSharePreferences = UserSharePreferences.Default,
val isPrivateInformationDialogVisible: Boolean = false,
) {

val privateContactState = PrivateContactState(
emailValue = privateContactInfo.privateEmail,
isEmailShared = userSharePreferences.privateEmail,
Expand All @@ -34,11 +34,23 @@ internal data class ShareUiState(
profileUrl = if (shareFieldType is ShareFieldType.ProfileUrl) shareFieldType.checked else userSharePreferences.profileUrl,
)
)

val vCardQrCodeData: VCard = VCard.Builder()
.firstName(profile?.firstName.takeIf { userSharePreferences.name })
.lastName(profile?.lastName.takeIf { userSharePreferences.name })
.nickname(profile?.displayName.takeIf { userSharePreferences.description })
.organization(profile?.company.takeIf { userSharePreferences.organization })
.title(profile?.jobTitle.takeIf { userSharePreferences.title })
.profileUrl(profile?.profileUrl.toString().takeIf { userSharePreferences.profileUrl })
.note(profile?.description.takeIf { userSharePreferences.description })
.phoneNumber(privateContactState.phoneValue.takeIf { privateContactState.isPhoneShared })
.email(privateContactState.emailValue.takeIf { privateContactState.isEmailShared })
.build()
}

internal data class PrivateContactState(
val emailValue: String = "",
val isEmailShared: Boolean = false,
val isEmailShared: Boolean = true,
Copy link

Copilot AI Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the default value from false to true for isEmailShared is a breaking change that could unexpectedly expose user email addresses. This should default to false for privacy protection.

Suggested change
val isEmailShared: Boolean = true,
val isEmailShared: Boolean = false,

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that on purpose? It's ON even with the field empty 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that's what we agreed in the last call, and it's how the iOS app is working at the moment. However, we can improve this behaviour. We can discuss this with iOS folks.

val phoneValue: String = "",
val isPhoneShared: Boolean = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
Expand All @@ -33,14 +34,27 @@ import com.gravatar.app.homeUi.R
import com.gravatar.app.homeUi.presentation.home.components.BlurredHeaderBackground
import com.gravatar.app.homeUi.presentation.home.components.topbar.TopBarPickerPopup
import com.gravatar.app.homeUi.presentation.home.profile.header.MENU_BUTTON_SIZE
import io.github.alexzhirkevich.qrose.options.QrBallShape
import io.github.alexzhirkevich.qrose.options.QrFrameShape
import io.github.alexzhirkevich.qrose.options.QrPixelShape
import io.github.alexzhirkevich.qrose.options.roundCorners
import io.github.alexzhirkevich.qrose.rememberQrCodePainter

@Composable
internal fun ShareHeader(
avatarUrl: String,
vCardQrCodeData: String,
modifier: Modifier = Modifier,
onAboutAppClicked: () -> Unit = {},
) {
var topBarMenuVisible by remember { mutableStateOf(false) }
val qrcodePainter: Painter = rememberQrCodePainter(vCardQrCodeData) {
shapes {
ball = QrBallShape.roundCorners(.30f)
darkPixel = QrPixelShape.roundCorners()
frame = QrFrameShape.roundCorners(.15f)
}
}

BlurredHeaderBackground(
avatarUrl = avatarUrl,
Expand All @@ -59,14 +73,17 @@ internal fun ShareHeader(
.padding(top = 6.dp)
.weight(1f),
) {
Box(
Image(
painter = qrcodePainter,
contentDescription = null,
modifier = Modifier
.background(Color.White, RoundedCornerShape(4.dp))
.fillMaxWidth()
.aspectRatio(
ratio = 1f,
matchHeightConstraintsFirst = false,
)
.padding(6.dp)
)
Text(
text = stringResource(R.string.share_tab_scan_qr_code),
Expand Down Expand Up @@ -111,6 +128,7 @@ private fun ShareHeaderPreview() {
GravatarAppTheme {
ShareHeader(
avatarUrl = "url",
vCardQrCodeData = "BEGIN:VCARD\nVERSION:3.0\nFN:Preview User\nEND:VCARD",
modifier = Modifier
.fillMaxWidth()
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.gravatar.app.homeUi.presentation.home.share.model

internal class VCard private constructor(
val firstName: String? = null,
val lastName: String? = null,
val nickname: String? = null,
val organization: String? = null,
val title: String? = null,
val profileUrl: String? = null,
val note: String? = null,
val phoneNumber: String? = null,
val email: String? = null,
) {

override fun toString(): String {
val contentBuilder = StringBuilder().append("BEGIN:VCARD\n")
.append("VERSION:3.0\n")
.append("PRODID:Gravatar Android\n")

val firstName = firstName.orEmpty()
val lastName = lastName.orEmpty()
if (firstName.isNotEmpty() || lastName.isNotEmpty()) {
contentBuilder.append("N:${lastName.escaped()};${firstName.escaped()};;;\n")
} else {
nickname?.takeIf { it.isNotEmpty() }?.let {
contentBuilder.append("N:;${nickname.escaped()};;;\n")
}
}

// Providing an empty FN as it is required for vCard 3.0.
contentBuilder.append("FN:\n")

nickname?.takeIf { it.isNotEmpty() }?.let {
contentBuilder
.append("NICKNAME:${it.escaped()}\n")
}
organization?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("ORG:${it.escaped()}\n") }
title?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("TITLE:${it.escaped()}\n") }
profileUrl?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("URL:${it.escaped()}\n") }
note?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("NOTE:${it.escaped()}\n") }
phoneNumber?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("TEL;TYPE=cell:${it.escaped()}\n") }
email?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("EMAIL:${it.escaped()}\n") }

contentBuilder.append("END:VCARD")
return contentBuilder.toString()
}

// We've seen issues with newlines in the vCard content causing problems when importing the contact so removing them
private fun String.escaped() = this.replace("\n", " ")

class Builder(
private var firstName: String? = null,
private var lastName: String? = null,
private var nickname: String? = null,
private var organization: String? = null,
private var title: String? = null,
private var profileUrl: String? = null,
private var note: String? = null,
private var phoneNumber: String? = null,
private var email: String? = null,
) {
fun firstName(firstName: String?) = apply { this.firstName = firstName }
fun lastName(lastName: String?) = apply { this.lastName = lastName }
fun nickname(nickname: String?) = apply { this.nickname = nickname }
fun organization(organization: String?) = apply { this.organization = organization }
fun title(title: String?) = apply { this.title = title }
fun profileUrl(url: String?) = apply { this.profileUrl = url }
fun note(description: String?) = apply { this.note = description }
fun phoneNumber(phone: String?) = apply { this.phoneNumber = phone }
fun email(email: String?) = apply { this.email = email }

fun build() = VCard(
firstName = firstName,
lastName = lastName,
nickname = nickname,
organization = organization,
title = title,
profileUrl = profileUrl,
note = note,
phoneNumber = phoneNumber,
email = email
)
}
}
Loading