git.iko.soy/opencode-android
on 06fd41b help sign in
115 lines 5.6 kB history raw
1package soy.iko.opencode.ui.chat
3import android.content.Context
4import android.net.Uri
5import android.provider.OpenableColumns
6import android.util.Base64
7import androidx.core.content.FileProvider
8import soy.iko.opencode.data.model.FilePromptPart
9import soy.iko.opencode.data.network.NetworkConfig
10import soy.iko.opencode.util.runCatchingCancellable
11import kotlinx.coroutines.Dispatchers
12import kotlinx.coroutines.withContext
13import java.io.File
15/**
16 * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
17 * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
18 * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
19 */
20suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
21 val resolver = context.contentResolver
22 val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
23 val name = displayName(context) ?: defaultName(mime)
24 val max = NetworkConfig.maxAttachmentBytes
25 // Cheap pre-check: if the provider reports a size, reject an oversize file before reading
26 // a single byte into memory. The picker can hand back arbitrarily large files (e.g. a
27 // multi-hundred-MB video), so reading first and checking after would OOM the app.
28 val declaredSize = fileSize(context)
29 if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
30 // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
31 // us: stop as soon as we've read one byte past the cap, then reject.
32 val bytes = runCatchingCancellable {
33 resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
34 }.getOrNull() ?: return@withContext AttachmentResult.Failed
35 if (bytes.size > max) return@withContext AttachmentResult.TooLarge
36 val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
37 val dataUrl = "data:$mime;base64,$b64"
38 AttachmentResult.Ok(
39 PendingAttachment(
40 id = java.util.UUID.randomUUID().toString(),
41 name = name,
42 mime = mime,
43 previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
44 part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
45 ),
46 )
49/** The provider-reported byte size of the content [uri], or null if it isn't exposed. */
50private fun Uri.fileSize(context: Context): Long? = runCatching {
51 context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
52 val idx = cursor.getColumnIndex(OpenableColumns.SIZE)
53 if (idx >= 0 && cursor.moveToFirst() && !cursor.isNull(idx)) cursor.getLong(idx) else null
54 }
55}.getOrNull()
57/** Read up to [limit] bytes from the stream without materializing the whole file, so an
58 * oversize source can be rejected on a bounded amount of memory. Reaching [limit] bytes is
59 * the caller's signal that the source exceeds the cap. */
60private fun java.io.InputStream.readAtMost(limit: Long): ByteArray {
61 val out = java.io.ByteArrayOutputStream()
62 val chunk = ByteArray(8 * 1024)
63 var total = 0L
64 while (total < limit) {
65 val want = minOf(chunk.size.toLong(), limit - total).toInt()
66 val read = read(chunk, 0, want)
67 if (read < 0) break
68 out.write(chunk, 0, read)
69 total += read
70 }
71 return out.toByteArray()
74/** The raw (pre-base64) byte size encoded in a `data:<mime>;base64,<payload>` URL, derived from
75 * the payload length (len / 4 * 3, less any '=' padding) without allocating or decoding the
76 * payload — important since the payload can be many MB. Returns 0 when the URL has no base64
77 * payload. Lets the cumulative attachment-size cap use the same raw-byte measure as the per-file
78 * [NetworkConfig.maxAttachmentBytes] check above (which counts raw `bytes.size`). */
79internal fun base64DataUrlByteSize(dataUrl: String): Long {
80 val marker = "base64,"
81 val start = dataUrl.indexOf(marker)
82 if (start < 0) return 0L
83 val payloadLen = dataUrl.length - (start + marker.length)
84 if (payloadLen <= 0) return 0L
85 val padding = when {
86 dataUrl.endsWith("==") -> 2
87 dataUrl.endsWith("=") -> 1
88 else -> 0
89 }
90 return payloadLen.toLong() / 4 * 3 - padding
93/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
94private fun Uri.displayName(context: Context): String? = runCatching {
95 context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
96 val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
97 if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
98 }
99}.getOrNull()?.takeIf { it.isNotBlank() }
101private fun defaultName(mime: String): String {
102 val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
103 return "attachment.$ext"
106/**
107 * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
108 * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
109 * created. The file is overwritten on each capture; the OS cleans the cache dir.
110 */
111fun newCameraCaptureUri(context: Context): Uri? = runCatching {
112 val dir = File(context.cacheDir, "captures").apply { mkdirs() }
113 val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
114 FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
115}.getOrNull()