Attach any file, take shares from other apps, and survive a backwards highlight
Attachments were images only. Now any file can be attached: from the file chooser behind the "+" menu, or from Android's share sheet, which the app is now in. An image still goes to the model as a picture; anything else is stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`) and the Claude driver ends the message with `Attached file: /abs/path`, since the CLI reads files by path and a model cannot be shown a trace. The user-message field is renamed `images` -> `attachments` on both sides, with a serde alias reading the rows written before. A share arrives before anyone has said which session it is for, so it is held in AppRoot with a banner on the list until a session takes it; an open session takes it at once. Unreadable shares are reported beside the composer, not thrown. The tool card crashed the app when opened on a command holding a quoted glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers `x '*/a/*'` with a span whose end is before its start, and AnnotatedString refuses the range. Such spans are dropped; the library is the place for the fix. The echo driver gains `/bash <command>` so a card with a given command can be produced on the emulator. ui-sandbox.sh's token salvage read the tokens block's close only at a line start, ran past the compact `),],` the server writes, and copied `setups` into the new config twice, which the server then refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bc69e8f9c
commit
6180663f14
25 files changed
+672
-165
No files matched your search
@@ -61,6 +61,16 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="aiapp" android:host="enroll" />
|
||||
</intent-filter>
|
||||
<!-- The share sheet: a file, a photo or some text from another app
|
||||
lands here and is attached to a session (see Share.kt). Any
|
||||
type, because what a session can be handed is the server's
|
||||
decision rather than the sheet's. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- specialUse rather than dataSync, which is the type this looks
|
||||
|
||||
@@ -539,17 +539,24 @@ fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Uploads one picked image; the returned id goes into [sendMessage]. */
|
||||
/**
|
||||
* Uploads one attachment; the returned id goes into [sendMessage]. [name] is what the server keeps
|
||||
* a file under and tells the session; for an image it is ignored, since the model is shown the
|
||||
* picture rather than told its name.
|
||||
*/
|
||||
fun uploadAttachment(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
bytes: ByteArray,
|
||||
mime: String,
|
||||
name: String,
|
||||
): String {
|
||||
val boundary = "----aiapp-${System.currentTimeMillis()}"
|
||||
// The header is a line: a quote or a line break in the name would end it early.
|
||||
val safeName = name.replace(Regex("[\"\r\n]"), "_")
|
||||
val head =
|
||||
("--$boundary\r\n" +
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"image\"\r\n" +
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"$safeName\"\r\n" +
|
||||
"Content-Type: $mime\r\n\r\n")
|
||||
.encodeToByteArray()
|
||||
val tail = "\r\n--$boundary--\r\n".encodeToByteArray()
|
||||
|
||||
@@ -62,9 +62,16 @@ private data class FailedOpen(val request: SessionOpenRequest, val message: Stri
|
||||
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
|
||||
*
|
||||
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
|
||||
*
|
||||
* [shareRequest] is what another app shared in, likewise. It is held here until a session takes it,
|
||||
* because the share arrives before anyone has said which session it is for.
|
||||
*/
|
||||
@Composable
|
||||
fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
fun AppRoot(
|
||||
settingsVersion: Int,
|
||||
openRequest: SessionOpenRequest?,
|
||||
shareRequest: ShareRequest? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||
@@ -75,6 +82,17 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
// Bumped whenever another screen changes something the list shows, so
|
||||
// returning to it refetches instead of showing a stale list.
|
||||
var reloadToken by remember { mutableIntStateOf(0) }
|
||||
// Cleared by the session screen that attached it, not when a newer request arrives: a share
|
||||
// must be attached exactly once, and only the screen that did it knows that it has.
|
||||
var share by remember { mutableStateOf<ShareRequest?>(null) }
|
||||
LaunchedEffect(shareRequest) {
|
||||
if (shareRequest != null) {
|
||||
share = shareRequest
|
||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
||||
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
||||
if (screen !is Screen.Session) screen = Screen.Main
|
||||
}
|
||||
}
|
||||
|
||||
// A standing condition rather than a per-request failure, so it is
|
||||
// stated once here instead of appended to every error that might be
|
||||
@@ -162,6 +180,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
MainScreen(
|
||||
settings = current,
|
||||
reloadToken = reloadToken,
|
||||
share = share,
|
||||
onOpen = { screen = Screen.Session(it) },
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onImported = { imported ->
|
||||
@@ -179,7 +198,13 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
// row key. Only reachable since a notification can move straight from one session to
|
||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
||||
key(here.summary.id) {
|
||||
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
Box(Modifier.imePadding()) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
|
||||
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
|
||||
* `media` table, which is the one other place the list lives.
|
||||
*/
|
||||
fun isImageRef(ref: String): Boolean = ref.substringAfterLast('.', "") in IMAGE_EXTENSIONS
|
||||
|
||||
private val IMAGE_EXTENSIONS = setOf("png", "jpg", "gif", "webp")
|
||||
|
||||
/**
|
||||
* The name a file was attached under: the ref less the hex the server put before it. The hex has no
|
||||
* dash in it, so the first one is the boundary however many the name has.
|
||||
*/
|
||||
fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
|
||||
|
||||
/**
|
||||
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
|
||||
* file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the
|
||||
* name is the whole of it.
|
||||
*/
|
||||
@Composable
|
||||
fun Attachment(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
ref: String,
|
||||
onOpenImage: (String) -> Unit,
|
||||
) {
|
||||
if (isImageRef(ref)) SessionImage(settings, sessionId, ref, onOpenImage)
|
||||
else
|
||||
FileName(
|
||||
attachmentName(ref),
|
||||
Modifier.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(rawSurface)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
|
||||
* is identified by both ends -- what it is at the front, what kind at the back -- and either
|
||||
* ellipsis alone takes away one of them.
|
||||
*/
|
||||
@Composable
|
||||
fun FileName(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
name,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.max
|
||||
@@ -31,7 +33,66 @@ suspend fun uploadPickedImage(
|
||||
maxEdge: Int?,
|
||||
): String {
|
||||
val (bytes, mime) = readForUpload(context, uri, maxEdge)
|
||||
return uploadAttachment(settings, sessionId, bytes, mime)
|
||||
return uploadAttachment(settings, sessionId, bytes, mime, "image")
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
|
||||
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
|
||||
* it, because the session is told that name rather than shown the bytes.
|
||||
*/
|
||||
suspend fun uploadPicked(
|
||||
context: Context,
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
uri: Uri,
|
||||
maxEdge: Int?,
|
||||
): String {
|
||||
val resolver = context.contentResolver
|
||||
val mime = resolver.getType(uri)
|
||||
if (mime != null && mime.startsWith("image/")) {
|
||||
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
|
||||
}
|
||||
val bytes = readAll(resolver, uri)
|
||||
return uploadAttachment(
|
||||
settings,
|
||||
sessionId,
|
||||
bytes,
|
||||
mime ?: "application/octet-stream",
|
||||
displayName(resolver, uri),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything at [uri], or the failure as the kind the composer reports beside the message.
|
||||
*
|
||||
* A share arrives with whatever access the other app granted, and a provider that refuses says so
|
||||
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
|
||||
* are things the reader can act on, so neither is left to end the process.
|
||||
*/
|
||||
private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
|
||||
try {
|
||||
resolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: nothing there")
|
||||
} catch (e: SecurityException) {
|
||||
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: no access to it")
|
||||
} catch (e: java.io.IOException) {
|
||||
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: ${e.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a document provider shows for [uri]. The last path segment is the fallback because a
|
||||
* provider's own id for a file is usually a number, which says nothing to the session.
|
||||
*/
|
||||
private fun displayName(resolver: ContentResolver, uri: Uri): String {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (column >= 0 && cursor.moveToFirst())
|
||||
cursor.getString(column)?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment ?: "file"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,9 +105,7 @@ suspend fun uploadPickedImage(
|
||||
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
|
||||
val resolver = context.contentResolver
|
||||
val mime = resolver.getType(uri) ?: "image/jpeg"
|
||||
val original =
|
||||
resolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: throw ApiException("couldn't read the picked image")
|
||||
val original = readAll(resolver, uri)
|
||||
if (maxEdge == null) return original to mime
|
||||
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
|
||||
@@ -30,14 +30,15 @@ sealed class SessionEvent {
|
||||
*/
|
||||
val id: String?,
|
||||
/**
|
||||
* What was attached to it, by the ref the files route serves.
|
||||
* What was attached to it, by the ref the files route serves: images, and since 2026-09-03
|
||||
* any file, told apart by [isImageRef].
|
||||
*
|
||||
* On the message rather than beside it: these arrived as separate image events until
|
||||
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
|
||||
* it, and left this app deciding from adjacency alone which message an image went with --
|
||||
* something the sender knew and could simply have said.
|
||||
*/
|
||||
val images: List<String>,
|
||||
val attachments: List<String>,
|
||||
) : SessionEvent()
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,7 @@ sealed class SessionEvent {
|
||||
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
|
||||
* [CommandSent].
|
||||
*/
|
||||
data class MessageQueued(val id: String, val text: String, val images: List<String>) :
|
||||
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
|
||||
SessionEvent()
|
||||
|
||||
/**
|
||||
@@ -197,13 +198,13 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
SessionEvent.UserMessage(
|
||||
body.getString("text"),
|
||||
body.optString("id").ifEmpty { null },
|
||||
body.stringList("images"),
|
||||
body.stringList("attachments"),
|
||||
)
|
||||
"messageQueued" ->
|
||||
SessionEvent.MessageQueued(
|
||||
body.getString("id"),
|
||||
body.getString("text"),
|
||||
body.stringList("images"),
|
||||
body.stringList("attachments"),
|
||||
)
|
||||
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
||||
|
||||
@@ -37,6 +37,10 @@ class MainActivity : ComponentActivity() {
|
||||
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
|
||||
private var opens = 0
|
||||
|
||||
// What another app shared into this one, for the same reason and with the same serial.
|
||||
private var shareRequest by mutableStateOf<ShareRequest?>(null)
|
||||
private var shares = 0
|
||||
|
||||
// Registered up front since permission launchers must be registered
|
||||
// before the activity reaches STARTED.
|
||||
private val requestLocalNetworkPermission =
|
||||
@@ -139,7 +143,7 @@ class MainActivity : ComponentActivity() {
|
||||
// scoped to what actually moves.
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
AppRoot(settingsVersion, openRequest)
|
||||
AppRoot(settingsVersion, openRequest, shareRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,14 +159,21 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place an incoming `aiapp://` URI is sorted into what it means.
|
||||
* The one place an incoming intent is sorted into what it means.
|
||||
*
|
||||
* Two things arrive this way -- an enrollment code and a notification naming a session -- and
|
||||
* they are told apart by the URI's host rather than by two entry points, so a third kind is a
|
||||
* branch here rather than another intent to remember to handle.
|
||||
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
|
||||
* either an enrollment code or a notification naming a session. The URIs are told apart by host
|
||||
* rather than by two entry points, so a further kind is a branch here rather than another
|
||||
* intent to remember to handle.
|
||||
*/
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
intent ?: return
|
||||
sharedContent(intent, shares + 1)?.let { shared ->
|
||||
shares = shared.serial
|
||||
shareRequest = shared
|
||||
return
|
||||
}
|
||||
val uri = intent.data ?: return
|
||||
val sessionId = notifiedSessionId(uri)
|
||||
if (sessionId != null) {
|
||||
opens++
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -46,6 +47,8 @@ private enum class MainTab(val label: String) {
|
||||
fun MainScreen(
|
||||
settings: ServerSettings,
|
||||
reloadToken: Int,
|
||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
||||
share: ShareRequest? = null,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
onImported: (SessionSummary) -> Unit,
|
||||
@@ -106,6 +109,24 @@ fun MainScreen(
|
||||
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
|
||||
}
|
||||
}
|
||||
// What is waiting to be attached, and what to do about it. Said here because the list
|
||||
// below is where the choice is made, and a share that arrived with nothing on screen
|
||||
// saying so would read as a tap that did nothing.
|
||||
share?.let {
|
||||
Text(
|
||||
it.summary() + " -- open the session it belongs in.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.shapes.small,
|
||||
)
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Primary rather than the plain TabRow, which is deprecated in favour of the two that
|
||||
// say where they sit: these are the app's top-level destinations.
|
||||
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
|
||||
|
||||
@@ -8,8 +8,12 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -34,7 +38,8 @@ import androidx.compose.ui.unit.sp
|
||||
* anywhere else on the screen.
|
||||
*
|
||||
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
|
||||
* in it, so four attachments look like four of the same thing rather than four smaller ones.
|
||||
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file
|
||||
* is a tile of the same height carrying its name, since a name is all there is to show of it.
|
||||
*/
|
||||
@Composable
|
||||
fun PendingAttachments(
|
||||
@@ -49,7 +54,10 @@ fun PendingAttachments(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
refs.forEach { ref -> PendingThumbnail(settings, sessionId, ref) { onRemove(ref) } }
|
||||
refs.forEach { ref ->
|
||||
if (isImageRef(ref)) PendingThumbnail(settings, sessionId, ref) { onRemove(ref) }
|
||||
else PendingFile(ref) { onRemove(ref) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +130,32 @@ private fun PendingThumbnail(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One attached file: its name, tap to take it back off. The same height and removal as a thumbnail,
|
||||
* so a row of mixed attachments is one row; the cross sits after the name because a tile this wide
|
||||
* has no corner the eye goes to.
|
||||
*/
|
||||
@Composable
|
||||
private fun PendingFile(ref: String, onRemove: () -> Unit) {
|
||||
val name = attachmentName(ref)
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Row(
|
||||
Modifier.height(THUMBNAIL)
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onRemove)
|
||||
.semantics { contentDescription = "Attached file $name, tap to remove" }
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FileName(name, Modifier.widthIn(max = FILE_TILE_WIDTH))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
private val THUMBNAIL = 64.dp
|
||||
|
||||
/** Wide enough for most names whole; longer ones lose their middle, keeping both ends. */
|
||||
private val FILE_TILE_WIDTH = 200.dp
|
||||
@@ -2,6 +2,7 @@ package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
@@ -228,7 +229,15 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
|
||||
// self-correction needs it.
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
||||
fun SessionScreen(
|
||||
settings: ServerSettings,
|
||||
summary: SessionSummary,
|
||||
onBack: () -> Unit,
|
||||
/** What another app shared in while this session is the one open; see [ShareRequest]. */
|
||||
share: ShareRequest? = null,
|
||||
/** Said once [share] has been attached here, so it is not attached again. */
|
||||
onShareTaken: () -> Unit = {},
|
||||
) {
|
||||
DebugStats.count("session screen recomposed")
|
||||
val scope = rememberCoroutineScope()
|
||||
val topEdgeHeld = remember { TopEdgeHold() }
|
||||
@@ -410,7 +419,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// Waiting, then read. Matched by id: the same message sent twice is two
|
||||
// bubbles, and clearing by text would take away whichever matched first.
|
||||
if (event is SessionEvent.MessageQueued) {
|
||||
queued = queued + QueuedMessage(event.id, event.text, event.images)
|
||||
queued = queued + QueuedMessage(event.id, event.text, event.attachments)
|
||||
}
|
||||
if (event is SessionEvent.UserMessage) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
@@ -1076,34 +1085,45 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
act { sendMessage(settings, summary.id, text, attachments) }
|
||||
}
|
||||
|
||||
// The system photo picker; the image uploads as soon as it's chosen,
|
||||
// so Send only has ids to reference.
|
||||
val pickImage =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val id =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Shrunk to what this session's provider takes before it is
|
||||
// uploaded, so a twelve-megapixel photo does not cross the tunnel
|
||||
// to be rejected at the far end -- see `uploadPickedImage`.
|
||||
uploadPickedImage(
|
||||
context,
|
||||
settings,
|
||||
summary.id,
|
||||
uri,
|
||||
summary.maxImageEdge,
|
||||
)
|
||||
}
|
||||
pendingAttachments = pendingAttachments + id
|
||||
actionError = null
|
||||
} catch (e: ApiException) {
|
||||
actionError = e.message
|
||||
// One path for everything attached, however it arrived: the photo picker, the file chooser
|
||||
// or another app's share sheet. It uploads as soon as it is chosen, so Send only has ids to
|
||||
// reference.
|
||||
fun attach(uri: Uri) {
|
||||
scope.launch {
|
||||
try {
|
||||
val id =
|
||||
withContext(Dispatchers.IO) {
|
||||
// An image is shrunk to what this session's provider takes before it is
|
||||
// uploaded, so a twelve-megapixel photo does not cross the tunnel to be
|
||||
// rejected at the far end; a file goes whole -- see `uploadPicked`.
|
||||
uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge)
|
||||
}
|
||||
}
|
||||
pendingAttachments = pendingAttachments + id
|
||||
actionError = null
|
||||
} catch (e: ApiException) {
|
||||
actionError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
val pickImage =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
uri?.let(::attach)
|
||||
}
|
||||
val pickFile =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri?.let(::attach)
|
||||
}
|
||||
// What another app shared in, attached the moment this screen has it. Taken off the request
|
||||
// first, so a recomposition or a return to this screen cannot attach it a second time.
|
||||
LaunchedEffect(share) {
|
||||
val incoming = share ?: return@LaunchedEffect
|
||||
onShareTaken()
|
||||
incoming.uris.forEach(::attach)
|
||||
incoming.text?.let { shared ->
|
||||
input = if (input.isBlank()) shared else input + "\n" + shared
|
||||
saveDraft(context, summary.id, input)
|
||||
}
|
||||
}
|
||||
|
||||
// One poll for this machine's limits, read by the two things that show them: the bar under
|
||||
// the header, and the colour of the button that opens the dialog.
|
||||
@@ -1333,7 +1353,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
text = waiting.text,
|
||||
images = waiting.images,
|
||||
attachments = waiting.attachments,
|
||||
onOpenImage = ::openImage,
|
||||
pending = true,
|
||||
refusal = waiting.refusal,
|
||||
@@ -1470,7 +1490,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
text = item.text,
|
||||
images = item.images,
|
||||
attachments = item.attachments,
|
||||
onOpenImage = ::openImage,
|
||||
)
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
@@ -1727,17 +1747,38 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pickImage.launch(
|
||||
PickVisualMediaRequest(
|
||||
ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||
)
|
||||
// Photo or file, asked here rather than by two buttons: the row is full,
|
||||
// and attaching is one action whichever picker answers it.
|
||||
var attaching by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
// Just "+". The count it used to carry was standing in for showing them.
|
||||
TextButton(onClick = { attaching = true }) { Text("+") }
|
||||
DropdownMenu(
|
||||
expanded = attaching,
|
||||
onDismissRequest = { attaching = false },
|
||||
// See PickerButton: without this the menu opens a status bar's
|
||||
// height away from the button in an edge-to-edge activity.
|
||||
properties = PopupProperties(clippingEnabled = false),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Photo") },
|
||||
onClick = {
|
||||
attaching = false
|
||||
pickImage.launch(
|
||||
PickVisualMediaRequest(
|
||||
ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("File") },
|
||||
onClick = {
|
||||
attaching = false
|
||||
pickFile.launch(arrayOf("*/*"))
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Just "+" now. The count was standing in for showing them.
|
||||
Text("+")
|
||||
}
|
||||
// The settings share what is left after the actions have
|
||||
// taken what they need. A Row hands out intrinsic widths in
|
||||
@@ -1929,9 +1970,9 @@ private fun UserChunkRow(
|
||||
Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
// The same arrangement [UserBubble] gives them: under the words, on the last slice
|
||||
// because that is the bubble's bottom.
|
||||
unit.images.forEachIndexed { index, ref ->
|
||||
unit.attachments.forEachIndexed { index, ref ->
|
||||
if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
||||
SessionImage(settings, sessionId, ref, onOpenImage)
|
||||
Attachment(settings, sessionId, ref, onOpenImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1957,7 +1998,7 @@ private fun UserBubble(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
text: String,
|
||||
images: List<String> = emptyList(),
|
||||
attachments: List<String> = emptyList(),
|
||||
onOpenImage: (String) -> Unit,
|
||||
pending: Boolean = false,
|
||||
refusal: String? = null,
|
||||
@@ -2003,9 +2044,9 @@ private fun UserBubble(
|
||||
// Under the words: what somebody wrote is what the bubble is, and the picture is
|
||||
// what they attached to it. It also keeps the first line of every bubble at the
|
||||
// same place down the transcript, whether or not there is an image in it.
|
||||
images.forEachIndexed { index, ref ->
|
||||
attachments.forEachIndexed { index, ref ->
|
||||
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
||||
SessionImage(settings, sessionId, ref, onOpenImage)
|
||||
Attachment(settings, sessionId, ref, onOpenImage)
|
||||
}
|
||||
refusal?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
@@ -2030,7 +2071,7 @@ private fun UserBubble(
|
||||
private data class QueuedMessage(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val images: List<String>,
|
||||
val attachments: List<String>,
|
||||
val refusal: String? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.IntentCompat
|
||||
|
||||
/**
|
||||
* What another app handed this one through the share sheet, waiting to be attached to a session.
|
||||
*
|
||||
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
|
||||
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
|
||||
* requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose.
|
||||
*/
|
||||
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
|
||||
|
||||
/** The share in [intent], or null when it is some other intent. */
|
||||
fun sharedContent(intent: Intent, serial: Int): ShareRequest? {
|
||||
val uris =
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND ->
|
||||
listOfNotNull(
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
)
|
||||
Intent.ACTION_SEND_MULTIPLE ->
|
||||
IntentCompat.getParcelableArrayListExtra(
|
||||
intent,
|
||||
Intent.EXTRA_STREAM,
|
||||
Uri::class.java,
|
||||
)
|
||||
.orEmpty()
|
||||
else -> return null
|
||||
}
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
|
||||
if (uris.isEmpty() && text == null) return null
|
||||
return ShareRequest(uris, text, serial)
|
||||
}
|
||||
|
||||
/** What is waiting, for the banner that says so. */
|
||||
fun ShareRequest.summary(): String =
|
||||
when {
|
||||
uris.size == 1 -> "1 file to attach"
|
||||
uris.isNotEmpty() -> "${uris.size} files to attach"
|
||||
else -> "Text to attach"
|
||||
}
|
||||
@@ -160,6 +160,15 @@ private fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedStrin
|
||||
Highlights.Builder(code = code, language = language, theme = theme)
|
||||
.build()
|
||||
.getHighlights()
|
||||
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a
|
||||
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is
|
||||
// before its start, and AnnotatedString refuses such a range. That crashed the
|
||||
// app the moment a card holding `-path '*/.git/*'` was opened. Dropped rather
|
||||
// than clamped: a span the lexer got backwards is not one it knows the colour
|
||||
// of. Delete when snipme/highlights fixes it.
|
||||
.filter {
|
||||
it.location.start in 0..it.location.end && it.location.end <= code.length
|
||||
}
|
||||
buildAnnotatedString {
|
||||
append(code)
|
||||
marks.forEach { mark ->
|
||||
|
||||
@@ -42,7 +42,7 @@ sealed class TranscriptItem {
|
||||
override val seq: Long,
|
||||
val text: String,
|
||||
/** Refs of what was attached, drawn inside the bubble. */
|
||||
val images: List<String> = emptyList(),
|
||||
val attachments: List<String> = emptyList(),
|
||||
) : TranscriptItem()
|
||||
|
||||
data class AssistantMsg(
|
||||
@@ -375,7 +375,7 @@ private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<Transcri
|
||||
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
|
||||
when (val event = entry.event) {
|
||||
is SessionEvent.UserMessage ->
|
||||
items + TranscriptItem.UserMsg(entry.seq, event.text, event.images)
|
||||
items + TranscriptItem.UserMsg(entry.seq, event.text, event.attachments)
|
||||
is SessionEvent.AssistantText -> {
|
||||
// Deltas accumulate into the message they're streaming, which keeps the seq of the
|
||||
// first of them: a row whose identity changed with every delta would be a new row on
|
||||
|
||||
@@ -129,7 +129,7 @@ sealed class TranscriptUnit {
|
||||
val first: Boolean,
|
||||
val last: Boolean,
|
||||
/** The message's attachments, drawn under the words -- so only the last slice has any. */
|
||||
val images: List<String>,
|
||||
val attachments: List<String>,
|
||||
override val gap: Dp,
|
||||
) : TranscriptUnit() {
|
||||
override val key: Any
|
||||
@@ -211,7 +211,7 @@ fun transcriptUnits(
|
||||
chunk,
|
||||
first = at == 0,
|
||||
last = at == chunks.lastIndex,
|
||||
images = if (at == chunks.lastIndex) item.images else emptyList(),
|
||||
attachments = if (at == chunks.lastIndex) item.attachments else emptyList(),
|
||||
gap = if (at == 0) rowGap else 0.dp,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user