VCard Detail Screen: Java/XML to Kotlin/Compose migration#184
Conversation
| 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) | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Dropped the in-flight dedup, so there's no broad catch anymore. getEntries just parses directly and relies on the cache.
| 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 |
There was a problem hiding this comment.
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)
| return flow { | ||
| emit(VCardDetailResult.Loading) | ||
|
|
||
| vCardEntryRepository | ||
| .observeEntries( | ||
| vCardUri = vCardUri, | ||
| refreshes = refreshes, | ||
| ) | ||
| .map(::mapResult) | ||
| .collect(::emit) | ||
| } |
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
this should probably also be applied to SettingsMainScreen.kt:65 which also has subscription.displayDetail
| 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 | ||
| } |
There was a problem hiding this comment.
We should probably also not cache Contacts.CONTENT_MULTI_VCARD_URI
| } | ||
|
|
||
| return name?.takeIf(String::isNotBlank) | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
}There was a problem hiding this comment.
Already handled by CustomVCardEntryConstructor.onEntryEnded() before onEntryCreated(), so I removed it here. displayName is now just a read.
| Column( | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .combinedClickable( | ||
| onClick = onClick, | ||
| onLongClick = onLongClick, | ||
| ) | ||
| .padding( | ||
| horizontal = RowHorizontalPadding, | ||
| vertical = RowVerticalPadding, | ||
| ), | ||
| verticalArrangement = Arrangement.spacedBy(2.dp), |
There was a problem hiding this comment.
Note that the VCardDetailViewModel can drop clicks for VCardFieldAction.None, but semantically it will still show a click action for e.g. TalkBack
| val avatarImage = remember(avatarPhoto) { | ||
| avatarPhoto | ||
| ?.asReadOnlyByteBuffer() | ||
| ?.let(ParticipantAvatarImage::Bytes) | ||
| } |
There was a problem hiding this comment.
Repeated code and could be refactored
| val avatarImage = remember(avatarPhoto) { | ||
| avatarPhoto | ||
| ?.asReadOnlyByteBuffer() | ||
| ?.let(ParticipantAvatarImage::Bytes) | ||
| } |
There was a problem hiding this comment.
Repeated code and could be refactored
| val avatarImage = remember(attachment.vCardUiModel.avatarPhoto) { | ||
| attachment.vCardUiModel.avatarPhoto | ||
| ?.asReadOnlyByteBuffer() | ||
| ?.let(ParticipantAvatarImage::Bytes) | ||
| } |
There was a problem hiding this comment.
Repeated code and could be refactored
| var currentConversationId: String? = null | ||
|
|
||
| conversationIdFlow.collectLatest { conversationId -> | ||
| _state.value = ConversationMessagesUiState.Loading | ||
| if (conversationId != currentConversationId) { | ||
| currentConversationId = conversationId | ||
| _state.value = ConversationMessagesUiState.Loading | ||
| } |
There was a problem hiding this comment.
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
| itemsIndexed( | ||
| items = contacts, | ||
| key = { index, _ -> index }, | ||
| ) { _, contact -> |
There was a problem hiding this comment.
This key = { index, _ -> index } is redundant since itemsIndexed documentation already says:
If null is passed the position in the list will represent the key.
| return VCardFieldUiModel( | ||
| value = field.value, | ||
| displayValue = field.value, |
There was a problem hiding this comment.
It seems like value is always equal to displayValue
| 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) |
There was a problem hiding this comment.
Note that this trySend(Unit) will trigger a full parse after first observer registration and can cause double parsing on open; see #184 (comment)
| override fun onScreenForegrounded(cancelNotification: Boolean) { | ||
| conversationComposerAttachmentsDelegate.refresh() | ||
| conversationMessagesDelegate.refresh() |
There was a problem hiding this comment.
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
f65b4a7 to
40e2935
Compare
|
LGTM, just needs to fix conflicts with main branch |
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_CACHEcaching for a newVCardEntryRepositoryon top of a KotlinVCardParser. The old cache could show stale data for vCards backed by a contact, so those now refresh via aContentObserverwhen the contact is edited.Screenshots
Video
Screen_recording_20260708_031103.mp4