Skip to content

android: avoid staging raw media bytes#539

Open
jodobear wants to merge 1 commit intosledtools:masterfrom
jodobear:android-issue-433-media-staging
Open

android: avoid staging raw media bytes#539
jodobear wants to merge 1 commit intosledtools:masterfrom
jodobear:android-issue-433-media-staging

Conversation

@jodobear
Copy link
Copy Markdown

@jodobear jodobear commented Mar 9, 2026

Summary

  • keep staged Android attachments as URI metadata instead of raw bytes/bitmaps in Compose state
  • enforce the 32-item staging cap cumulatively while continuing to scan later URIs when earlier picks are invalid
  • move media encoding onto Dispatchers.IO and treat ContentResolver failures as soft failures
  • prevent duplicate staged sends, route follow-up single picks into an existing staged batch, and block composer send/attach actions while staging is still in flight
  • clean up persisted URI grants on remove/send/dispose without revoking grants still needed by retained staged items
  • add focused Android coverage for staging-capacity logic
  • the composer snapshot/freeze fix for async media sends
  • the reviewed staging/accounting/cleanup fixes
  • the visible filename for staged non-image attachments
  • the modest batch-send retention cleanup
  • the added KDoc updates

Testing

  • app:compileDebugKotlin
  • app:compileDebugAndroidTestKotlin
  • runtime attachment flow not exercised in this session because no connected Android device/emulator was available

Summary by CodeRabbit

  • New Features

    • Media attachment staging with capacity limits, permission-aware lifecycle, MIME-type-aware previews (images vs file icons), removable staged items, and safeguards that disable actions while staging/sending.
    • Just-in-time payload encoding and improved multi-attachment send flow with user-facing feedback for limits, read failures, and partial send outcomes.
  • Tests

    • Added Android instrumented tests verifying staging selection respects remaining capacity and returns empty when staging is full.

Open with Devin

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a metadata-first, capacity-limited media staging system to ChatScreen with persistable URI permission management and just-in-time Base64 encoding at send time, updates UI preview rendering by MIME type, and introduces instrumented tests for selectUrisForStaging.

Changes

Cohort / File(s) Summary
Media Staging Tests
android/app/src/androidTest/java/com/pika/app/ui/screens/ChatScreenMediaStagingTest.kt
New instrumented test class with two tests for selectUrisForStaging: verifies capacity-aware selection and empty result when at MAX_STAGED_MEDIA_ITEMS.
ChatScreen staging & send logic
android/app/src/main/.../ChatScreen.kt
Adds MAX_STAGED_MEDIA_ITEMS, StagedMedia/EncodedStagedMedia/MediaUploadPayload, selectUrisForStaging, metadata read and encoding helpers (readMediaMetadata, encodeMediaUploadPayload), permission helpers (takePersistableReadPermission, releasePersistableReadPermission), staging lifecycle (reserve, stage, send, release), MIME-type-based preview rendering, and UI/edge-case handling (toasts, disable controls while staging/sending).

Sequence Diagram

sequenceDiagram
    participant User
    participant ChatScreen
    participant ContentResolver
    participant SystemPermissions as "System Permissions"
    participant RemoteServer as "Remote Server"

    User->>ChatScreen: select media files
    ChatScreen->>ChatScreen: selectUrisForStaging(existingCount, uris)
    ChatScreen->>SystemPermissions: takePersistableReadPermission(uri)
    SystemPermissions-->>ChatScreen: permission granted
    ChatScreen->>ContentResolver: readMediaMetadata(uri)
    ContentResolver-->>ChatScreen: mimeType, filename
    ChatScreen->>ChatScreen: add StagedMedia & show preview

    User->>ChatScreen: send staged media
    ChatScreen->>ChatScreen: encodeMediaUploadPayload(StagedMedia)
    ChatScreen->>ContentResolver: openInputStream(uri) / read bytes
    ContentResolver-->>ChatScreen: raw bytes
    ChatScreen->>ChatScreen: Base64 encode -> dataBase64
    ChatScreen->>RemoteServer: upload MediaUploadPayload(dataBase64, mimeType, filename)
    RemoteServer-->>ChatScreen: success/error
    ChatScreen->>SystemPermissions: releasePersistableReadPermission(uri)
    SystemPermissions-->>ChatScreen: permission released
    ChatScreen->>ChatScreen: update stagedMedia state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

🐇 I hopped through URIs with careful paws,
I kept each permission safe from flaws,
I staged the names, then encoded on cue,
Sent batches tidy — swift, neat, and true. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'android: avoid staging raw media bytes' clearly and concisely summarizes the main architectural change: replacing raw byte/bitmap storage with metadata-based staging in the Android media handling system.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@jodobear jodobear force-pushed the android-issue-433-media-staging branch from b913545 to d79ecca Compare March 9, 2026 00:51
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt (3)

433-492: ⚠️ Potential issue | 🟠 Major

Make staged sends non-reentrant.

sendStagedMedia() snapshots the list and only updates UI state after the IO work finishes. Double-tapping Send dispatches the same batch twice, and removing an item during that window can still send it because it's already in stagedSnapshot. Add an in-flight guard in sendStagedMedia() and disable the send/remove controls while it runs.

Also applies to: 1038-1043, 1133-1137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt` around lines
433 - 492, The sendStagedMedia function is reentrant: it snapshots stagedMedia,
does IO, then updates state, allowing double-taps or concurrent calls to
dispatch duplicate sends; add an in-flight guard (e.g., a Boolean property like
isSending or sendInProgress) checked at the top of sendStagedMedia and set true
before starting the coroutine and false in all terminal paths (success, failure,
early returns) to prevent reentry, and also use that flag to disable the
send/remove UI controls while the operation runs (references: sendStagedMedia,
stagedMedia, stagedSnapshot, draft, replyDraft).

509-520: ⚠️ Potential issue | 🟠 Major

Single picks should join an existing staged batch instead of sending immediately.

If stagedMedia already contains items, these callbacks still route a one-item selection through sendMediaFromUri(). That sends a separate message, clears the caption, and leaves the existing staged items behind.

🔀 Suggested routing fix
 val pickPhotoOrVideoLauncher =
     rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
-        if (uris.size == 1) {
+        if (uris.size == 1 && currentStagedMedia.isEmpty()) {
             sendMediaFromUri(uris.first())
         } else if (uris.isNotEmpty()) {
             stageMediaFromUris(uris)
         }
     }
 
 val pickFileLauncher =
     rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
-        sendMediaFromUri(uri)
+        uri?.let {
+            if (currentStagedMedia.isEmpty()) sendMediaFromUri(it) else stageMediaFromUris(listOf(it))
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt` around lines
509 - 520, The single-item selection handlers (pickPhotoOrVideoLauncher and
pickFileLauncher) currently call sendMediaFromUri() which creates a separate
message instead of joining an existing staged batch; change both callbacks to
check stagedMedia (e.g., stagedMedia.isNotEmpty()) and, when non-empty, add the
selected URI to the staging flow (call stageMediaFromUris(listOf(uri)) or the
existing staging helper) instead of calling sendMediaFromUri(), otherwise keep
the current behavior (use sendMediaFromUri() when stagedMedia is empty); update
both locations referencing sendMediaFromUri, pickPhotoOrVideoLauncher,
pickFileLauncher, stageMediaFromUris and stagedMedia accordingly.

210-233: ⚠️ Potential issue | 🔴 Critical

Wrap helper functions with exception handling to prevent provider failures from crashing coroutines.

readMediaMetadata() and encodeMediaUploadPayload() call ContentResolver.getType(), query(), openInputStream(), and readBytes(), which can all throw uncaught exceptions (e.g., SecurityException, IllegalArgumentException, FileNotFoundException, IOException). These helpers are used in sendMediaFromUri() and sendStagedMedia() without try-catch, so revoked document URIs or flaky providers will crash the coroutine instead of falling through to your existing null/toast handling.

