@@ -173,17 +173,23 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
173173 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
174174 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
175175 // FileChip to fall through to).
176− val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
176+ //
177+ // Bumping retryKey re-runs resolveModel (and re-builds the ImageRequest below) so a failed
178+ // load can be re-issued by tapping the error state. Without retryKey in these keys, a null
179+ // model (e.g. a cross-origin URL blocked at resolve time) would stay null forever and the
180+ // retry button would be a no-op that re-renders the broken-image icon — see the ImageRetry
181+ // callback at the SubcomposeAsyncImage error slot.
182+ var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
183+ val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl, retryKey) {
177184 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
178185 }.value
179186 if (model === ImageResolving) return
180187 if (model == null) {
181− ImageStatusBox { BrokenImageIcon() }
188+ ImageStatusBox {
189+ BrokenImageIcon(onRetry = { retryKey++ })
190+ }
182191 return
183192 }
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) }
187193 // Fullscreen zoomable viewer state: opened by tapping the inline image.
188194 var showFullscreen by remember { mutableStateOf(false) }
189195 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
@@ -501,12 +507,19 @@ private fun ImageStatusBox(content: @Composable () -> Unit) {
501507}
502508
503509@Composable
504−private fun BrokenImageIcon() {
505− Icon(
506− Icons.Filled.BrokenImage,
507− contentDescription = stringResource(R.string.image_failed),
508− tint = MaterialTheme.colorScheme.onSurfaceVariant,
509− )
510+private fun BrokenImageIcon(onRetry: (() -> Unit)? = null) {
511+ if (onRetry == null) {
512+ Icon(
513+ Icons.Filled.BrokenImage,
514+ contentDescription = stringResource(R.string.image_failed),
515+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
516+ )
517+ } else {
518+ // Reuse the retry affordance so a null-model (e.g. a cross-origin URL blocked at resolve
519+ // time) is retryable just like a failed network fetch — tapping re-runs resolveModel
520+ // (retryKey is a produceState key) which may succeed if the block was transient.
521+ ImageRetry(onRetry = onRetry)
522+ }
510523}
511524
512525/** Decode the image behind [request] via Coil and write it to the system MediaStore