git.iko.soy/opencode-android
on 2794fda help sign in
577 lines 29 kB history raw
1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.gestures.detectTapGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
8import androidx.compose.foundation.gestures.detectVerticalDragGestures
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
15import androidx.compose.foundation.layout.navigationBars
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.statusBars
18import androidx.compose.foundation.layout.windowInsetsPadding
19import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.Icon
21import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Immutable
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableFloatStateOf
27import androidx.compose.runtime.mutableIntStateOf
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.produceState
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.rememberCoroutineScope
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.graphics.graphicsLayer
37import androidx.compose.ui.input.pointer.pointerInput
38import androidx.compose.ui.layout.ContentScale
39import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.invisibleToUser
44import androidx.compose.ui.semantics.semantics
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.compose.ui.window.Dialog
48import androidx.compose.ui.window.DialogProperties
49import coil3.compose.SubcomposeAsyncImage
50import coil3.network.NetworkHeaders
51import coil3.network.httpHeaders
52import coil3.request.ImageRequest
53import coil3.request.crossfade
54import coil3.toBitmap
55import kotlinx.coroutines.Dispatchers
56import kotlinx.coroutines.launch
57import kotlin.math.absoluteValue
58import androidx.compose.animation.core.Animatable
59import kotlinx.coroutines.withContext
60import androidx.compose.material.icons.Icons
61import androidx.compose.material.icons.filled.BrokenImage
62import androidx.compose.material.icons.filled.Close
63import androidx.compose.material.icons.filled.MoreVert
64import soy.iko.opencode.R
65import soy.iko.opencode.data.model.FilePart
66import soy.iko.opencode.data.model.ServerProfile
67import soy.iko.opencode.data.model.sourcePath
68import soy.iko.opencode.data.network.CertPinUtil
69import soy.iko.opencode.data.network.HttpClientFactory
70import soy.iko.opencode.data.network.NetworkConfig
71import soy.iko.opencode.util.runCatchingCancellable
73/**
74 * Carries the bits needed to load an image off the opencode server: the base URL (for
75 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
76 */
77@Immutable
78data class ImageLoadContext(
79 val baseUrl: String,
80 val basicAuthHeader: String?,
83/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
84fun ServerProfile.toImageContext(): ImageLoadContext {
85 val auth = if (hasAuth && !password.isNullOrEmpty()) {
86 val raw = "$username:$password".toByteArray()
87 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
88 } else {
89 null
90 }
91 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
92 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
93 // header attached — over cleartext http even when the stored URL is http://. This mirrors
94 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
95 return ImageLoadContext(baseUrl = CertPinUtil.effectiveBaseUrl(this), basicAuthHeader = auth)
98/** True if the part references a raster/vector image we can render. */
99val FilePart.isImage: Boolean
100 get() = mime?.startsWith("image/") == true
102private fun decodeDataUri(data: String): ByteArray? {
103 val comma = data.indexOf(',')
104 if (comma < 0) return null
105 val header = data.substring(0, comma)
106 val payload = data.substring(comma + 1)
107 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
108 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
109 // which would throw and render the broken-image icon.
110 return runCatching {
111 if (header.contains(";base64", ignoreCase = true)) {
112 Base64.decode(payload, Base64.DEFAULT)
113 } else {
114 android.net.Uri.decode(payload).toByteArray()
115 }
116 }.getOrNull()
119/**
120 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
121 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
122 */
123private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
124 val src = sourcePath
125 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
126 val url = url ?: return null
127 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
128 if (url.startsWith("data:")) return decodeDataUri(url)
129 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
130 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
131 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
132 // otherwise get the server's Basic auth attached and leak the credentials to that host.
133 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
134 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
135 val resolved = runCatching {
136 java.net.URI(base).resolve(url).normalize()
137 }.getOrElse { return null }
138 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
139 // sending the request (with its Basic auth) to another host. A server-absolute path
140 // resolves to a different base *path* but the same origin, so it must still be allowed.
141 // Compare *effective* ports (substituting the scheme default when unspecified) so an
142 // absolute image URL that spells out the default port — "https://host:443/x.png" while
143 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
144 // when omitted, so a raw `==` would treat 443 and -1 as different).
145 val sameOrigin = resolved.host != null &&
146 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
147 resolved.host.equals(baseUri.host, ignoreCase = true) &&
148 effectivePort(resolved) == effectivePort(baseUri)
149 return if (sameOrigin) resolved.toString() else null
152/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
153private fun effectivePort(uri: java.net.URI): Int {
154 if (uri.port != -1) return uri.port
155 return when (uri.scheme?.lowercase()) {
156 "https" -> 443
157 "http" -> 80
158 else -> -1
159 }
162/**
163 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
164 * and server-relative URLs (fetched with Basic auth when the server requires it).
165 * Falls back to nothing when the part has no loadable source.
166 */
167@Composable
168fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
169 val context = LocalContext.current
170 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
171 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
172 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
173 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
174 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
175 // FileChip to fall through to).
176 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
177 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
178 }.value
179 if (model === ImageResolving) return
180 if (model == null) {
181 ImageStatusBox { BrokenImageIcon() }
182 return
183 }
184 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
185 // the error state instead of being a permanent broken-image icon.
186 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
187 // Fullscreen zoomable viewer state: opened by tapping the inline image.
188 var showFullscreen by remember { mutableStateOf(false) }
189 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
190 ImageRequest.Builder(context)
191 .data(model)
192 .apply {
193 // resolveModel already verified same-origin, so a String model can only point at
194 // the user's own server — attaching Basic auth over http (not just https) is safe.
195 // A data-URI model is a ByteArray, so gate on the model being a URL String.
196 if (model is String) ctx.basicAuthHeader?.let {
197 httpHeaders(NetworkHeaders.Builder().add("Authorization", it).build())
198 }
199 }
200 .crossfade(true)
201 .build()
202 }
203 SubcomposeAsyncImage(
204 model = request,
205 contentDescription = part.filename ?: stringResource(R.string.image),
206 contentScale = ContentScale.FillWidth,
207 modifier = modifier
208 .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
209 .clip(MaterialTheme.shapes.small)
210 // Tap to open a fullscreen zoomable viewer so the user can inspect details
211 // without the inline 320dp height cap.
212 .clickable(role = Role.Button) { showFullscreen = true },
213 loading = {
214 Box(
215 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
216 contentAlignment = Alignment.Center,
217 ) {
218 val loadingLabel = stringResource(R.string.loading)
219 CircularProgressIndicator(
220 modifier = Modifier.semantics { contentDescription = loadingLabel },
221 )
222 }
223 },
224 error = { ImageRetry(onRetry = { retryKey++ }) },
225 )
226 if (showFullscreen) {
227 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
228 showFullscreen = false
229 }
230 }
233/**
234 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
235 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
236 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
237 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
238 */
239@Composable
240internal fun FullscreenImageViewer(
241 request: ImageRequest,
242 contentDescription: String,
243 onDismiss: () -> Unit,
244) {
245 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
246 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
247 val scale = remember { Animatable(1f) }
248 var offsetX by remember { mutableFloatStateOf(0f) }
249 var offsetY by remember { mutableFloatStateOf(0f) }
250 val scope = rememberCoroutineScope()
251 val context = LocalContext.current
252 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
253 // the composable body (not inside the pointerInput coroutine) since it reads the
254 // composition's LocalReducedMotion.
255 val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
256 // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
257 // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
258 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
259 // a continuous motion rather than a snap.
260 var dragOffsetY by remember { mutableFloatStateOf(0f) }
261 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
262 var showOverflow by remember { mutableStateOf(false) }
263 val saveLabel = stringResource(R.string.save_image)
264 val shareLabel = stringResource(R.string.share_image)
265 val savedLabel = stringResource(R.string.image_saved)
266 val saveFailedLabel = stringResource(R.string.image_save_failed)
267 Dialog(
268 onDismissRequest = onDismiss,
269 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
270 ) {
271 Box(
272 modifier = Modifier
273 .fillMaxSize()
274 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
275 .pointerInput(Unit) {
276 detectTransformGestures { _, pan, zoom, _ ->
277 // Clamp the zoom so the image can't be scaled down below 1× (which would
278 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
279 val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
280 scope.launch { scale.snapTo(newScale) }
281 if (newScale <= 1f) {
282 // Returning to 1× recenters so a prior pan doesn't leave the image
283 // stuck off-center at its rest size.
284 offsetX = 0f
285 offsetY = 0f
286 } else {
287 offsetX += pan.x
288 offsetY += pan.y
289 }
290 }
291 }
292 // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
293 // vertical drag accumulates an offset; crossing the threshold dismisses.
294 .pointerInput(scale.value) {
295 if (scale.value <= 1.001f) {
296 detectVerticalDragGestures(
297 onDragEnd = {
298 if (dragOffsetY.absoluteValue >= dismissThreshold) {
299 onDismiss()
300 } else {
301 // Animate the snap-back to rest. A simple reset; the user's
302 // finger is already lifting so a full spring isn't needed.
303 dragOffsetY = 0f
304 }
305 },
306 onVerticalDrag = { _, dragAmount ->
307 // Only accumulate downward drags (positive Y) for dismiss, so an
308 // upward swipe doesn't accidentally trigger it.
309 if (dragAmount > 0) dragOffsetY += dragAmount
310 },
311 )
312 }
313 }
314 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
315 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
316 // defers the single-tap callback until it knows the touch isn't the first of a pair.
317 .pointerInput(Unit) {
318 detectTapGestures(
319 onDoubleTap = {
320 scope.launch {
321 if (scale.value > 1.001f) {
322 offsetX = 0f
323 offsetY = 0f
324 scale.animateTo(1f, zoomSpec)
325 } else {
326 scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
327 }
328 }
329 },
330 onTap = { onDismiss() },
331 )
332 },
333 contentAlignment = Alignment.Center,
334 ) {
335 SubcomposeAsyncImage(
336 model = request,
337 contentDescription = contentDescription,
338 contentScale = ContentScale.Fit,
339 modifier = Modifier
340 .fillMaxSize()
341 .graphicsLayer(
342 scaleX = scale.value,
343 scaleY = scale.value,
344 translationX = offsetX,
345 // Apply the swipe-drag offset on top of any pan offset so the image
346 // follows the finger during a dismiss swipe.
347 translationY = offsetY + dragOffsetY,
348 ),
349 )
350 // Close button in the top corner, inset below the status bar so it doesn't sit
351 // under the notch / status bar overlay on edge-to-edge devices.
352 androidx.compose.material3.IconButton(
353 onClick = onDismiss,
354 modifier = Modifier
355 .align(Alignment.TopEnd)
356 .windowInsetsPadding(WindowInsets.statusBars)
357 .padding(end = 8.dp),
358 ) {
359 Icon(
360 Icons.Filled.Close,
361 contentDescription = stringResource(R.string.close),
362 tint = androidx.compose.ui.graphics.Color.White,
363 )
364 }
365 // Overflow with Save-to-gallery and Share. Save decodes the request's bytes via
366 // Coil and writes them to MediaStore (Pictures/opencode); Share fires ACTION_SEND
367 // with a content Uri. Both run off the main thread on the coroutine scope.
368 Box(
369 modifier = Modifier
370 .align(Alignment.TopStart)
371 .windowInsetsPadding(WindowInsets.statusBars)
372 .padding(start = 8.dp),
373 ) {
374 androidx.compose.material3.IconButton(onClick = { showOverflow = true }) {
375 Icon(
376 Icons.Filled.MoreVert,
377 contentDescription = stringResource(R.string.more_options),
378 tint = androidx.compose.ui.graphics.Color.White,
379 )
380 }
381 androidx.compose.material3.DropdownMenu(
382 expanded = showOverflow,
383 onDismissRequest = { showOverflow = false },
384 ) {
385 androidx.compose.material3.DropdownMenuItem(
386 text = { Text(saveLabel) },
387 onClick = {
388 showOverflow = false
389 scope.launch {
390 val ok = runCatchingCancellable { saveImageToGallery(context, request, contentDescription) }.isSuccess
391 showToast(context, if (ok) savedLabel else saveFailedLabel)
392 }
393 },
394 )
395 androidx.compose.material3.DropdownMenuItem(
396 text = { Text(shareLabel) },
397 onClick = {
398 showOverflow = false
399 scope.launch {
400 runCatchingCancellable { shareImage(context, request, contentDescription) }
401 .onFailure { showToast(context, saveFailedLabel) }
402 }
403 },
404 )
405 }
406 }
407 // Show the alt text / filename as a caption at the bottom of the viewer so a user
408 // can tell what the image is (e.g. an agent-named diagram) without leaving fullscreen.
409 // The contentDescription is already used by the image itself for TalkBack; the caption
410 // is hidden from a11y here to avoid double-announcing.
411 if (contentDescription.isNotBlank() && contentDescription != stringResource(R.string.image)) {
412 Box(
413 modifier = Modifier
414 .align(Alignment.BottomCenter)
415 .fillMaxWidth()
416 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
417 .windowInsetsPadding(WindowInsets.navigationBars)
418 .padding(horizontal = 16.dp, vertical = 8.dp),
419 ) {
420 Text(
421 contentDescription,
422 color = androidx.compose.ui.graphics.Color.White,
423 style = MaterialTheme.typography.bodySmall,
424 maxLines = 2,
425 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
426 modifier = Modifier.semantics { invisibleToUser() },
427 )
428 }
429 }
430 // Zoom-level indicator: a small chip at the top-start showing the current zoom
431 // factor (e.g. "2.5×") whenever the image is zoomed beyond 1×, with a tap action
432 // to reset to 1×. Aids orientation when deep-zoomed and gives a one-tap reset
433 // (otherwise only a double-tap toggles between 1× and the zoom target).
434 if (scale.value > 1.01f) {
435 val zoomText = remember(scale.value) {
436 String.format(java.util.Locale.US, "%.1f×", scale.value)
437 }
438 val resetLabel = stringResource(R.string.reset_zoom)
439 androidx.compose.material3.Surface(
440 color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
441 shape = MaterialTheme.shapes.small,
442 modifier = Modifier
443 .align(Alignment.TopStart)
444 .padding(12.dp)
445 .windowInsetsPadding(WindowInsets.statusBars)
446 .clickable(role = Role.Button) {
447 scope.launch { scale.animateTo(1f, zoomSpec) }
448 offsetX = 0f
449 offsetY = 0f
450 }
451 .semantics { this.contentDescription = "$zoomText, $resetLabel" },
452 ) {
453 Text(
454 zoomText,
455 color = androidx.compose.ui.graphics.Color.White,
456 style = MaterialTheme.typography.labelSmall,
457 modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
458 )
459 }
460 }
461 }
462 }
465/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
466 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
467@Composable
468private fun ImageRetry(onRetry: () -> Unit) {
469 val label = stringResource(R.string.tap_to_retry)
470 Box(
471 modifier = Modifier
472 .fillMaxWidth()
473 .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
474 .clickable(role = Role.Button, onClick = onRetry),
475 contentAlignment = Alignment.Center,
476 ) {
477 Column(horizontalAlignment = Alignment.CenterHorizontally) {
478 BrokenImageIcon()
479 Text(
480 label,
481 style = MaterialTheme.typography.labelSmall,
482 color = MaterialTheme.colorScheme.onSurfaceVariant,
483 modifier = Modifier.padding(top = 4.dp),
484 )
485 }
486 }
489/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
490 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
491private val ImageResolving = Any()
493/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
494 * reserves the same space whether it's loading, failed, or unresolvable. */
495@Composable
496private fun ImageStatusBox(content: @Composable () -> Unit) {
497 Box(
498 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
499 contentAlignment = Alignment.Center,
500 ) { content() }
503@Composable
504private fun BrokenImageIcon() {
505 Icon(
506 Icons.Filled.BrokenImage,
507 contentDescription = stringResource(R.string.image_failed),
508 tint = MaterialTheme.colorScheme.onSurfaceVariant,
509 )
512/** Decode the image behind [request] via Coil and write it to the system MediaStore
513 * (Pictures/opencode) so it appears in the gallery. Returns true on success. Runs on a
514 * background dispatcher; callers should use runCatchingCancellable. */
515private suspend fun saveImageToGallery(
516 context: android.content.Context,
517 request: ImageRequest,
518 displayName: String,
519): Boolean {
520 val bitmap = decodeRequestBitmap(context, request) ?: return false
521 val resolver = context.contentResolver
522 val name = (displayName.ifBlank { "opencode-image" }).take(100)
523 val timestamp = System.currentTimeMillis() / 1000
524 val values = android.content.ContentValues().apply {
525 put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "$name-${timestamp}.png")
526 put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
527 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
528 put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/opencode")
529 put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
530 }
531 }
532 val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
533 ?: return false
534 return runCatching {
535 resolver.openOutputStream(uri)?.use { out ->
536 bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
537 } ?: error("no output stream")
538 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
539 values.clear()
540 values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
541 resolver.update(uri, values, null, null)
542 }
543 true
544 }.getOrDefault(false)
547/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
548 * failure. Suspending so callers run it on a background dispatcher. */
549private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
550 return soy.iko.opencode.util.runCatchingCancellableSuspend {
551 val loader = coil3.SingletonImageLoader.get(context)
552 val result = loader.execute(request)
553 result.image?.toBitmap()
554 }.getOrNull()
557/** Share the image behind [request] via ACTION_SEND. Writes a temp file to the cache dir and
558 * grants the chooser read access via FileProvider. */
559private suspend fun shareImage(
560 context: android.content.Context,
561 request: ImageRequest,
562 displayName: String,
563) {
564 val bitmap = decodeRequestBitmap(context, request) ?: error("decode failed")
565 val name = (displayName.ifBlank { "opencode-image" }).take(80)
566 val file = java.io.File(context.cacheDir, "share/$name-${System.currentTimeMillis()}.png")
567 file.parentFile?.mkdirs()
568 file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
569 val authority = "${context.packageName}.fileprovider"
570 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
571 val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
572 type = "image/png"
573 putExtra(android.content.Intent.EXTRA_STREAM, uri)
574 addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
575 }
576 context.startActivity(android.content.Intent.createChooser(intent, displayName))