From a72c29e1b8ce4dff582a6ea928cafab4252c897c Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:45:22 +0900 Subject: [PATCH 1/2] feat(chat): paste clipboard images into the composer A copied screenshot or image gave the message field nothing to paste, so it had to be saved and re-attached from the gallery. The field now offers Paste in its selection toolbar when the clipboard holds an image and no text, and Ctrl+V / Shift+Insert attach it from a hardware keyboard. The image goes through the same decode-and-attach path as the gallery picker, so it shows the usual thumbnail before sending. A clipboard that carries text keeps the normal text paste. The gallery picker now shares decodeImageUri with the paste path, which reads through the existing 25 MB readLimited cap instead of readBytes(). Closes #401 Co-Authored-By: Claude Sonnet 5.5 --- .../andcode/feature/chat/ChatHomeScreen.kt | 129 ++++++++++------- .../andcode/feature/chat/ChatImageViewer.kt | 2 +- .../feature/chat/ClipboardImagePaste.kt | 108 ++++++++++++++ .../feature/chat/ClipboardImagePasteTest.kt | 132 ++++++++++++++++++ 4 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePasteTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 9b60cf531..1f7faa2bd 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -3,6 +3,7 @@ package com.yugahashimoto.andcode.feature.chat import android.Manifest import android.content.pm.PackageManager import android.graphics.Bitmap +import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -92,11 +93,13 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -106,11 +109,18 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -155,10 +165,8 @@ import com.yugahashimoto.andcode.ui.components.StatusChip import com.yugahashimoto.andcode.ui.components.VolumeMeter import com.yugahashimoto.andcode.ui.components.systemPromptPresetLabel import com.yugahashimoto.andcode.ui.theme.AndCodeTheme -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonPrimitive /** What the long-press action sheet offers for one message: its id, its text to copy, and whether "Edit & resend" applies. */ @@ -323,27 +331,16 @@ fun ChatHomeScreen( cameraLauncher.launch(null) } } + val attachImageUris: (List) -> Unit = { uris -> + coroutineScope.launch { + uris.forEach { uri -> decodeImageUri(context, uri)?.let(onImageAttachment) } + } + } val galleryLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(), - ) { uris -> - coroutineScope.launch { - uris.forEach { uri -> - try { - val bitmap = - withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?.let { bytes -> decodeSampledBitmap(bytes, COMPOSER_PREVIEW_MAX_DIMENSION) } - } - if (bitmap != null) { - onImageAttachment(bitmap) - } - } catch (e: Exception) { - android.util.Log.w("ChatHomeScreen", "Failed to load image", e) - } - } - } - } + onResult = attachImageUris, + ) LaunchedEffect(listState) { snapshotFlow { @@ -734,6 +731,7 @@ fun ChatHomeScreen( ), ) }, + onPasteImage = { attachImageUris(context.clipboardImageUris()) }, ) } } @@ -1356,10 +1354,23 @@ private fun ChatComposer( onRemoveImage: (Int) -> Unit, onCameraLaunch: () -> Unit, onGalleryLaunch: () -> Unit, + onPasteImage: () -> Unit, ) { val input = inputValue.text val voiceActive = isListening || isSpeechProcessing var showAttachMenu by remember { mutableStateOf(false) } + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val currentOnPasteImage by rememberUpdatedState(onPasteImage) + val baseTextToolbar = LocalTextToolbar.current + val imagePasteToolbar = + remember(baseTextToolbar, context) { + ImagePasteTextToolbar( + delegate = baseTextToolbar, + canPasteImage = { context.clipboardHasImage() }, + onPasteImage = { currentOnPasteImage() }, + ) + } Column( modifier = @@ -1479,39 +1490,51 @@ private fun ChatComposer( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - BasicTextField( - value = inputValue, - onValueChange = onInputValueChange, - modifier = - Modifier - .fillMaxWidth() - .testTag("chat-message-input"), - minLines = 1, - maxLines = 4, - textStyle = - TextStyle( - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.bodyLarge.fontSize, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, - // Follow the typed content's direction so Arabic (RTL) input lays - // out correctly even when the app language is LTR (issue #341). - textDirection = TextDirection.Content, - ), - keyboardOptions = - KeyboardOptions( - imeAction = - if (enterToSend) ImeAction.Send else ImeAction.Default, - ), - keyboardActions = - KeyboardActions( - onSend = { - if (enterToSend) { - onSend() - } - }, - ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - ) + // A copied screenshot is an image, which the field itself cannot paste: the + // toolbar and the paste shortcut attach it instead (issue #401). + CompositionLocalProvider(LocalTextToolbar provides imagePasteToolbar) { + BasicTextField( + value = inputValue, + onValueChange = onInputValueChange, + modifier = + Modifier + .fillMaxWidth() + .testTag("chat-message-input") + .onPreviewKeyEvent { event -> + val pasteImage = + event.type == KeyEventType.KeyDown && + isPasteShortcut(event.key, event.isCtrlPressed, event.isShiftPressed) && + shouldPasteImage(clipboardManager.hasText(), context.clipboardHasImage()) + if (pasteImage) currentOnPasteImage() + pasteImage + }, + minLines = 1, + maxLines = 4, + textStyle = + TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, + // Follow the typed content's direction so Arabic (RTL) input lays + // out correctly even when the app language is LTR (issue #341). + textDirection = TextDirection.Content, + ), + keyboardOptions = + KeyboardOptions( + imeAction = + if (enterToSend) ImeAction.Send else ImeAction.Default, + ), + keyboardActions = + KeyboardActions( + onSend = { + if (enterToSend) { + onSend() + } + }, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + ) + } } if (isListening || isSpeechProcessing) { Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt index 9fd48a470..110ddbfa7 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt @@ -303,7 +303,7 @@ private fun File.isInside(root: File): Boolean { return canonicalFile.toPath().startsWith(canonicalRoot) } -private fun readLimited(input: java.io.InputStream): ByteArray { +internal fun readLimited(input: java.io.InputStream): ByteArray { val output = ByteArrayOutputStream() val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var total = 0 diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt new file mode 100644 index 000000000..2a9975e23 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt @@ -0,0 +1,108 @@ +package com.yugahashimoto.andcode.feature.chat + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.TextToolbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val IMAGE_MIME_PATTERN = "image/*" +private const val IMAGE_MIME_PREFIX = "image/" + +/** + * Whether a paste should attach the clipboard's image rather than insert its text. + * + * Text wins when both are present: a copied web selection can carry an image alongside its text, + * and replacing the paste the user expects with an attachment would be the surprise. + */ +internal fun shouldPasteImage( + hasText: Boolean, + hasImage: Boolean, +): Boolean = hasImage && !hasText + +/** Ctrl+V, or Shift+Insert, as a hardware keyboard sends them. */ +internal fun isPasteShortcut( + key: Key, + ctrl: Boolean, + shift: Boolean, +): Boolean = (ctrl && key == Key.V) || (shift && key == Key.Insert) + +/** + * Adds "Paste" to the text field's selection toolbar when the clipboard holds an image. + * + * The field only offers Paste for text, so a copied screenshot gave the long-press menu nothing to + * paste. When the field offers no paste of its own and [canPasteImage] holds, this supplies one + * that calls [onPasteImage]; a paste the field does offer, and every other action, pass through. + */ +internal class ImagePasteTextToolbar( + private val delegate: TextToolbar, + private val canPasteImage: () -> Boolean, + private val onPasteImage: () -> Unit, +) : TextToolbar by delegate { + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + ) { + val paste = + onPasteRequested + ?: if (canPasteImage()) { + { + onPasteImage() + delegate.hide() + } + } else { + null + } + delegate.showMenu(rect, onCopyRequested, paste, onCutRequested, onSelectAllRequested) + } +} + +private fun Context.clipboard(): ClipboardManager? = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + +/** + * Whether the primary clip holds an image. Reads only the clip's description, which unlike its + * content does not make Android 12+ tell the user that the app has read their clipboard. + */ +internal fun Context.clipboardHasImage(): Boolean = + runCatching { clipboard()?.primaryClipDescription?.hasMimeType(IMAGE_MIME_PATTERN) == true } + .getOrDefault(false) + +/** The image URIs on the primary clip; an empty list when it holds none or cannot be read. */ +internal fun Context.clipboardImageUris(): List { + val clip = runCatching { clipboard()?.primaryClip }.getOrNull() ?: return emptyList() + return clip.imageUris(contentResolver) +} + +internal fun ClipData.imageUris(resolver: ContentResolver): List = + (0 until itemCount) + .mapNotNull { getItemAt(it).uri } + .filter { uri -> + runCatching { resolver.getType(uri)?.startsWith(IMAGE_MIME_PREFIX) == true }.getOrDefault(false) + } + +/** + * Decodes the image at [uri] into a composer-sized thumbnail, or returns null when it cannot be + * read, is too large, or is not an image. Shared by the gallery picker and clipboard paste. + */ +internal suspend fun decodeImageUri( + context: Context, + uri: Uri, +): Bitmap? = + withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use(::readLimited) + ?.let { bytes -> decodeSampledBitmap(bytes, COMPOSER_PREVIEW_MAX_DIMENSION) } + } catch (e: Exception) { + android.util.Log.w("ClipboardImagePaste", "Failed to load image", e) + null + } + } diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePasteTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePasteTest.kt new file mode 100644 index 000000000..943f93f63 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePasteTest.kt @@ -0,0 +1,132 @@ +package com.yugahashimoto.andcode.feature.chat + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.TextToolbar +import androidx.compose.ui.platform.TextToolbarStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClipboardImagePasteTest { + @Test + fun `an image-only clipboard is pasted as an image`() { + assertTrue(shouldPasteImage(hasText = false, hasImage = true)) + } + + @Test + fun `text on the clipboard keeps the normal text paste`() { + assertFalse(shouldPasteImage(hasText = true, hasImage = true)) + assertFalse(shouldPasteImage(hasText = true, hasImage = false)) + } + + @Test + fun `an empty clipboard pastes nothing`() { + assertFalse(shouldPasteImage(hasText = false, hasImage = false)) + } + + @Test + fun `ctrl+v and shift+insert are the paste shortcuts`() { + assertTrue(isPasteShortcut(Key.V, ctrl = true, shift = false)) + assertTrue(isPasteShortcut(Key.Insert, ctrl = false, shift = true)) + } + + @Test + fun `other keys and unmodified keys are not paste shortcuts`() { + assertFalse(isPasteShortcut(Key.V, ctrl = false, shift = false)) + assertFalse(isPasteShortcut(Key.C, ctrl = true, shift = false)) + assertFalse(isPasteShortcut(Key.Insert, ctrl = true, shift = false)) + assertFalse(isPasteShortcut(Key.V, ctrl = false, shift = true)) + } + + @Test + fun `toolbar offers paste for a clipboard image the text field would not offer`() { + val delegate = RecordingToolbar() + var pasted = 0 + val toolbar = ImagePasteTextToolbar(delegate, canPasteImage = { true }, onPasteImage = { pasted++ }) + + toolbar.showMenu(RECT, onPasteRequested = null) + + val offered = delegate.shownPaste + assertNotNull(offered) + offered!!.invoke() + assertEquals(1, pasted) + assertEquals("pasting must dismiss the menu like the stock paste action does", 1, delegate.hideCount) + } + + @Test + fun `toolbar leaves the text paste alone when the text field offers one`() { + val delegate = RecordingToolbar() + val textPaste: () -> Unit = {} + val toolbar = ImagePasteTextToolbar(delegate, canPasteImage = { true }, onPasteImage = { error("not an image paste") }) + + toolbar.showMenu(RECT, onPasteRequested = textPaste) + + assertSame(textPaste, delegate.shownPaste) + } + + @Test + fun `toolbar offers no paste when the clipboard has no image`() { + val delegate = RecordingToolbar() + val toolbar = ImagePasteTextToolbar(delegate, canPasteImage = { false }, onPasteImage = { error("not an image paste") }) + + toolbar.showMenu(RECT, onPasteRequested = null) + + assertNull(delegate.shownPaste) + } + + @Test + fun `toolbar forwards the other actions and the status untouched`() { + val delegate = RecordingToolbar() + val copy: () -> Unit = {} + val cut: () -> Unit = {} + val selectAll: () -> Unit = {} + val toolbar = ImagePasteTextToolbar(delegate, canPasteImage = { true }, onPasteImage = {}) + + toolbar.showMenu(RECT, onCopyRequested = copy, onCutRequested = cut, onSelectAllRequested = selectAll) + + assertSame(copy, delegate.shownCopy) + assertSame(cut, delegate.shownCut) + assertSame(selectAll, delegate.shownSelectAll) + assertEquals(TextToolbarStatus.Shown, toolbar.status) + toolbar.hide() + assertEquals(TextToolbarStatus.Hidden, toolbar.status) + } + + private class RecordingToolbar : TextToolbar { + var shownCopy: (() -> Unit)? = null + var shownPaste: (() -> Unit)? = null + var shownCut: (() -> Unit)? = null + var shownSelectAll: (() -> Unit)? = null + var hideCount = 0 + override var status: TextToolbarStatus = TextToolbarStatus.Hidden + private set + + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + ) { + shownCopy = onCopyRequested + shownPaste = onPasteRequested + shownCut = onCutRequested + shownSelectAll = onSelectAllRequested + status = TextToolbarStatus.Shown + } + + override fun hide() { + hideCount++ + status = TextToolbarStatus.Hidden + } + } + + private companion object { + val RECT = Rect(0f, 0f, 10f, 10f) + } +} From 5f61895b27961cb18f1f974af52981fa628e2f95 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:51:23 +0900 Subject: [PATCH 2/2] fix(chat): harden clipboard image paste after review - Resolve the clip's URI types off the main thread: getType() calls into the app that copied the image, which can be slow to answer. - Accept only content:// URIs from the clipboard, so a file:// URI chosen by the clip's owner cannot point at this app's own files. - Attach on the first KeyDown only, so holding Ctrl+V does not attach the same image repeatedly. - Tell the user when a pasted or gallery image cannot be attached, using the existing attachment_load_failed string. - Move decodeImageUri next to decodeSampledBitmap/readLimited and cover the 25 MB readLimited boundary with unit tests. Co-Authored-By: Claude Sonnet 5.5 --- .../andcode/feature/chat/ChatHomeScreen.kt | 27 +++++++--- .../andcode/feature/chat/ChatImageViewer.kt | 18 +++++++ .../feature/chat/ClipboardImagePaste.kt | 40 ++++++--------- .../andcode/feature/chat/ReadLimitedTest.kt | 49 +++++++++++++++++++ 4 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/chat/ReadLimitedTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 1f7faa2bd..d952807cb 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -331,16 +331,23 @@ fun ChatHomeScreen( cameraLauncher.launch(null) } } - val attachImageUris: (List) -> Unit = { uris -> - coroutineScope.launch { - uris.forEach { uri -> decodeImageUri(context, uri)?.let(onImageAttachment) } + val showAttachFailure = { + android.widget.Toast.makeText( + context, + R.string.attachment_load_failed, + android.widget.Toast.LENGTH_SHORT, + ).show() + } + val attachImageUris: suspend (List) -> Unit = { uris -> + uris.forEach { uri -> + val bitmap = decodeImageUri(context, uri) + if (bitmap != null) onImageAttachment(bitmap) else showAttachFailure() } } val galleryLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(), - onResult = attachImageUris, - ) + ) { uris -> coroutineScope.launch { attachImageUris(uris) } } LaunchedEffect(listState) { snapshotFlow { @@ -731,7 +738,13 @@ fun ChatHomeScreen( ), ) }, - onPasteImage = { attachImageUris(context.clipboardImageUris()) }, + onPasteImage = { + val clip = context.primaryClipOrNull() + coroutineScope.launch { + val uris = clip?.imageUris(context.contentResolver).orEmpty() + if (uris.isEmpty()) showAttachFailure() else attachImageUris(uris) + } + }, ) } } @@ -1503,6 +1516,8 @@ private fun ChatComposer( .onPreviewKeyEvent { event -> val pasteImage = event.type == KeyEventType.KeyDown && + // Holding the keys repeats KeyDown, which would attach the image again. + event.nativeKeyEvent.repeatCount == 0 && isPasteShortcut(event.key, event.isCtrlPressed, event.isShiftPressed) && shouldPasteImage(clipboardManager.hasText(), context.clipboardHasImage()) if (pasteImage) currentOnPasteImage() diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt index 110ddbfa7..e9e06068b 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatImageViewer.kt @@ -167,6 +167,24 @@ fun decodeSampledBitmap( return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) } +/** + * Decodes the image at [uri] into a composer-sized thumbnail, or returns null when it cannot be + * read, is over [MAX_IMAGE_BYTES], or is not an image. Shared by the gallery picker and clipboard paste. + */ +suspend fun decodeImageUri( + context: Context, + uri: Uri, +): Bitmap? = + withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use(::readLimited) + ?.let { bytes -> decodeSampledBitmap(bytes, COMPOSER_PREVIEW_MAX_DIMENSION) } + } catch (e: Exception) { + android.util.Log.w("ChatImage", "Failed to load image", e) + null + } + } + suspend fun loadChatImageBytes( context: Context, source: ChatImageSource, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt index 2a9975e23..c9ee5664e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ClipboardImagePaste.kt @@ -4,7 +4,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context -import android.graphics.Bitmap import android.net.Uri import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.key.Key @@ -76,33 +75,22 @@ internal fun Context.clipboardHasImage(): Boolean = runCatching { clipboard()?.primaryClipDescription?.hasMimeType(IMAGE_MIME_PATTERN) == true } .getOrDefault(false) -/** The image URIs on the primary clip; an empty list when it holds none or cannot be read. */ -internal fun Context.clipboardImageUris(): List { - val clip = runCatching { clipboard()?.primaryClip }.getOrNull() ?: return emptyList() - return clip.imageUris(contentResolver) -} - -internal fun ClipData.imageUris(resolver: ContentResolver): List = - (0 until itemCount) - .mapNotNull { getItemAt(it).uri } - .filter { uri -> - runCatching { resolver.getType(uri)?.startsWith(IMAGE_MIME_PREFIX) == true }.getOrDefault(false) - } +/** The primary clip, or null when the clipboard is empty or cannot be read. */ +internal fun Context.primaryClipOrNull(): ClipData? = runCatching { clipboard()?.primaryClip }.getOrNull() /** - * Decodes the image at [uri] into a composer-sized thumbnail, or returns null when it cannot be - * read, is too large, or is not an image. Shared by the gallery picker and clipboard paste. + * The content URIs on this clip that hold an image; empty when there are none. + * + * Asking a URI for its type calls into the app that copied it, which may be slow to answer, so + * this runs off the main thread. Only `content://` URIs count: the clip's owner chooses the URI, + * and a `file://` one could point at this app's own files. */ -internal suspend fun decodeImageUri( - context: Context, - uri: Uri, -): Bitmap? = +internal suspend fun ClipData.imageUris(resolver: ContentResolver): List = withContext(Dispatchers.IO) { - try { - context.contentResolver.openInputStream(uri)?.use(::readLimited) - ?.let { bytes -> decodeSampledBitmap(bytes, COMPOSER_PREVIEW_MAX_DIMENSION) } - } catch (e: Exception) { - android.util.Log.w("ClipboardImagePaste", "Failed to load image", e) - null - } + (0 until itemCount) + .mapNotNull { getItemAt(it).uri } + .filter { uri -> uri.scheme == ContentResolver.SCHEME_CONTENT && resolver.isImage(uri) } } + +private fun ContentResolver.isImage(uri: Uri): Boolean = + runCatching { getType(uri)?.startsWith(IMAGE_MIME_PREFIX) == true }.getOrDefault(false) diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ReadLimitedTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ReadLimitedTest.kt new file mode 100644 index 000000000..63e93a2db --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ReadLimitedTest.kt @@ -0,0 +1,49 @@ +package com.yugahashimoto.andcode.feature.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.InputStream +import java.util.Arrays + +class ReadLimitedTest { + @Test + fun `a stream at the size limit is read in full`() { + assertEquals(LIMIT, readLimited(zeros(LIMIT)).size) + } + + @Test + fun `a stream one byte over the size limit is rejected`() { + assertThrows(IllegalArgumentException::class.java) { readLimited(zeros(LIMIT + 1)) } + } + + @Test + fun `an empty stream reads as no bytes`() { + assertEquals(0, readLimited(zeros(0)).size) + } + + /** A stream of [count] zero bytes that never allocates them, so the test can cross the limit cheaply. */ + private fun zeros(count: Int): InputStream = + object : InputStream() { + private var left = count + + override fun read(): Int = if (left > 0) 0.also { left-- } else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (left == 0) return -1 + val read = minOf(length, left) + Arrays.fill(buffer, offset, offset + read, 0) + left -= read + return read + } + } + + private companion object { + /** Mirrors the private `MAX_IMAGE_BYTES` in `ChatImageViewer.kt`. */ + const val LIMIT = 25 * 1024 * 1024 + } +}