Skip to content

VCard Detail Screen: Java/XML to Kotlin/Compose migration#184

Open
m4pl wants to merge 15 commits into
GrapheneOS:mainfrom
m4pl:vcard-compose
Open

VCard Detail Screen: Java/XML to Kotlin/Compose migration#184
m4pl wants to merge 15 commits into
GrapheneOS:mainfrom
m4pl:vcard-compose

Conversation

@m4pl

@m4pl m4pl commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Migrated the VCard Detail flow and inline vCard cards from Java + XML to Kotlin + Compose, and removed the legacy vCard Java code.

Also swapped the old MediaResourceManager/VCARD_CACHE caching for a new VCardEntryRepository on top of a Kotlin VCardParser. The old cache could show stale data for vCards backed by a contact, so those now refresh via a ContentObserver when the contact is edited.

Screenshots

Before After
Screenshot_20260708_032321 Screenshot_20260708_031733

Video

Screen_recording_20260708_031103.mp4

@m4pl m4pl force-pushed the vcard-compose branch from ecda16c to 5cbc15c Compare July 8, 2026 18:15
@m4pl m4pl requested review from RankoR and inthewaves July 8, 2026 20:22
@m4pl m4pl marked this pull request as ready for review July 8, 2026 20:23
Comment on lines +95 to +109
val result = runCatching {
val entries = parser.parse(vCardUri)
cacheIfNeeded(
vCardUri = vCardUri,
entries = entries,
shouldCache = shouldCache,
)
entries
}

result.onSuccess { entries ->
parseHandle.deferred.complete(entries)
}.onFailure { throwable ->
parseHandle.deferred.completeExceptionally(throwable)
}

@inthewaves inthewaves Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Need to be careful with using broad catching functions when the body is a suspend lambda/block calling suspendable (and thus possibly cancellable) functions. Kotlin coroutine cancellation is done through CancellationExceptions which should never be caught

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped the in-flight dedup, so there's no broad catch anymore. getEntries just parses directly and relies on the cache.

