Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { Image } from 'react-native';
import { EnrichedMarkdownTextStory } from '../EnrichedMarkdownTextStory';
import { storyMeta } from '../shared/storyMeta';
import {
Expand All @@ -15,6 +16,13 @@ import type { TextStory } from '../shared/storyTypes';
const MARKDOWN =
'![Misty forest at sunrise](https://images.unsplash.com/photo-1448375240586-882707db888b?w=800)';

const LOCAL_ASSET_URI = Image.resolveAssetSource(
require('../../../../../src/assets/logo.png')
).uri;

const DATA_URI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeklEQVR42u3asQnAIBQE0D9JILhDyFwZyUW1E4uki5jgg19e4auOAyPtx68vAABeBEQu7c7tur0+MysPADAK8LWHPuUBAAAAAMYANDEAAACAPaCJAQAAAOwBTQwAAABgD2hiAAAAAHtAEwMAAADYA5oYYCWAn7sACwIqZZCNkUg0NTIAAAAASUVORK5CYII=';

const argTypes = {
height: numberControl('markdownStyle.image.height', {
min: 80,
Expand Down Expand Up @@ -73,3 +81,41 @@ export const Default: TextStory<ImageStyleControls> = {
);
},
};

export const LocalAsset: TextStory<ImageStyleControls> = {
args: {
markdown: `![Bundled logo](${LOCAL_ASSET_URI})`,
...imageStyledDefaults,
},
argTypes,
render: (args) => {
const { controls, rest } = splitStyleControls(args, imageStyledDefaults);
return (
<EnrichedMarkdownTextStory
title="Local asset image"
description={`A require()'d asset resolved via Image.resolveAssetSource. In dev this is a Metro http URL; in a release build it becomes a drawable resource name (Android) or a bundle file:// URL (iOS), exercising the local image loaders. Resolved to: ${LOCAL_ASSET_URI}`}
{...rest}
style={{ image: toImageStyle(controls) }}
/>
);
},
};

export const DataUri: TextStory<ImageStyleControls> = {
args: {
markdown: `![Checkerboard](${DATA_URI})`,
...imageStyledDefaults,
},
argTypes,
render: (args) => {
const { controls, rest } = splitStyleControls(args, imageStyledDefaults);
return (
<EnrichedMarkdownTextStory
title="Data URI image"
description="A base64 data: URI. On Android this exercises the local image loader in every build mode (dev included); on iOS data: URLs are served natively by NSURLSession."
{...rest}
style={{ image: toImageStyle(controls) }}
/>
);
},
};
24 changes: 24 additions & 0 deletions docs/IMAGE_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

Images in Markdown content are loaded, cached, and reused automatically — no configuration required.

## Supported Image Sources

Markdown image URLs are strings, so bundled assets must be resolved to a URI before being interpolated into the markdown:

```tsx
import { Image } from 'react-native';

const logoUri = Image.resolveAssetSource(require('./assets/logo.png')).uri;
const markdown = `![Logo](${logoUri})`;
```

This works in every environment: in dev the URI is a Metro dev-server URL, while in a release build it resolves to a drawable resource name on Android and a bundle `file://` URL on iOS — all of which are supported. URIs from `expo-asset` (`asset.uri` / `asset.localUri`) work the same way.

| Source | Android | iOS / macOS |
|---|---|---|
| `http://`, `https://` | Yes | Yes |
| `file://` (including percent-encoded paths) | Yes | Yes |
| Bare asset name (release builds, expo-asset `localUri`) | Yes (drawable/raw resource) | Yes (bundle resource) |
| `file:///android_res/…`, `file:///android_asset/…` | Yes | No |
| `asset://`, `res://`, `content://` | Yes | No |
| `data:` (base64) | Yes | Yes |

Local sources skip the HTTP layers below (disk cache, request headers, deduplication) but still use both memory cache tiers.

## Cache Layers

The library uses a three-tier caching strategy on both platforms:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.text.Spannable
import android.text.Spanned
Expand All @@ -20,6 +21,7 @@ import androidx.core.graphics.withSave
import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.text.ImageCache
import com.swmansion.enriched.markdown.utils.text.ImageDownloader
import com.swmansion.enriched.markdown.utils.text.LocalImageLoader
import java.lang.ref.WeakReference
import java.util.concurrent.Executors
import android.text.style.ImageSpan as AndroidImageSpan
Expand Down Expand Up @@ -52,25 +54,21 @@ class ImageSpan(
}

private fun loadImage() {
if (imageUrl.startsWith("http")) {
val scheme = Uri.parse(imageUrl).scheme?.lowercase()
if (scheme == "http" || scheme == "https") {
ImageDownloader.download(context, imageUrl, requestHeaders) { bitmap ->
if (bitmap != null) {
sourceDrawable = bitmap.toDrawable(context.resources)
wrapAndAssignDrawable()
}
}
} else {
val path = imageUrl.removePrefix("file://")
try {
val cached = ImageCache.getOriginal(imageUrl)
val bitmap = cached ?: ImageDownloader.decodeFileDownsampled(context, path)
if (bitmap != null) {
if (cached == null) ImageCache.putOriginal(imageUrl, bitmap)
sourceDrawable = bitmap.toDrawable(context.resources)
wrapAndAssignDrawable()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load local image: $path", e)
val cached = ImageCache.getOriginal(imageUrl)
val bitmap = cached ?: LocalImageLoader.load(context, imageUrl)
if (bitmap != null) {
if (cached == null) ImageCache.putOriginal(imageUrl, bitmap)
sourceDrawable = bitmap.toDrawable(context.resources)
wrapAndAssignDrawable()
}
}
}
Expand Down Expand Up @@ -330,7 +328,6 @@ class ImageSpan(
}

companion object {
private const val TAG = "ImageSpan"
private val transparentDrawable by lazy { Color.TRANSPARENT.toDrawable() }
private val cacheExecutor = Executors.newSingleThreadExecutor()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ object ImageDownloader {
}
}

fun decodeBytesDownsampled(
context: Context,
bytes: ByteArray,
): Bitmap? = decodeDownsampled(bytes, context.resources.displayMetrics.widthPixels)

fun decodeFileDownsampled(
context: Context,
path: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package com.swmansion.enriched.markdown.utils.text

import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import android.util.Log
import java.io.FileNotFoundException
import java.io.InputStream

/**
* Loads markdown images from non-network sources (issue #377).
*
* React Native's asset system produces different URI shapes for the same
* `require('./img.png')` depending on environment, and none of them are plain
* file paths in a bundled app:
* - Metro dev server: `http://host:8081/assets/...` (handled by [ImageDownloader])
* - Release APK: a bare drawable resource name, e.g. `src_assets_logo`
* (RN's `AssetSourceResolver.resourceIdentifierWithoutScale`)
* - Sideloaded JS bundle: `file:///.../drawable-xhdpi/src_assets_logo.png`
* - expo-updates embedded assets: `file:///android_res/drawable/<name>.png`
* - expo-asset downloads: percent-encoded `file://` paths in the cache dir
*
* Resolution mirrors RN core: `ImageSource.computeUri` treats a scheme-less
* source as a resource name, and `ResourceDrawableIdHelper` normalizes names
* by lowercasing, replacing `-` with `_`, and accepting numeric resource ids. The
* `android_res`/`android_asset` and drawable/raw lookups follow expo-asset's
* `ResourceAsset.kt`. `content://`, `asset://`, `res://` and `data:` URIs are
* supported for parity with RN's Fresco pipeline. All decodes are downsampled
* to screen width like the network path.
*/
object LocalImageLoader {
private const val TAG = "LocalImageLoader"
private const val ASSET_PATH_PREFIX = "/android_asset/"
private const val RES_PATH_PREFIX = "/android_res/"
private const val BASE64_MARKER = "base64,"

fun load(
context: Context,
source: String,
): Bitmap? =
try {
val uri = Uri.parse(source)
when (uri.scheme?.lowercase()) {
null -> {
if (source.startsWith('/')) {
ImageDownloader.decodeFileDownsampled(context, source)
} else {
decodeResourceByName(context, source)
}
}

"file" -> {
loadFileUri(context, uri)
}

"asset" -> {
decodeStream(context) { context.assets.open(schemelessPath(uri)) }
}

"content" -> {
decodeStream(context) {
context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException(source)
}
}

"res" -> {
decodeResourceByName(context, schemelessPath(uri))
}

"data" -> {
decodeDataUri(context, source)
}

else -> {
Log.w(TAG, "Unsupported image URI scheme: $source")
null
}
}
} catch (_: OutOfMemoryError) {
Log.e(TAG, "OOM loading local image: $source")
null
} catch (e: Exception) {
Log.w(TAG, "Failed to load local image: $source", e)
null
}

private fun loadFileUri(
context: Context,
uri: Uri,
): Bitmap? {
val path = uri.path ?: return null
return when {
path.startsWith(ASSET_PATH_PREFIX) -> {
decodeStream(context) { context.assets.open(path.removePrefix(ASSET_PATH_PREFIX)) }
}

path.startsWith(RES_PATH_PREFIX) -> {
decodeAndroidRes(context, uri)
}

else -> {
ImageDownloader.decodeFileDownsampled(context, path)
}
}
}

/**
* Resolves `file:///android_res/<dir>[-qualifier]/<file>[.<ext>]` by stripping
* density qualifiers and the file extension, then looking the resource up by name.
*/
private fun decodeAndroidRes(
context: Context,
uri: Uri,
): Bitmap? {
val segments = uri.pathSegments
if (segments.size < 3) return null
val directory = segments[1].substringBefore('-')
val name = segments[2].substringBeforeLast('.')
val resId = context.resources.getIdentifier(name, directory, context.packageName)
if (resId == 0) {
Log.w(TAG, "No $directory resource named: $name")
return null
}
return decodeStream(context) { context.resources.openRawResource(resId) }
}

private fun decodeResourceByName(
context: Context,
name: String,
): Bitmap? {
if (name.isEmpty()) return null
val normalized = name.lowercase().replace('-', '_')
val resId =
normalized.toIntOrNull()
?: context.resources.getIdentifier(normalized, "drawable", context.packageName).takeIf { it != 0 }
?: context.resources.getIdentifier(normalized, "raw", context.packageName)
if (resId == 0) {
Log.w(TAG, "No drawable or raw resource named: $normalized")
return null
}
return decodeStream(context) { context.resources.openRawResource(resId) }
}

private fun decodeDataUri(
context: Context,
source: String,
): Bitmap? {
val marker = source.indexOf(BASE64_MARKER)
if (marker == -1) return null
val bytes = Base64.decode(source.substring(marker + BASE64_MARKER.length), Base64.DEFAULT)
return ImageDownloader.decodeBytesDownsampled(context, bytes)
}

private inline fun decodeStream(
context: Context,
open: () -> InputStream,
): Bitmap? = open().use { ImageDownloader.decodeBytesDownsampled(context, it.readBytes()) }

private fun schemelessPath(uri: Uri): String = (uri.host.orEmpty() + uri.path.orEmpty()).trimStart('/')
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.text.Spannable
import android.text.Spanned
Expand All @@ -22,6 +23,7 @@ import com.swmansion.enriched.markdown.EnrichedMarkdownText
import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.text.ImageCache
import com.swmansion.enriched.markdown.utils.text.ImageDownloader
import com.swmansion.enriched.markdown.utils.text.LocalImageLoader
import java.lang.ref.WeakReference
import java.util.concurrent.Executors
import android.text.style.ImageSpan as AndroidImageSpan
Expand Down Expand Up @@ -104,25 +106,21 @@ class ImageSpan(
}

private fun loadImage() {
if (imageUrl.startsWith("http")) {
val scheme = Uri.parse(imageUrl).scheme?.lowercase()
if (scheme == "http" || scheme == "https") {
ImageDownloader.download(context, imageUrl, requestHeaders) { bitmap ->
if (bitmap != null) {
sourceDrawable = bitmap.toDrawable(context.resources)
wrapAndAssignDrawable()
}
}
} else {
val path = imageUrl.removePrefix("file://")
try {
val cached = ImageCache.getOriginal(imageUrl)
val bitmap = cached ?: ImageDownloader.decodeFileDownsampled(context, path)
if (bitmap != null) {
if (cached == null) ImageCache.putOriginal(imageUrl, bitmap)
sourceDrawable = bitmap.toDrawable(context.resources)
wrapAndAssignDrawable()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load local image: $path", e)
val cached = ImageCache.getOriginal(imageUrl)
val bitmap = cached ?: LocalImageLoader.load(context, imageUrl)
if (bitmap != null) {
if (cached == null) ImageCache.putOriginal(imageUrl, bitmap)
sourceDrawable = bitmap.toDrawable(context.resources)
wrapAndAssignDrawable()
}
}
}
Expand Down Expand Up @@ -467,7 +465,6 @@ class ImageSpan(
}

companion object {
private const val TAG = "ImageSpan"
private val transparentDrawable by lazy { Color.TRANSPARENT.toDrawable() }
private val cacheExecutor = Executors.newSingleThreadExecutor()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ object ImageDownloader {
}
}

fun decodeBytesDownsampled(
context: Context,
bytes: ByteArray,
): Bitmap? = decodeDownsampled(bytes, context.resources.displayMetrics.widthPixels)

fun decodeFileDownsampled(
context: Context,
path: String,
Expand Down
Loading
Loading