Skip to content

add message deletion and overhaul the example app#526

Open
mchenani wants to merge 9 commits intomainfrom
mc/example-app-overhaul-and-message-deletion
Open

add message deletion and overhaul the example app#526
mchenani wants to merge 9 commits intomainfrom
mc/example-app-overhaul-and-message-deletion

Conversation

@mchenani
Copy link
Contributor

@mchenani mchenani commented Jan 13, 2026

Add message deletion and overhaul the Android example app to a Compose-based UI with synchronized client creation in example.ClientManager

Introduce DeletedMessage handling and delete operations in conversation flows, migrate activities and screens to Jetpack Compose, and refactor example.ClientManager to synchronize client lifecycle, rebuild wallet from stored keys, and register codecs. Secure key storage moves to EncryptedSharedPreferences in KeyUtil.kt, and navigation, messaging, and attachments are implemented via new Compose components and view models.

📍Where to Start

Start with the client lifecycle changes in example.ClientManager in ClientManager.kt, then review message classification and delete handling in ConversationDetailViewModel.kt.


📊 Macroscope summarized 0259338. 9 files reviewed, 52 issues evaluated, 36 issues filtered, 9 comments posted

🗂️ Filtered Issues

example/src/main/java/org/xmtp/android/example/ClientManager.kt — 0 comments posted, 7 evaluated, 7 filtered
  • line 40: The lock object is used inconsistently for protecting _clientState and _client. In createClient(), the state is checked and set to Creating inside synchronized(lock) (lines 93-97), but the final state updates to Ready or Error (lines 140, 143) happen outside any synchronization. This means concurrent calls to setClient() or clearClient() could interleave with these state updates, leading to inconsistent state. [ Already posted ]
  • line 43: Race condition between clearClient() and the coroutine launched in createClient(). When clearClient() is called, it cancels managerScope at line 167 and sets _client = null at line 165. However, if a coroutine from createClient() is still running (lines 99-145), it may complete after clearClient() finishes, setting _client to a new value and _clientState to Ready (line 140) or Error (line 143), overwriting the Unknown state set by clearClient(). The coroutine's state updates at lines 140 and 143 are not synchronized with the lock. [ Already posted ]
  • line 87: The createClient function signature expects an address parameter to look up the private key, but the example usage in MainActivity passes the result of KeyUtil.loadKeys() which returns the account password/key data from AccountManager, not the wallet address. This mismatch will cause keyUtil.retrievePrivateKey(address) to look up with an incorrect key, returning null and throwing IllegalStateException("No wallet key found. Please sign in again.") for all existing users. [ Already posted ]
  • line 100: Migration from legacy preferences will fail and cause data loss. The old code used plain SharedPreferences with context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), but migrateLegacyPrefsIfNeeded() tries to read the legacy prefs as EncryptedSharedPreferences. This will throw an exception when trying to decrypt the unencrypted data, causing the catch block to mark migration as complete without actually migrating the keys. Existing users will lose their DB encryption keys stored under the xmtp-dev- prefix. [ Already posted ]
  • line 128: When rebuilding the wallet from stored private key, the code uses rebuiltAddress from wallet.publicIdentity.identifier for clientOptions, but the original address parameter was used for keyUtil.retrievePrivateKey(address). If these addresses differ in case or format, the DB encryption key lookup in clientOptions could create a new key instead of retrieving the existing one, potentially causing data access issues. [ Already posted ]
  • line 162: The clearClient() function cancels managerScope while createClient() may have an active coroutine running. If clearClient() is called immediately after createClient(), the state transitions to ClientState.Creating but the coroutine is cancelled before it can transition to Ready or Error. Subsequent calls to createClient() will return early at line 95 due to the ClientState.Creating check, leaving the client permanently stuck in the Creating state. [ Already posted ]
  • line 167: Race condition in clearClient(): Cancelling managerScope does not immediately stop non-suspending code in the createClient() coroutine. After clearClient() sets _client = null and _clientState.value = ClientState.Unknown, the already-running coroutine from createClient() can continue executing lines 135-140, setting _clientState.value = ClientState.Ready while _client remains null. This leaves the system in an inconsistent state where clientState is Ready but _client is null, causing the client getter to throw NullPointerException when it executes _client!!. [ Already posted ]
