Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Uri>,
acceptsMedia: Boolean,
remaining: Int,
mimeOf: (Uri) -> String?,
): List<Uri> {
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<Uri>, copy: suspend (Uri) -> Uri?): List<Uri> =
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<Uri>) -> 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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading