Skip to content
Merged
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 @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -323,27 +331,23 @@ fun ChatHomeScreen(
cameraLauncher.launch(null)
}
}
val showAttachFailure = {
android.widget.Toast.makeText(
context,
R.string.attachment_load_failed,
android.widget.Toast.LENGTH_SHORT,
).show()
}
val attachImageUris: suspend (List<Uri>) -> Unit = { uris ->
uris.forEach { uri ->
val bitmap = decodeImageUri(context, uri)
if (bitmap != null) onImageAttachment(bitmap) else showAttachFailure()
}
}
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)
}
}
}
}
) { uris -> coroutineScope.launch { attachImageUris(uris) } }

LaunchedEffect(listState) {
snapshotFlow {
Expand Down Expand Up @@ -734,6 +738,13 @@ fun ChatHomeScreen(
),
)
},
onPasteImage = {
val clip = context.primaryClipOrNull()
coroutineScope.launch {
val uris = clip?.imageUris(context.contentResolver).orEmpty()
if (uris.isEmpty()) showAttachFailure() else attachImageUris(uris)
}
},
)
}
}
Expand Down Expand Up @@ -1356,10 +1367,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 =
Expand Down Expand Up @@ -1479,39 +1503,53 @@ 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 &&
// 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()
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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -303,7 +321,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.yugahashimoto.andcode.feature.chat

import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.Context
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 primary clip, or null when the clipboard is empty or cannot be read. */
internal fun Context.primaryClipOrNull(): ClipData? = runCatching { clipboard()?.primaryClip }.getOrNull()

/**
* 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 ClipData.imageUris(resolver: ContentResolver): List<Uri> =
withContext(Dispatchers.IO) {
(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)
Loading
Loading