example/src/main/java/org/xmtp/android/example/MainActivity.kt — 2 comments posted, 9 evaluated, 5 filtered
  • line 57: The migration in migrateLegacyPrefsIfNeeded attempts to read legacy data using EncryptedSharedPreferences, but the original implementation stored keys in plain SharedPreferences (via context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)). When EncryptedSharedPreferences.create() tries to open the unencrypted legacy file, it will throw an exception because the data format is incompatible. The exception is caught and silently ignored, marking migration as complete without actually migrating any data. This causes data loss for existing users - their DB encryption keys stored in plain prefs will not be migrated and retrieveKey() will return null. [ Low confidence ]
  • line 92: The migration logic in KeyUtil attempts to read legacy SharedPreferences as EncryptedSharedPreferences, but the old code used regular SharedPreferences (via context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)). When migrateLegacyPrefsIfNeeded tries to open the legacy file with EncryptedSharedPreferences.create(), it will fail because the file format is incompatible. The exception is caught and migration is marked complete without actually migrating data. This causes existing users to lose their DB encryption keys when ClientManager.createClient() is called, resulting in data loss as their encrypted database becomes unreadable with the newly generated key. [ Low confidence ]
  • line 92: The variable keys returned by KeyUtil(this).loadKeys() (which retrieves accountManager.getPassword(account)) is passed to ClientManager.createClient(keys, this) where the first parameter is named address and is used to look up private keys via keyUtil.retrievePrivateKey(address). If the value stored as the account password is not the Ethereum address used when storing keys via storePrivateKey(), the lookup will fail and throw IllegalStateException("No wallet key found"), forcing users to re-authenticate even if their data exists. [ Low confidence ]
  • line 213: The error detection clientState.message.contains("No wallet key found") relies on matching a specific substring in the error message. If the error message text is changed or localized in ClientManager.createClient(), this check will fail to detect the condition, and users will see a generic error toast instead of being redirected to sign-in to re-authenticate. [ Already posted ]
  • line 583: The "Yesterday" calculation uses simple day-of-year arithmetic (now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1) which fails at year boundaries. If today is January 1st (day 1) and the message is from December 31st (day 365), the calculation yields -364 not 1, so "Yesterday" won't display. The same issue affects the "within last week" calculation where negative differences would incorrectly be < 7. However, since the year check (now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR)) filters these out, cross-year messages fall through to show the date format, which is acceptable but semantically incorrect for recent messages. [ Low confidence ]
example/src/main/java/org/xmtp/android/example/MainViewModel.kt — 0 comments posted, 3 evaluated, 2 filtered
  • line 58: Inside the .map { } block starting at line 57, ClientManager.client.conversations.getHmacKeys() is called for every topic returned by allPushTopics(). This means the potentially expensive getHmacKeys() network/database call is executed N times (once per topic) instead of once. This should be moved outside the map to call it once and reuse the result, improving performance and avoiding potential rate limiting or timeout issues with many conversations. [ Out of scope ]
  • line 152: In the stream and messageStream flows (lines 152-155 and 170-173), when an exception occurs during processing (e.g., in getConversationDisplayInfo or fetchMostRecentMessage), the .catch block emits null and the inner flow terminates. Since clientState may not emit another Ready state, a single transient error (like a network failure while fetching group name or members) could cause the conversation/message stream to stop permanently until the app is restarted or client state cycles. [ Already posted ]
example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt — 2 comments posted, 10 evaluated, 5 filtered
  • line 84: Building attachments inside the remember block performs synchronous bitmap loading via loadBitmap(uri) on the main/UI thread during composition. For large images, BitmapFactory.decodeStream() can take significant time, potentially causing ANR (Application Not Responding) if it exceeds 5 seconds. [ Already posted ]
  • line 85: Full-size bitmaps are loaded without any downsampling via BitmapFactory.decodeStream(). For high-resolution images (e.g., camera photos), this could cause OutOfMemoryError when multiple large attachments are loaded simultaneously. [ Already posted ]
  • line 144: loadBitmap is called during Compose's remember block initialization, which runs on the main thread. Loading multiple images synchronously blocks the UI thread and can cause ANR (Application Not Responding) if the images are large or numerous. [ Already posted ]
  • line 145: loadBitmap loads full-resolution images without downsampling. For large images (e.g., high-resolution camera photos), this can cause OutOfMemoryError since BitmapFactory.decodeStream will attempt to allocate memory for the entire unscaled bitmap. [ Already posted ]
  • line 145: Loading full-resolution bitmaps via BitmapFactory.decodeStream() without BitmapFactory.Options.inSampleSize can cause OutOfMemoryError when processing large images. There is no downsampling or size limiting applied before decoding. [ Already posted ]
example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt — 4 comments posted, 11 evaluated, 6 filtered
  • line 240: The cameraImageUri state is stored using remember { mutableStateOf<Uri?>(null) } which does not survive configuration changes. If the device rotates while the camera app is open, cameraImageUri will be reset to null. When the camera returns successfully at line 352-355, the check if (success && cameraImageUri != null) will fail and the captured photo will be silently discarded. [ Already posted ]
  • line 243: The pendingAttachmentUris and pendingCaptions state variables are declared at lines 243-244 but are never used anywhere in the function, suggesting incomplete implementation of attachment handling logic. [ Low confidence ]
  • line 243: The pendingAttachmentUris and pendingCaptions state variables are declared but never used. The attachment sending logic in attachmentPreviewLauncher reads URIs and captions directly from the result intent rather than using these state variables. This appears to be dead code from an incomplete refactor. [ Low confidence ]
  • line 338: In galleryLauncher and fileLauncher, the code catches SecurityException when calling takePersistableUriPermission() and continues silently. If permissions cannot be persisted and the process is killed while AttachmentPreviewActivity is showing, the URIs may become unreadable when the result returns, causing openInputStream(uri) to return null. While the null case is handled, the user receives no indication of why the attachment failed. [ Low confidence ]
  • line 486: If avatarColors is passed as an empty list, the expression avatarColors[abs(headerTitle.hashCode()) % avatarColors.size] at line 486 will throw ArithmeticException (division by zero) or IndexOutOfBoundsException. The parameter type List<Color> allows empty lists but the implementation assumes it's non-empty. [ Low confidence ]
  • line 1026: The "within 7 days" check at line 1026 uses the same flawed day-of-year subtraction logic. Messages from late December viewed in early January will show "Dec 31" format instead of the weekday abbreviation, because the subtraction yields a large negative number rather than a small positive one. [ Already posted ]
