1package soy.iko.opencode.ui.chat
3import android.content.Context
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
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).
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"
40 id = java.util.UUID.randomUUID().toString(),
43 previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
44 part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
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
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)
64 while (total < limit) {
65 val want = minOf(chunk.size.toLong(), limit - total).toInt()
66 val read = read(chunk, 0, want)
68 out.write(chunk, 0, read)
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
86 dataUrl.endsWith("==") -> 2
87 dataUrl.endsWith("=") -> 1
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
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"
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.
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)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list