🩹 Suggested hardening
 private fun readMediaMetadata(ctx: Context, uri: Uri): StagedMedia? {
-    val mimeType = ctx.contentResolver.getType(uri).orEmpty()
-    val filename =
-        ctx.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
-            ?.use { cursor ->
-                val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
-                if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
-            }
-            ?.trim()
-            .takeUnless { it.isNullOrEmpty() }
-            ?: "attachment.bin"
-    return StagedMedia(uri = uri, mimeType = mimeType, filename = filename)
+    return runCatching {
+        val mimeType = ctx.contentResolver.getType(uri).orEmpty()
+        val filename =
+            ctx.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
+                ?.use { cursor ->
+                    val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+                    if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
+                }
+                ?.trim()
+                .takeUnless { it.isNullOrEmpty() }
+                ?: "attachment.bin"
+        StagedMedia(uri = uri, mimeType = mimeType, filename = filename)
+    }.getOrNull()
 }
 
 private fun encodeMediaUploadPayload(ctx: Context, staged: StagedMedia): MediaUploadPayload? {
-    val bytes = ctx.contentResolver.openInputStream(staged.uri)?.use { it.readBytes() } ?: return null
+    val bytes =
+        runCatching {
+            ctx.contentResolver.openInputStream(staged.uri)?.use { it.readBytes() }
+        }.getOrNull() ?: return null
     if (bytes.isEmpty()) return null
     return MediaUploadPayload(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt` around lines
210 - 233, Wrap the body of readMediaMetadata(...) and
encodeMediaUploadPayload(...) in try-catch blocks that catch provider-related
exceptions (e.g., SecurityException, IllegalArgumentException,
FileNotFoundException, IOException or a general Exception fallback) so any
thrown from ContentResolver.getType, query, openInputStream, or readBytes are
handled; on exception return null (so callers like
sendMediaFromUri/sendStagedMedia fall through to existing null/toast handling)
and optionally log the exception via Android Log or your existing logger for
debugging; keep the functions' signatures and returned types unchanged
(StagedMedia? and MediaUploadPayload?) and ensure resources are still closed by
using existing use { } inside the try.
♻️ Duplicate comments (1)
android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt (1)

371-398: ⚠️ Potential issue | 🟠 Major

Don't let the first invalid picks consume the last free slots.

Capacity is still decided before validation. With 30 staged items and [bad1, bad2, good1, good2], only the first two URIs are examined, so later valid URIs never get a chance to fill the freed slots. Iterate until you collect remainingCapacity successful items instead of truncating up front.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt` around lines
371 - 398, stageMediaFromUris currently decides capacity by truncating
acceptedUris up front, causing early invalid items to consume slots; change the
logic to compute remainingCapacity from stagedMedia.size and
stagingReservationCount, then iterate over the incoming uris (not just the
truncated acceptedUris) and process each (takePersistableReadPermission,
readMediaMetadata) inside the withContext block until you have collected up to
remainingCapacity successful StagedMedia items or run out of uris; only
increment stagingReservationCount by the number of actually reserved/retained
items, and update grantedUris/retainedUris accordingly so later valid URIs can
fill freed slots (keep referenced symbols: stageMediaFromUris,
selectUrisForStaging, stagingReservationCount, acceptedUris, readMediaMetadata,
takePersistableReadPermission).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt`:
- Around line 433-492: The sendStagedMedia function is reentrant: it snapshots
stagedMedia, does IO, then updates state, allowing double-taps or concurrent
calls to dispatch duplicate sends; add an in-flight guard (e.g., a Boolean
property like isSending or sendInProgress) checked at the top of sendStagedMedia
and set true before starting the coroutine and false in all terminal paths
(success, failure, early returns) to prevent reentry, and also use that flag to
disable the send/remove UI controls while the operation runs (references:
sendStagedMedia, stagedMedia, stagedSnapshot, draft, replyDraft).
- Around line 509-520: The single-item selection handlers
(pickPhotoOrVideoLauncher and pickFileLauncher) currently call
sendMediaFromUri() which creates a separate message instead of joining an
existing staged batch; change both callbacks to check stagedMedia (e.g.,
stagedMedia.isNotEmpty()) and, when non-empty, add the selected URI to the
staging flow (call stageMediaFromUris(listOf(uri)) or the existing staging
helper) instead of calling sendMediaFromUri(), otherwise keep the current
behavior (use sendMediaFromUri() when stagedMedia is empty); update both
locations referencing sendMediaFromUri, pickPhotoOrVideoLauncher,
pickFileLauncher, stageMediaFromUris and stagedMedia accordingly.
- Around line 210-233: Wrap the body of readMediaMetadata(...) and
encodeMediaUploadPayload(...) in try-catch blocks that catch provider-related
exceptions (e.g., SecurityException, IllegalArgumentException,
FileNotFoundException, IOException or a general Exception fallback) so any
thrown from ContentResolver.getType, query, openInputStream, or readBytes are
handled; on exception return null (so callers like
sendMediaFromUri/sendStagedMedia fall through to existing null/toast handling)
and optionally log the exception via Android Log or your existing logger for
debugging; keep the functions' signatures and returned types unchanged
(StagedMedia? and MediaUploadPayload?) and ensure resources are still closed by
using existing use { } inside the try.

---

Duplicate comments:
In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt`:
- Around line 371-398: stageMediaFromUris currently decides capacity by
truncating acceptedUris up front, causing early invalid items to consume slots;
change the logic to compute remainingCapacity from stagedMedia.size and
stagingReservationCount, then iterate over the incoming uris (not just the
truncated acceptedUris) and process each (takePersistableReadPermission,
readMediaMetadata) inside the withContext block until you have collected up to
remainingCapacity successful StagedMedia items or run out of uris; only
increment stagingReservationCount by the number of actually reserved/retained
items, and update grantedUris/retainedUris accordingly so later valid URIs can
fill freed slots (keep referenced symbols: stageMediaFromUris,
selectUrisForStaging, stagingReservationCount, acceptedUris, readMediaMetadata,
takePersistableReadPermission).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7d1cef75-a343-4c37-9340-737dc3173daf

📥 Commits

Reviewing files that changed from the base of the PR and between b913545 and d79ecca.

📒 Files selected for processing (2)
  • android/app/src/androidTest/java/com/pika/app/ui/screens/ChatScreenMediaStagingTest.kt
  • android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt

@jodobear jodobear force-pushed the android-issue-433-media-staging branch from d79ecca to 9988a31 Compare March 9, 2026 01:27
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt (1)

515-527: ⚠️ Potential issue | 🟠 Major

Block compose actions while attachment staging is still in flight.

stagedMedia stays empty until stageMediaFromUris() finishes its IO pass, but these guards only look at stagedSendInFlight. In that gap, tapping Send can dispatch a plain SendMessage for the caption, and a follow-up single pick can go through sendMediaFromUri() instead of joining the staged batch. Please treat stagingReservationCount > 0 as busy state anywhere we enable send/attach actions or choose immediate-send vs staging.

💡 Suggested guard pattern
+    val isStagingMedia = stagingReservationCount > 0
+
     fun sendDraftMessage() {
+        if (isStagingMedia) return
         if (stagedMedia.isNotEmpty()) {
             sendStagedMedia()
             return
         }
         val text = draft.trim()
@@
         rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
-            if (uris.size == 1 && currentStagedMedia.isEmpty()) {
+            if (uris.size == 1 && currentStagedMedia.isEmpty() && !isStagingMedia) {
                 sendMediaFromUri(uris.first())
             } else if (uris.isNotEmpty()) {
                 stageMediaFromUris(uris)
             }
         }
@@
         rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
             uri?.let {
-                if (currentStagedMedia.isEmpty()) sendMediaFromUri(it) else stageMediaFromUris(listOf(it))
+                if (currentStagedMedia.isEmpty() && !isStagingMedia) {
+                    sendMediaFromUri(it)
+                } else {
+                    stageMediaFromUris(listOf(it))
+                }
             }
         }
@@
-                                    enabled = !stagedSendInFlight,
+                                    enabled = !stagedSendInFlight && !isStagingMedia,
@@
-                                    enabled = (draft.isNotBlank() || stagedMedia.isNotEmpty()) && !stagedSendInFlight,
+                                    enabled = (draft.isNotBlank() || stagedMedia.isNotEmpty()) &&
+                                        !stagedSendInFlight &&
+                                        !isStagingMedia,

Also applies to: 529-543, 1098-1161

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt` around lines
515 - 527, The sendDraftMessage flow is allowing a plain SendMessage to go
through while stageMediaFromUris() is still performing IO because it only checks
stagedSendInFlight; update send/attach guard logic to treat
stagingReservationCount > 0 as a busy state as well so actions join the staging
flow instead of sending immediately: in sendDraftMessage (and the other
send/attach decision points around sendStagedMedia, sendMediaFromUri and the
send/attach UI handlers referenced at lines ~529-543 and ~1098-1161) add a
pre-check that if stagingReservationCount > 0 then either return/disable send or
route the caption into the staged batch (i.e., defer dispatch of
AppAction.SendMessage(chat.chatId, text, null, replyDraft?.id) until stagedMedia
is available), and ensure replyDraft is preserved until staging completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt`:
- Around line 515-527: The sendDraftMessage flow is allowing a plain SendMessage
to go through while stageMediaFromUris() is still performing IO because it only
checks stagedSendInFlight; update send/attach guard logic to treat
stagingReservationCount > 0 as a busy state as well so actions join the staging
flow instead of sending immediately: in sendDraftMessage (and the other
send/attach decision points around sendStagedMedia, sendMediaFromUri and the
send/attach UI handlers referenced at lines ~529-543 and ~1098-1161) add a
pre-check that if stagingReservationCount > 0 then either return/disable send or
route the caption into the staged batch (i.e., defer dispatch of
AppAction.SendMessage(chat.chatId, text, null, replyDraft?.id) until stagedMedia
is available), and ensure replyDraft is preserved until staging completes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a673840b-bcde-41c6-ae10-cdb196f22628

📥 Commits

Reviewing files that changed from the base of the PR and between d79ecca and 9988a31.

📒 Files selected for processing (2)
  • android/app/src/androidTest/java/com/pika/app/ui/screens/ChatScreenMediaStagingTest.kt
  • android/app/src/main/java/com/pika/app/ui/screens/ChatScreen.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • android/app/src/androidTest/java/com/pika/app/ui/screens/ChatScreenMediaStagingTest.kt

devin-ai-integration[bot]

This comment was marked as resolved.

@jodobear jodobear force-pushed the android-issue-433-media-staging branch from 9988a31 to 173d4ef Compare March 9, 2026 01:57
coderabbitai[bot]

This comment was marked as resolved.

@jodobear jodobear force-pushed the android-issue-433-media-staging branch from 173d4ef to a099f69 Compare March 9, 2026 02:55
coderabbitai[bot]

This comment was marked as resolved.

@jodobear jodobear force-pushed the android-issue-433-media-staging branch from a099f69 to 3ead9a6 Compare March 9, 2026 03:28
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