example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt — 0 comments posted, 11 evaluated, 11 filtered
  • line 171: Content type identification uses different message types and properties between stream-level filtering (lines 171-174 using message.encodedContent.type?.typeId on DecodedMessage) and classifyMessage filtering (line 411 using message.contentTypeId.typeId on DecodedMessageV2). If these properties represent content types differently between the two message formats, hidden content types could slip through the stream-level check and cause classifyMessage to return null, resulting in unexpected null emissions to the StateFlow. [ Low confidence ]
  • line 184: When findEnrichedMessage returns null or classifyMessage returns null (lines 182-188), the mapLatest block implicitly returns null, which is emitted to the StateFlow. This could lead to unexpected null values being observed by collectors when messages exist but cannot be enriched or classified, rather than clearly indicating why the message was filtered. [ Already posted ]
  • line 188: In streamMessages at lines 182-188, when findEnrichedMessage returns null or classifyMessage returns null, the mapLatest block evaluates to null. This null emission is indistinguishable from the null emitted on errors in the catch block at line 192, potentially making it difficult for consumers to differentiate between "message couldn't be processed" and "an error occurred". [ Already posted ]
  • line 237: In sendMessage, if conversation is null, the conversation?.send() calls on lines 228-231 and 235 will silently do nothing due to the safe call operator, but SendMessageState.Success will still be emitted on line 237. This reports success to the UI even though no message was actually sent. [ Already posted ]
  • line 237: In sendMessage, when conversation is null and either a reply or regular message is being sent (lines 228-235), the conversation?.send() call silently does nothing due to the safe call operator, but SendMessageState.Success is still emitted at line 237. This causes the UI to report success when the message was never actually sent. [ Already posted ]
  • line 250: The deleteMessage function silently succeeds when conversation is null. The safe call conversation?.deleteMessage(messageId) skips the operation if conversation is null, but the code still sets flow.value = DeleteMessageState.Success. This misleads callers into thinking the deletion succeeded when nothing actually happened, causing the UI to show "Message deleted" while the message remains. [ Already posted ]
  • line 251: In deleteMessage, when conversation is null, conversation?.deleteMessage(messageId) at line 250 silently does nothing due to the safe call operator, but DeleteMessageState.Success is still set at line 251. This reports successful deletion when nothing was actually deleted. [ Already posted ]
  • line 284: In sendReaction, if conversation is null, conversation?.send() on line 273 will silently do nothing, but ReactionState.Success is returned on line 284. This reports success even though no reaction was actually sent. [ Already posted ]
  • line 284: In sendReaction, when conversation is null, conversation?.send() at line 273 silently does nothing, and ReactionState.Success is returned at line 284. This reports success when the reaction was never sent. [ Already posted ]
  • line 309: In sendAttachment, after the explicit null check at lines 305-307 that returns an error, the code at line 309 still uses conversation?.send() with a safe call. Since conversation is a var, Kotlin cannot smart-cast it, meaning if conversation is set to null by another thread between the check and the send, the attachment won't be sent but SendAttachmentState.Success will still be returned at line 314. [ Already posted ]
  • line 309: In sendAttachment, after the explicit null check on lines 305-307 returns an error if conversation is null, line 309 still uses conversation?.send() with a safe call operator. If conversation were to become null between the check and the send (race condition), the send would silently fail but SendAttachmentState.Success would still be returned on line 314. [ Already posted ]

@mchenani mchenani requested a review from a team as a code owner January 13, 2026 19:02
@claude
Copy link

claude bot commented Jan 13, 2026

Claude finished @mchenani's task —— View job


Code Review Complete ✅

I've completed a comprehensive review of PR #526. This is a significant overhaul adding message deletion, encrypted key storage, and a complete UI redesign with Jetpack Compose. Here are my findings:


🔴 Critical Issues

1. Thread Safety Issues in ClientManager (ClientManager.kt:43-50)

The getOrCreateScope() function has been synchronized, which is good, but there are still race conditions:

  • Line 93-96: The check-then-act pattern if (currentState is ClientState.Ready || currentState is ClientState.Creating) return followed by _clientState.value = ClientState.Creating is not atomic despite being inside synchronized block
  • Line 149-158: setClient() doesn't check current state before overwriting, can clobber an in-progress createClient() call
  • Recommendation: Use AtomicReference.compareAndSet() or ensure all state transitions are atomic

2. CancellationException Handling (ClientManager.kt:141-144)

The catch block catches all Exception types, including CancellationException. When clearClient() cancels the scope, the coroutine will be treated as an error instead of a proper cancellation.

catch (e: Exception) {  // ❌ Catches CancellationException
    Timber.e(e, "createClient failed")
    _clientState.value = ClientState.Error(e.localizedMessage.orEmpty())
}