Comment on lines +78 to +85
val hasFullName = fullName.isNotEmpty()
val displayName = when {
fullName.isNullOrEmpty() -> {
bidiFormatter.unicodeWrap(participant.sendDestination.orEmpty(), LTR)
}
else -> fullName
hasFullName -> fullName
else -> participant.sendDestination.orEmpty()
}
val details = when {
fullName.isNullOrEmpty() || participant.isUnknownSender -> null
else -> participant.sendDestination?.let {
bidiFormatter.unicodeWrap(it, LTR)
}
hasFullName && !participant.isUnknownSender -> participant.sendDestination
else -> null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We need to retain the fullName.isNullOrEmpty() instead of fullName.isEmpty(). This currently results in an NPE when opening conversations settings for an unsaved number which would have a null fullName (should add a test for this too)

Comment on lines +34 to +44
return flow {
emit(VCardDetailResult.Loading)

vCardEntryRepository
.observeEntries(
vCardUri = vCardUri,
refreshes = refreshes,
)
.map(::mapResult)
.collect(::emit)
}

@inthewaves inthewaves Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should probably specify dispatcher via flowOn, otherwise this could flow and thus do parsing/bitmap decoding on main thread

SettingsClickableItem(
title = stringResource(R.string.mms_phone_number_pref_title),
summary = subscriptionSettings.displayDetail,
summary = subscriptionSettings.displayDetail.asLtrText(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this should probably also be applied to SettingsMainScreen.kt:65 which also has subscription.displayDetail

Comment on lines +153 to +163
private fun shouldCache(vCardUri: String): Boolean {
return !vCardUri.toUri().isContactVCardUri()
}

private fun Uri.isContactVCardUri(): Boolean {
val contactVCardUri = Contacts.CONTENT_VCARD_URI

return scheme == contactVCardUri.scheme &&
authority == contactVCardUri.authority &&
pathSegments.take(contactVCardUri.pathSegments.size) == contactVCardUri.pathSegments
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should probably also not cache Contacts.CONTENT_MULTI_VCARD_URI

}

return name?.takeIf(String::isNotBlank)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems to be doing hidden mutations in entry.consolidateFields() which isn't good practice especially if this can be called concurrently; it's probably better to do it once in e.g. VCardParser

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review lines got messed up, but it's for this:

override fun displayName(entry: CustomVCardEntry): String? {
        val name = entry.displayName ?: run {
            entry.consolidateFields() // <--
            entry.displayName
        }

        return name?.takeIf(String::isNotBlank)
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already handled by CustomVCardEntryConstructor.onEntryEnded() before onEntryCreated(), so I removed it here. displayName is now just a read.

Comment on lines +138 to +149
Column(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(
horizontal = RowHorizontalPadding,
vertical = RowVerticalPadding,
),
verticalArrangement = Arrangement.spacedBy(2.dp),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that the VCardDetailViewModel can drop clicks for VCardFieldAction.None, but semantically it will still show a click action for e.g. TalkBack

Comment on lines +89 to +93
val avatarImage = remember(avatarPhoto) {
avatarPhoto
?.asReadOnlyByteBuffer()
?.let(ParticipantAvatarImage::Bytes)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Repeated code and could be refactored

Comment on lines +42 to +46
val avatarImage = remember(avatarPhoto) {
avatarPhoto
?.asReadOnlyByteBuffer()
?.let(ParticipantAvatarImage::Bytes)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Repeated code and could be refactored

Comment on lines +118 to +122
val avatarImage = remember(attachment.vCardUiModel.avatarPhoto) {
attachment.vCardUiModel.avatarPhoto
?.asReadOnlyByteBuffer()
?.let(ParticipantAvatarImage::Bytes)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Repeated code and could be refactored

Comment on lines +67 to +73
var currentConversationId: String? = null

conversationIdFlow.collectLatest { conversationId ->
_state.value = ConversationMessagesUiState.Loading
if (conversationId != currentConversationId) {
currentConversationId = conversationId
_state.value = ConversationMessagesUiState.Loading
}

@inthewaves inthewaves Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think currentConversationId and the equality check is needed. conversationIdFlow is a StateFlow, which never emits consecutive equal values

_state is also a StateFlow, so setting Loading unconditionally is also a no-op when the state is already Loading, for the same reason

Comment on lines +138 to +141
itemsIndexed(
items = contacts,
key = { index, _ -> index },
) { _, contact ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This key = { index, _ -> index } is redundant since itemsIndexed documentation already says:

If null is passed the position in the list will represent the key.

Comment on lines +59 to +61
return VCardFieldUiModel(
value = field.value,
displayValue = field.value,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems like value is always equal to displayValue

Comment on lines +136 to +145
private fun contactChangeEvents(): Flow<Unit> {
return callbackFlow {
val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
trySend(Unit)
}
}

contentResolver.registerContentObserver(Contacts.CONTENT_URI, true, observer)
trySend(Unit)

@inthewaves inthewaves Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that this trySend(Unit) will trigger a full parse after first observer registration and can cause double parsing on open; see #184 (comment)

Comment on lines 790 to +792
override fun onScreenForegrounded(cancelNotification: Boolean) {
conversationComposerAttachmentsDelegate.refresh()
conversationMessagesDelegate.refresh()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that this will trigger a vCards parse . This will do uncached contact vCards parsing twice on first open, since the repository's initial observer registration does its own trySend(Unit) from the initial refreshEvents

e.g. put a log inside of VCardEntryRepositoryImpl.parseShared before the val result = runCatching call, and 2 log lines will be printed

@m4pl m4pl marked this pull request as draft July 9, 2026 22:34
@m4pl m4pl force-pushed the vcard-compose branch 3 times, most recently from f65b4a7 to 40e2935 Compare July 10, 2026 07:45
@m4pl m4pl marked this pull request as ready for review July 10, 2026 14:16
@inthewaves

Copy link
Copy Markdown
Member

LGTM, just needs to fix conflicts with main branch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants