Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR implements QR code generation with vCard data for user profiles using the qrose library. The implementation creates QR codes containing contact information that users can share, with configurable privacy settings for email and phone sharing.
Key changes:
- Added QR code generation using the qrose library with vCard format
- Implemented privacy controls for email and phone sharing in QR codes
- Updated UI components to display generated QR codes in the share section
Reviewed Changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
gradle/libs.versions.toml |
Added qrose library dependency for QR code generation |
homeUi/build.gradle.kts |
Integrated qrose library into the homeUi module |
ShareUiState.kt |
Added vCard data generation logic and updated privacy defaults |
ShareHeader.kt |
Integrated QR code display using qrose painter |
ShareScreen.kt |
Connected QR code data to the header component |
ShareViewModel.kt |
Added avatar URL dependency for complete profile data |
ShareUiStateTest.kt |
Added comprehensive test coverage for vCard generation logic |
ShareViewModelTest.kt |
Updated test to accommodate new avatar URL dependency |
ShareHeaderTest.kt |
Updated test with required vCard data parameter |
| internal data class PrivateContactInfo( | ||
| val emailValue: String = "", | ||
| val isEmailShared: Boolean = false, | ||
| val isEmailShared: Boolean = true, |
There was a problem hiding this comment.
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.
| val isEmailShared: Boolean = true, | |
| val isEmailShared: Boolean = false, |
There was a problem hiding this comment.
Is that on purpose? It's ON even with the field empty 🤔
There was a problem hiding this comment.
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 isEmailShared: Boolean = true, | ||
| val phoneValue: String = "", | ||
| val isPhoneShared: Boolean = false | ||
| val isPhoneShared: Boolean = true, |
There was a problem hiding this comment.
Changing the default value from false to true for isPhoneShared is a breaking change that could unexpectedly expose user phone numbers. This should default to false for privacy protection.
| val isPhoneShared: Boolean = true, | |
| val isPhoneShared: Boolean = false, |
| vCardBuilder.append("FN:${("$firstName $lastName".trim()).ifEmpty { profile.displayName }}\n") | ||
| vCardBuilder.append("NICKNAME:${profile.displayName.ifEmpty { "$firstName $lastName".trim() }}\n") |
There was a problem hiding this comment.
[nitpick] The nested string interpolation and conditional logic is complex and hard to read. Consider extracting this to a separate variable: val fullName = "$firstName $lastName".trim().ifEmpty { profile.displayName }
| vCardBuilder.append("FN:${("$firstName $lastName".trim()).ifEmpty { profile.displayName }}\n") | |
| vCardBuilder.append("NICKNAME:${profile.displayName.ifEmpty { "$firstName $lastName".trim() }}\n") | |
| val fullName = "$firstName $lastName".trim().ifEmpty { profile.displayName } | |
| vCardBuilder.append("FN:$fullName\n") | |
| vCardBuilder.append("NICKNAME:${profile.displayName.ifEmpty { fullName }}\n") |
| vCardBuilder.append("N:$lastName;$firstName;;;\n") | ||
| vCardBuilder.append("FN:${("$firstName $lastName".trim()).ifEmpty { profile.displayName }}\n") | ||
| vCardBuilder.append("NICKNAME:${profile.displayName.ifEmpty { "$firstName $lastName".trim() }}\n") | ||
| } |
There was a problem hiding this comment.
[nitpick] Similar to the FN field, this logic is duplicated and complex. Consider extracting the name concatenation logic to a helper function to avoid duplication.
| vCardBuilder.append("N:$lastName;$firstName;;;\n") | |
| vCardBuilder.append("FN:${("$firstName $lastName".trim()).ifEmpty { profile.displayName }}\n") | |
| vCardBuilder.append("NICKNAME:${profile.displayName.ifEmpty { "$firstName $lastName".trim() }}\n") | |
| } | |
| val (nField, fnField, nicknameField) = formatName(firstName, lastName, profile.displayName) | |
| vCardBuilder.append(nField) | |
| vCardBuilder.append(fnField) | |
| vCardBuilder.append(nicknameField) |
| val isPhoneShared: Boolean = false | ||
| val isPhoneShared: Boolean = true, | ||
| ) | ||
|
|
There was a problem hiding this comment.
The vCard generation does not escape special characters in user input. Fields like description, company, and job title should be escaped to prevent vCard format corruption if they contain newlines, semicolons, or other special characters.
| private fun escapeVCardValue(value: String): String { | |
| return value.replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,") | |
| } |
6505ef7 to
fda8deb
Compare
📲 You can test the changes from this Pull Request in Gravatar Android by scanning the QR code below to install the corresponding build.
|
AdamGrzybkowski
left a comment
There was a problem hiding this comment.
Works great! I would prefer to have the VCard generation extracted so that it can be tested in isolation, not as part of the uiState. WDYT?
homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt
Outdated
Show resolved
Hide resolved
homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt
Outdated
Show resolved
Hide resolved
homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt
Outdated
Show resolved
Hide resolved
...Ui/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeader.kt
Outdated
Show resolved
Hide resolved
750a3f5 to
a16ae6b
Compare
| if (firstName.isNotEmpty() || lastName.isNotEmpty()) { | ||
| contentBuilder.append("N:$lastName;$firstName;;;\n") | ||
| .append("FN:${("$firstName $lastName".trim()).ifEmpty { nickname }}\n") | ||
| } | ||
| val calculatedNickname = nickname.ifEmpty { "$firstName $lastName".trim() } | ||
|
|
||
| calculatedNickname.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("NICKNAME:$it\n") } |
There was a problem hiding this comment.
This doesn't match the iOS:
it should be:
N:\(model.lastName);\(model.firstName);
FN:\(model.displayName)
NICKNAME:\(model.displayName)
There was a problem hiding this comment.
🤔 I'm not sure if that is the correct approach. The specification says for the FN:
Purpose: To specify the formatted text corresponding to the name of the object the vCard represents.
I can update the nickname so that it does not use the first and last name when it's empty. But the FN looks better as it is.
There was a problem hiding this comment.
This is the logic we used with @pinarol for this one:
FN: Is a required field on the vcard spec, so we chose a profile property which we know will always have data to fill it with (displayName).
N: Will be filled with the first and last name if they exist.
NICKNAME: Is technically how the user wants to be called
We noticed by testing (on iOS contacts) that whenever N is present, FN gets replaced by N.
If N is empty and ORG is present, FN is replaced by ORG
If N and ORG are empty, FN becomes N (first and last name are split by spaces).
While NICKNAME is always preserved.
So, if N and ORG are empty, we will have FN = N = displayName
If N or ORG have data, FN gets replaced by either, so whatever data in FN is irrelevant.
And NICKNAME will always have the data of display name, as it is how the user wants to be recognized (or so we assume), and also, is a way to not get it replaced by N or ORG.
If FN = N (first - last name), FN becomes irrelevant in any case.
To know if this also counts for Android, we will need to know if FN is added to a separated field than N when both have different data. And a similar test when N is empty and ORG has data. Is FN replaced by ORG or is it added to a separated contact field?
There was a problem hiding this comment.
Great explanation @etoledom! Appreciate it.
I've done some tests and I'm not wrong, what you've detailed also applies to Android. I observe the same behaviour so, I'll update the code to save displayName in FN.
Thanks!
There was a problem hiding this comment.
I can't confirm this behavior on my device.
Now that the displayName is added as FN and NICKNAME, it's always added as a first name in my Google contacts. Even if N has data. The N is ignored.
When N is empty and ORG has data, the ORG is in the Organization field, and displayName is set as first name.
Here's an example showing the N is ignored.
| Data from the profile screen | Google contacts |
|---|---|
![]() |
![]() |
There was a problem hiding this comment.
You are right @AdamGrzybkowski. I messed up things with the code while testing. If I'm not wrong (again), if FN is present, Contact apps will use that value instead of N or NICKNAME (I've downloaded a couple of contact apps and the behaviour seems to be the same).
The priority is first FN, and if FN is not available, then N. I don't see Nickname being used in any case. 🤔
If that's correct, I would revert the last commit and set FN with the first and last name as we were doing when the discussion started. If those values are not available, I would use the display name. With this approach, I believe we should achieve acceptable results regardless of the platform scanning the QR. Wdyt?
There was a problem hiding this comment.
We can adapt iOS also to play good with Android contact.
The priority is first FN, and if FN is not available, then N. I don't see Nickname being used in any case. 🤔
So it would be:
FN: fullName ? displayName ?
What to do when there's no fullName and ORG is set?
When N is empty and ORG has data, the ORG is in the Organization field, and displayName is set as first name.
Is this ok?
cc @pinarol
There was a problem hiding this comment.
It seems that FN is parsed incorrectly on some Android devices, because the space character is treated as a delimiter(which, by the way, doesn’t comply with the spec). This becomes especially problematic when names include multiple first or last names, or prefixes.
Interestingly, when we pass an empty string to FN, the N field gets used and is parsed correctly.
That puts us in a tricky spot: leaving a “required” field empty seems to fix the issue, but it also looks like we may not have much of a choice.
We don't need to do any changes on the ORG field in the vCard template I think.
So it becomes:
FN:
N: First/Last name > Display name (prioritize First/Last name over Display name)
NICKNAME: Display name
ORG: SomeCompany
...
...
8d20527 to
0365912
Compare
# Conflicts: # homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt # homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt
With those classes, we kept the VCard generation isolated and easy to test.
b205c0a to
e1a8824
Compare
|
@etoledom I've removed the |
|
Hey, just to give a bit of context, we didn’t completely remove the FN field, we just left it empty since it’s required. I’m not totally sure this is the best way to handle it, but I do feel like keeping the vCard format consistent across platforms is the correct approach. |
|
When the standard says I'm not sure what the best approach is, so I've added the empty |
e1a8824 to
2b8446b
Compare
FN: N: First/Last name > Display name NICKNAME: Display name
2b8446b to
e82d6d9
Compare
etoledom
left a comment
There was a problem hiding this comment.
Working great as expected 🎉
Tested reading the generated QR code in both iOS and Xiaomi


Description
This PR implements the QR code generation using qrose.
Before Public Fields Section
Kapture.2025-07-23.at.17.34.37.mp4
Kapture.2025-07-29.at.14.52.52.mp4
What's included?
This PR generates a QR code containing the vCard information using the Profile data, plus the private contact information introduced by the user. It also takes into account the switches in the
Private Contact Section, so if the user decides not to share their email and/or phone, those won't be included.What's not included?
The public fields section is not included, but the data is being included in the QR. We need to implement the UI to show that section and take into account the switches while generating the QEEdited: The public fields are now included in this PR, as they were already added. You can include those fields in the vCard by playing with the switches.
Why I've chosen
qrose?kotlinandcompose.ui, it doesn't have more dependencies.Testing Steps