Recommendation: Add catch (e: CancellationException) { throw e } before the Exception handler, or use specific exception types.

3. KeyUtil Migration Logic Flaw (KeyUtil.kt:61-108)

The migration attempts to read legacy prefs as EncryptedSharedPreferences, but legacy prefs may have been plain SharedPreferences. This will fail silently:

  • Line 70-76: Opens legacy prefs as encrypted, but they may be unencrypted
  • Line 103-106: Catches failure and marks migration complete, causing silent data loss
    Recommendation: Check if legacy prefs exist as plain SharedPreferences first, then migrate

4. Unsafe Address Usage (ClientManager.kt:118-128)

val privateKeyBytes = keyUtil.retrievePrivateKey(address)  // Line 101
// ...
val rebuiltAddress = wallet.publicIdentity.identifier      // Line 123
Client.create(wallet, clientOptions(appContext, rebuiltAddress))  // Line 128

If rebuiltAddress differs from address (e.g., checksum variations), the DB encryption key lookup will fail.
Recommendation: Use the original address consistently, or document why they must differ.


🟠 High Priority Issues

5. String-Based Error Detection (MainActivity.kt:213)

if (clientState.message.contains("No wallet key found")) {

This brittle string matching will break with localization or message changes.
Recommendation: Use sealed class variants like ClientState.ErrorNoWallet or error codes.

6. Race Condition in disconnectWallet (MainActivity.kt:245-255)

val address = if (ClientManager.clientState.value is ClientManager.ClientState.Ready) {
    ClientManager.client.publicIdentity.identifier  // ❌ Client may become non-ready between check and access
} else null

Recommendation: Capture address before any state changes, or use try-catch.

7. Null Safety Issues in ConversationDetailViewModel (ConversationDetailViewModel.kt:76, 96, 228)

  • conversation is nullable but accessed with !! in multiple places
  • Line 228: conversation?.send(...) returns null if conversation is null, but method returns Success anyway
conversation?.send(content = replyContent, options = SendOptions(...))
_replyToMessage.value = null
flow.value = SendMessageState.Success  // ❌ Always success even if send was skipped

Recommendation: Guard conversation null early and emit error states appropriately.

8. Missing StreamedMessageResult Handling (ConversationDetailViewModel.kt:182-189)

When findEnrichedMessage() returns null, the mapLatest returns null and the message is dropped from the stream:

val enrichedMessage = ClientManager.client.conversations.findEnrichedMessage(message.id)
enrichedMessage?.let { ... }  // ❌ Returns null if not found, stream emits nothing

This can cause messages to be lost from the UI.
Recommendation: Emit RefreshNeeded when enrichment fails to ensure UI consistency.


🟡 Medium Priority Issues

9. Retry Logic Overlap (MainActivity.kt:119-136)

Two separate collectors both handle ClientState.Error:

  • One calls retryCreateClientWithBackoff() (line 124)
  • Another calls ensureClientState() which may call showSignIn() (line 115)

These can conflict and cause multiple retry attempts or premature sign-out.
Recommendation: Consolidate error handling into one collector.

10. Memory Leaks in AttachmentPreviewActivity

Based on Macroscope comments, bitmaps are never recycled when switching attachments. For large images, this will cause OOM on devices with limited memory.
Recommendation: Call bitmap.recycle() when switching images or use an image loading library like Coil/Glide.

11. Uncaught Errors in Flow.catch (MainViewModel.kt:152-155, ConversationDetailViewModel.kt:190-193)

.catch { e ->
    Timber.e(e, "Error in conversation stream")
    emit(null)  // ❌ Silently hides errors
}

Emitting null hides the error from the UI. Users won't know if streams are failing.
Recommendation: Log errors and continue the flow without emitting, or emit an error state.

12. Edit Message Not Implemented (ConversationDetailViewModel.kt:215-219)

Edit functionality is exposed in the UI but throws UnsupportedOperationException:

if (editMessage != null) {
    _editingMessage.value = null
    throw UnsupportedOperationException("Edit message feature is not yet available")
}

Recommendation: Remove UI edit options until the feature is ready, or make it a no-op with a user-facing message.


🟢 Security Observations

✅ Good Security Practices

  1. Encrypted Storage: Proper use of EncryptedSharedPreferences with AES256_GCM for private keys and DB encryption keys
  2. Secure Random: Uses SecureRandom().generateSeed(32) for DB encryption key generation
  3. Android Keystore: MasterKey backed by Android Keystore provides hardware-backed encryption on supported devices
  4. No Logging of Secrets: Private keys and encryption keys are not logged (good job!)

⚠️ Security Concerns

  1. Fallback to Unencrypted Prefs (KeyUtil.kt:50-54): Falls back to regular SharedPreferences if encryption fails. This stores private keys unencrypted on disk.

    • Recommendation: Fail hard instead of falling back, or document the risk clearly.
  2. Private Key in Memory: ConnectWalletViewModel.kt:63-68 extracts and stores private key bytes. Ensure these are cleared from memory after storage.

  3. No Key Rotation: Once encryption keys are generated, they're never rotated. Consider adding key rotation for long-lived keys.


📱 Android Best Practices

✅ Good Practices

  1. Edge-to-Edge Support: Proper use of enableEdgeToEdge() and insets
  2. Lifecycle Awareness: Good use of repeatOnLifecycle(Lifecycle.State.STARTED)
  3. Coroutine Scoping: Proper use of viewModelScope and lifecycleScope
  4. StateFlow: Good reactive patterns with StateFlow for UI state
  5. Compose Migration: Clean migration to Jetpack Compose with Material 3

⚠️ Improvements Needed

  1. SavedStateHandle: ConversationDetailViewModel uses SavedStateHandle but doesn't save all necessary state (e.g., cameraImageUri in Activity)
  2. Process Death: Camera capture will lose URI on process death. Save cameraImageUri in onSaveInstanceState
  3. Configuration Changes: Some state in Activities isn't preserved across configuration changes

🧪 Test Coverage Assessment

Current Coverage

  • ✅ Library module has comprehensive test coverage (26 test files covering codecs, conversations, groups, DMs, etc.)
  • Example app has minimal test coverage - only 1 basic instrumented test

Missing Tests for New Features

  1. No tests for ClientManager: Client lifecycle, state transitions, error handling
  2. No tests for KeyUtil: Encryption, migration logic, key storage/retrieval
  3. No tests for ViewModels: MainViewModel, ConversationDetailViewModel, ConnectWalletViewModel
  4. No tests for message deletion: Integration tests for delete feature
  5. No UI tests: No Compose UI tests for the new screens

Recommendation: Add unit tests for:

  • ClientManager state machine transitions
  • KeyUtil encryption and migration (mock EncryptedSharedPreferences)
  • ViewModel state flows and business logic
  • Message deletion workflow

🔧 Code Quality & Style

Null Safety

  • ⚠️ Multiple force unwraps (!!) that could be avoided with proper null checks
  • ⚠️ Some nullable chains (?.) return null silently instead of handling errors
  • ✅ Good use of nullable types and safe calls in most places

Coroutine Usage

  • ✅ Proper use of Dispatchers.IO for I/O operations
  • ✅ Good structured concurrency with scopes
  • ⚠️ Some blocking operations in withContext(Dispatchers.IO) could use .await() for better cancellation
  • ❌ CancellationException not properly handled in ClientManager

Performance Considerations

  1. Bitmap Loading: Full-resolution images loaded on main thread in some places
  2. Database Operations: Proper use of background dispatchers for DB operations
  3. Stream Management: Good use of WhileSubscribed with timeout for flow lifecycle
  4. Message Rendering: Efficient list updates with proper keys and diffing

📝 Recommendations Summary

Must Fix Before Merge

  1. Fix thread safety issues in ClientManager (atomic state transitions)
  2. Fix CancellationException handling
  3. Fix KeyUtil migration to handle plain SharedPreferences
  4. Fix address mismatch in client creation
  5. Add null safety guards in ConversationDetailViewModel
  6. Fix or remove edit message functionality

Should Fix Soon

  1. Replace string-based error detection with sealed classes
  2. Consolidate retry logic in MainActivity
  3. Add proper error handling in flow streams
  4. Handle StreamedMessageResult null cases
  5. Fix race condition in disconnectWallet

Nice to Have

  1. Add comprehensive test coverage for example app
  2. Implement bitmap recycling for attachments
  3. Save cameraImageUri to handle process death
  4. Consider key rotation mechanism
  5. Remove unencrypted SharedPreferences fallback

✨ Positive Highlights

  1. Major UI Improvement: The Compose migration looks clean and follows Material 3 guidelines
  2. Secure by Default: Good use of encrypted storage for sensitive data
  3. Feature Rich: Message deletion, reactions, replies, attachments all well implemented
  4. Code Organization: Clear separation of concerns with ViewModels, Composers, and UI components
  5. Logging: Excellent use of Timber for debugging throughout

This is a substantial improvement to the example app! The main concerns are around thread safety in ClientManager, proper error handling in flows, and test coverage. Once the critical issues are addressed, this will be a great showcase of XMTP capabilities on Android.

Overall Assessment: 🟡 Needs Work - Strong foundation with several critical issues to address before merge.


Base automatically changed from mc/delete-message to main January 16, 2026 19:53
Comment on lines +718 to +721
myExistingReaction != null -> {
viewModel.sendReaction(message.id, myExistingReaction!!, isRemoving = true)
viewModel.sendReaction(message.id, emoji, isRemoving = false)
}
Copy link

Choose a reason for hiding this comment

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

When replacing a reaction, the result of removing the old reaction is discarded. If removal fails, the error is silently ignored. Consider checking the first sendReaction result before proceeding with the second.

-                        myExistingReaction != null -> {
-                            viewModel.sendReaction(message.id, myExistingReaction!!, isRemoving = true)
-                            viewModel.sendReaction(message.id, emoji, isRemoving = false)
-                        }
+                        myExistingReaction != null -> {
+                            val removeResult = viewModel.sendReaction(message.id, myExistingReaction!!, isRemoving = true)
+                            if (removeResult is ConversationDetailViewModel.ReactionState.Success) {
+                                viewModel.sendReaction(message.id, emoji, isRemoving = false)
+                            } else {
+                                removeResult
+                            }
+                        }

🚀 Want me to fix this? Reply ex: "fix it for me".

onBackClick: () -> Unit,
onNavigateToGroupManagement: (String) -> Unit,
onNavigateToUserProfile: (String, String?) -> Unit,
onNavigateToAttachmentPreview: (List<Uri>) -> Unit,
Copy link

Choose a reason for hiding this comment

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

onNavigateToAttachmentPreview is unused. Suggest either remove it or route navigation through it instead of attachmentPreviewLauncher for consistency.

🚀 Want me to fix this? Reply ex: "fix it for me".

MainActivityContent(
viewModel = viewModel,
onConversationClick = { conversationId, topic ->
openConversation(topic, conversationId)
Copy link

Choose a reason for hiding this comment

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

Using the topic for both conversationId and peerAddress is incorrect and creates redundant mapping. Suggest pass the real peerAddress from ConversationItem to onConversationClick/openConversation and drop the topic→topic map.

🚀 Want me to fix this? Reply ex: "fix it for me".

now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(messageDate)
}
now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
Copy link

Choose a reason for hiding this comment

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

The "Yesterday" check fails at year boundaries. On January 1st, a message from December 31st yields a difference of -364, not 1. Consider comparing dates properly using calendar day manipulation instead of day-of-year arithmetic.

🚀 Want me to fix this? Reply ex: "fix it for me".

Comment on lines +743 to +747
if (showDeleteConfirmation && selectedMessage != null) {
DeleteConfirmationDialog(
onConfirm = {
scope.launch {
viewModel.deleteMessage(selectedMessage!!.id).collect { state ->
Copy link

Choose a reason for hiding this comment

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

Consider capturing selectedMessage in a local variable before using it in the onConfirm lambda. Since the lambda executes later, another callback could set selectedMessage = null before confirm is clicked, causing a NullPointerException at selectedMessage!!.id.

-    if (showDeleteConfirmation && selectedMessage != null) {
+    val messageToDelete = selectedMessage
+    if (showDeleteConfirmation && messageToDelete != null) {
         DeleteConfirmationDialog(
             onConfirm = {
                 scope.launch {
-                    viewModel.deleteMessage(selectedMessage!!.id).collect { state ->
+                    viewModel.deleteMessage(messageToDelete.id).collect { state ->

🚀 Want me to fix this? Reply ex: "fix it for me".


private val attachmentUris = mutableListOf<Uri>()
private val captions = mutableMapOf<Int, String>()
private val previewBitmaps = mutableMapOf<Int, Bitmap>()
Copy link

Choose a reason for hiding this comment

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

Manual Bitmap.recycle() here conflicts with Compose state holding the same instances and can crash during teardown. Suggest removing the manual recycling and letting GC handle it, or manage bitmap lifecycle in Compose (e.g., DisposableEffect) and avoid storing shared bitmaps at the activity level.

🚀 Want me to fix this? Reply ex: "fix it for me".

var showLogViewerSheet by remember { mutableStateOf(false) }

// Track log and hide deleted states for UI updates
var logsEnabled by remember { mutableStateOf(isLogsEnabled) }
Copy link

Choose a reason for hiding this comment

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

Local state logsEnabled and hideDeleted won't update when isLogsEnabled or hideDeletedMessages parameters change after recomposition. Consider using rememberUpdatedState or a LaunchedEffect to sync with parameter changes.

🚀 Want me to fix this? Reply ex: "fix it for me".

Comment on lines +36 to +40
Intent(context, AttachmentPreviewActivity::class.java).apply {
putParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, ArrayList(uris))
// Grant read permission for URIs so they can be accessed in the preview activity
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
Copy link

Choose a reason for hiding this comment

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

FLAG_GRANT_READ_URI_PERMISSION only grants access for URIs passed via setData() or setClipData(), not putParcelableArrayListExtra(). Consider using ClipData to grant permissions for multiple URIs.

-            Intent(context, AttachmentPreviewActivity::class.java).apply {
-                putParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, ArrayList(uris))
-                // Grant read permission for URIs so they can be accessed in the preview activity
-                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
-            }
+            Intent(context, AttachmentPreviewActivity::class.java).apply {
+                putParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, ArrayList(uris))
+                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+                // Use ClipData to grant URI permissions for all URIs
+                if (uris.isNotEmpty()) {
+                    val clipData = android.content.ClipData.newRawUri(null, uris.first())
+                    uris.drop(1).forEach { clipData.addItem(android.content.ClipData.Item(it)) }
+                    setClipData(clipData)
+                }
+            }

🚀 Want me to fix this? Reply ex: "fix it for me".

onGenerateWallet = { environment, logLevel ->
viewModel.generateWallet(environment, logLevel)
},
onConnectSuccess = { address ->
Copy link

Choose a reason for hiding this comment

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

On configuration changes, LaunchedEffect re-executes while uiState remains Success, causing duplicate signIn calls. Consider resetting the UI state after handling success, or using a one-shot event pattern to prevent re-triggering.

🚀 Want me to fix this? Reply ex: "fix it for me".

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.

1 participant