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
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user