From fb99c1b5bc32b3b7d3981e009ec4afaab26ccba7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:06:09 -0400 Subject: [PATCH] feat(chat): stage images dropped on the composer Images dropped on the bottom bar, pasted, or inserted from a keyboard are copied into the capture directory and staged like picked photos. Rejected when the conversation does not accept media or for non-image items. --- .../screens/components/ChatBottomBar.kt | 13 ++- .../shared/chat/ui/media/ChatCameraPanel.kt | 2 +- .../shared/chat/ui/media/ComposerImageDrop.kt | 109 ++++++++++++++++++ .../chat/ui/media/ComposerImageDropTest.kt | 53 +++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDrop.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDropTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 80e4233fc..a4cb4cbde 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -20,6 +20,7 @@ import com.flipcash.shared.chat.ui.media.ComposerPhotoChips import com.flipcash.shared.chat.ui.media.MAX_STAGED_PHOTOS import com.flipcash.shared.chat.ui.media.attachRows import com.flipcash.shared.chat.ui.media.rememberChatPhotoPicker +import com.flipcash.shared.chat.ui.media.composerImageReceiver import com.flipcash.shared.chat.ui.media.deleteLeftoverCaptures import androidx.compose.ui.platform.LocalContext import androidx.compose.animation.AnimatedContent @@ -255,7 +256,17 @@ internal fun UserControlBottomBar( val screenHeight = maxHeight Column( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + // Images dropped anywhere on the bar (strip and field included), pasted, or sent by a + // keyboard are staged like picked photos; a chat that takes no media refuses them. + .composerImageReceiver( + acceptsMedia = state.acceptsMedia, + remaining = MAX_STAGED_PHOTOS - state.stagedPhotos.size, + onImages = { uris -> + dispatch(ChatViewModel.Event.StagePhotos(uris, captured = true)) + focusAfterPick = true + }, + ), ) { // Compact at rest: with the keyboard down the composer sits narrower and a little lower, into // the navigation bar's inset, and opens out to the normal margins as the keyboard comes up. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt index d376296c1..57c36e130 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt @@ -488,7 +488,7 @@ private class CameraRelease(private val unbind: () -> Unit) { private fun captureDir(context: Context) = File(context.cacheDir, "chat_capture") -private fun captureFile(context: Context): File { +internal fun captureFile(context: Context): File { val dir = captureDir(context).apply { mkdirs() } return File(dir, "photo_${UUID.randomUUID()}.jpg") } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDrop.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDrop.kt new file mode 100644 index 000000000..5441a4ad2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDrop.kt @@ -0,0 +1,109 @@ +package com.flipcash.shared.chat.ui.media + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.content.ReceiveContentListener +import androidx.compose.foundation.content.TransferableContent +import androidx.compose.foundation.content.consume +import androidx.compose.foundation.content.contentReceiver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Takes the images in [candidates] that the composer will stage, in order. + * + * Nothing is taken when the conversation doesn't accept media (the same gate that hides the + * Camera and Photos rows), and no more than [remaining] are. An item is an image only if + * [mimeOf] says an image type for it; an unknown type is not taken. + */ +internal fun selectDroppedImages( + candidates: List, + acceptsMedia: Boolean, + remaining: Int, + mimeOf: (Uri) -> String?, +): List { + if (!acceptsMedia || remaining <= 0) return emptyList() + return candidates.filter { mimeOf(it)?.startsWith("image/") == true }.take(remaining) +} + +/** + * Copies each of [sources] with [copy] and returns the copies that succeeded, in order. + * + * A dropped content URI is readable only under a grant that lives as long as the activity, so the + * bytes are copied out before anything deferred; the upload pipeline would otherwise read it + * after a blob policy fetch, possibly after the grant is gone. + */ +internal suspend fun copyDroppedImages(sources: List, copy: suspend (Uri) -> Uri?): List = + sources.mapNotNull { runCatching { copy(it) }.getOrNull() } + +/** Copies [source] into the camera capture directory and returns the file's URI, null on failure. */ +internal suspend fun copyIntoCaptureFile(context: Context, source: Uri): Uri? = withContext(Dispatchers.IO) { + val file: File = captureFile(context) + val copied = runCatching { + context.contentResolver.openInputStream(source)?.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } != null + }.getOrDefault(false) + if (copied && file.length() > 0) { + Uri.fromFile(file) + } else { + file.delete() + null + } +} + +/** + * Receives images dropped (or pasted, or inserted from a keyboard) on whatever this is applied to, + * and hands app-owned copies of them to [onImages], to be staged like picked photos. + * + * `contentReceiver` asks the activity for the drag's URI permission itself on a drop (it calls + * `DragAndDropPermissionsCompat.request` before `onReceive`), and a text field below this node + * defers to it, so the field takes drops here as well. + * + * Only an image type items are taken. Anything else, and every item when [acceptsMedia] is false, is + * returned unconsumed so the field can use text or the drop is refused. [onImages] is called with + * files under the capture directory, which the view model deletes once they are encoded. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Modifier.composerImageReceiver( + acceptsMedia: Boolean, + remaining: Int, + onImages: (List) -> Unit, +): Modifier { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val currentAccepts by rememberUpdatedState(acceptsMedia) + val currentRemaining by rememberUpdatedState(remaining) + val currentOnImages by rememberUpdatedState(onImages) + val listener = remember(context, scope) { + ReceiveContentListener { content: TransferableContent -> + val resolver = context.contentResolver + val mimeOf: (Uri) -> String? = { runCatching { resolver.getType(it) }.getOrNull() } + val items = buildList { + val clip = content.clipEntry.clipData + for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let(::add) + } + val taken = selectDroppedImages(items, currentAccepts, currentRemaining, mimeOf) + if (taken.isEmpty()) return@ReceiveContentListener content + // Every image item is consumed, including any past the limit, so they don't land in the field. + val rest = content.consume { item -> item.uri?.let { mimeOf(it)?.startsWith("image/") } == true } + scope.launch { + val copies = copyDroppedImages(taken) { copyIntoCaptureFile(context, it) } + if (copies.isNotEmpty()) currentOnImages(copies) + } + rest + } + } + return this.contentReceiver(listener) +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDropTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDropTest.kt new file mode 100644 index 000000000..f8a8e230e --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerImageDropTest.kt @@ -0,0 +1,53 @@ +package com.flipcash.shared.chat.ui.media + +import android.net.Uri +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ComposerImageDropTest { + + private val png = Uri.parse("content://other.app/shot.png") + private val jpg = Uri.parse("content://other.app/pic.jpg") + private val pdf = Uri.parse("content://other.app/doc.pdf") + private val unknown = Uri.parse("content://other.app/mystery") + + private val types = mapOf(png to "image/png", jpg to "image/jpeg", pdf to "application/pdf") + private val mimeOf: (Uri) -> String? = { types[it] } + + @Test + fun takesImages() { + assertEquals(listOf(png, jpg), selectDroppedImages(listOf(png, jpg), true, 10, mimeOf)) + } + + @Test + fun rejectsNonImagesAndUnknownTypes() { + assertEquals(listOf(png), selectDroppedImages(listOf(pdf, png, unknown), true, 10, mimeOf)) + assertEquals(emptyList(), selectDroppedImages(listOf(pdf), true, 10, mimeOf)) + } + + @Test + fun rejectsEverythingWhenConversationDoesNotAcceptMedia() { + assertEquals(emptyList(), selectDroppedImages(listOf(png, jpg), false, 10, mimeOf)) + } + + @Test + fun stopsAtRemainingRoom() { + assertEquals(listOf(png), selectDroppedImages(listOf(png, jpg), true, 1, mimeOf)) + assertEquals(emptyList(), selectDroppedImages(listOf(png), true, 0, mimeOf)) + } + + @Test + fun copiesInOrderAndSkipsFailures() = runTest { + val local = { u: Uri -> Uri.parse("file:///cache/${u.lastPathSegment}") } + val copies = copyDroppedImages(listOf(png, unknown, jpg)) { if (it == unknown) null else local(it) } + assertEquals(listOf(local(png), local(jpg)), copies) + val thrown = copyDroppedImages(listOf(png, jpg)) { if (it == png) error("grant revoked") else local(it) } + assertEquals(listOf(local(jpg)), thrown) + } +}