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