mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-04 09:58:57 +00:00
* Route GIF decoding through Glide - Keep APNG4Android for APNG, animated WebP, and animated AVIF support. - Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow. * Clear Expo image resources with Glide targets - Detach drawables when Glide clears their custom target. - Prevent recycled GIF frame resources from remaining attached to Expo image views. * Preserve foreground image during GIF target recycling
813 lines
32 KiB
Diff
813 lines
32 KiB
Diff
diff --git a/node_modules/expo-image/android/build.gradle b/node_modules/expo-image/android/build.gradle
|
|
index 2668d8d..fc387fe 100644
|
|
--- a/node_modules/expo-image/android/build.gradle
|
|
+++ b/node_modules/expo-image/android/build.gradle
|
|
@@ -1,17 +1,9 @@
|
|
-buildscript {
|
|
- dependencies {
|
|
- classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:${rootProject["kspVersion"]}"
|
|
- }
|
|
-}
|
|
-
|
|
plugins {
|
|
id 'com.android.library'
|
|
id 'kotlin-kapt'
|
|
id 'expo-module-gradle-plugin'
|
|
}
|
|
|
|
-apply plugin: 'com.google.devtools.ksp'
|
|
-
|
|
group = 'expo.modules.image'
|
|
version = '55.0.10'
|
|
|
|
@@ -42,6 +34,6 @@ dependencies {
|
|
implementation 'com.facebook.react:react-android'
|
|
|
|
api "com.github.bumptech.glide:glide:${GLIDE_VERSION}"
|
|
- ksp "com.github.bumptech.glide:ksp:${GLIDE_VERSION}"
|
|
+ kapt "com.github.bumptech.glide:compiler:${GLIDE_VERSION}"
|
|
api 'com.caverock:androidsvg-aar:1.4'
|
|
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageAppGlideModule.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageAppGlideModule.kt
|
|
--- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageAppGlideModule.kt
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageAppGlideModule.kt
|
|
@@ -2,9 +2,16 @@ package expo.modules.image
|
|
-
|
|
+
|
|
import android.content.Context
|
|
import android.util.Log
|
|
+import com.bumptech.glide.Glide
|
|
import com.bumptech.glide.GlideBuilder
|
|
+import com.bumptech.glide.Registry
|
|
import com.bumptech.glide.annotation.GlideModule
|
|
+import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder
|
|
+import com.bumptech.glide.load.resource.gif.GifDrawable
|
|
+import com.bumptech.glide.load.resource.gif.StreamGifDecoder
|
|
import com.bumptech.glide.module.AppGlideModule
|
|
+import java.io.InputStream
|
|
+import java.nio.ByteBuffer
|
|
-
|
|
+
|
|
/**
|
|
* We need to include an [AppGlideModule] for [GlideModule] annotations
|
|
@@ -22,5 +29,32 @@ class ExpoImageAppGlideModule : AppGlideModule() {
|
|
Log.ERROR
|
|
}
|
|
)
|
|
+
|
|
+ // Configure per-server disk cache to match iOS behavior
|
|
+ // Structure: {cacheDir}/{cachePath}/Images/
|
|
+ val maxSizeBytes = 200L * 1024L * 1024L // 200MB per server, matching typical iOS limits
|
|
+ val diskCache = PerServerDiskCache(context.cacheDir, maxSizeBytes)
|
|
+ builder.setDiskCache { diskCache }
|
|
+
|
|
+ // Store reference to access clearPath method
|
|
+ diskCacheInstance = diskCache
|
|
+ }
|
|
+
|
|
+ override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
|
+ super.registerComponents(context, glide, registry)
|
|
+
|
|
+ // APNG4Android's GIF decoder can write beyond its output buffer. Prepending
|
|
+ // Glide's decoders keeps GIFs animated without routing them through that decoder.
|
|
+ val parsers = registry.imageHeaderParsers
|
|
+ val byteBufferDecoder = ByteBufferGifDecoder(context, parsers, glide.bitmapPool, glide.arrayPool)
|
|
+ val streamDecoder = StreamGifDecoder(parsers, byteBufferDecoder, glide.arrayPool)
|
|
+
|
|
+ registry.prepend(InputStream::class.java, GifDrawable::class.java, streamDecoder)
|
|
+ registry.prepend(ByteBuffer::class.java, GifDrawable::class.java, byteBufferDecoder)
|
|
+ }
|
|
+
|
|
+ companion object {
|
|
+ // Store reference to access clearPath method from ExpoImageModule
|
|
+ internal var diskCacheInstance: PerServerDiskCache? = null
|
|
}
|
|
}
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageModule.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageModule.kt
|
|
index 2579cb3..18a742a 100644
|
|
--- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageModule.kt
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageModule.kt
|
|
@@ -184,13 +175,17 @@ class ExpoImageModule : Module() {
|
|
return@AsyncFunction true
|
|
}.runOnQueue(Queues.MAIN)
|
|
|
|
- AsyncFunction<Boolean>("clearDiskCache") {
|
|
- val activity = appContext.currentActivity ?: return@AsyncFunction false
|
|
- activity.let {
|
|
- Glide.get(activity).clearDiskCache()
|
|
+ AsyncFunction("clearDiskCache") Coroutine { cachePath: String? ->
|
|
+ val ctx = appContext.currentActivity ?: appContext.reactContext ?: return@Coroutine false
|
|
+
|
|
+ if (cachePath.isNullOrBlank()) {
|
|
+ Glide.get(ctx).clearDiskCache()
|
|
+ } else {
|
|
+ // Clear only this server's disk cache folder: <cacheDir>/<cachePath>/Images
|
|
+ ExpoImageAppGlideModule.diskCacheInstance?.clearPath(cachePath)
|
|
}
|
|
|
|
- return@AsyncFunction true
|
|
+ true
|
|
}
|
|
|
|
AsyncFunction("getCachePathAsync") { cacheKey: String ->
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/PerServerDiskCache.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/PerServerDiskCache.kt
|
|
new file mode 100644
|
|
index 0000000..7ccf912
|
|
--- /dev/null
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/PerServerDiskCache.kt
|
|
@@ -0,0 +1,96 @@
|
|
+package expo.modules.image
|
|
+
|
|
+import com.bumptech.glide.load.Key
|
|
+import com.bumptech.glide.load.engine.cache.DiskCache
|
|
+import com.bumptech.glide.load.engine.cache.DiskLruCacheWrapper
|
|
+import java.io.File
|
|
+
|
|
+/**
|
|
+ * A disk cache implementation that stores images in separate directories based on cachePath.
|
|
+ * Structure: {rootDir}/{cachePath}/Images/
|
|
+ * This matches the iOS implementation: Library/Caches/{cachePath}/Images/
|
|
+ */
|
|
+class PerServerDiskCache(
|
|
+ private val rootDir: File,
|
|
+ private val maxSizeBytes: Long
|
|
+) : DiskCache {
|
|
+
|
|
+ // Cache the DiskCache instances per cachePath
|
|
+ private val cacheInstances = mutableMapOf<String, DiskCache>()
|
|
+ private val lock = Any()
|
|
+
|
|
+ private fun extractServerKey(key: Key): ServerImageKey? {
|
|
+ return when (key) {
|
|
+ is ServerImageKey -> key
|
|
+ else -> {
|
|
+ try {
|
|
+ val signatureField = key.javaClass.getDeclaredField("signature")
|
|
+ signatureField.isAccessible = true
|
|
+ val inner = signatureField.get(key) as? Key
|
|
+ inner as? ServerImageKey
|
|
+ } catch (e: Exception) {
|
|
+ null
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private fun getCachePathFromKey(key: Key): String {
|
|
+ val serverKey = extractServerKey(key)
|
|
+ return serverKey?.getCachePath() ?: "default"
|
|
+ }
|
|
+
|
|
+ private fun getCacheForPath(cachePath: String): DiskCache {
|
|
+ return synchronized(lock) {
|
|
+ cacheInstances.getOrPut(cachePath) {
|
|
+ val dir = File(File(rootDir, cachePath), "Images")
|
|
+ dir.mkdirs()
|
|
+ DiskLruCacheWrapper.create(dir, maxSizeBytes)
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ override fun get(key: Key): File? {
|
|
+ val cachePath = getCachePathFromKey(key)
|
|
+ val cache = getCacheForPath(cachePath)
|
|
+ return cache.get(key)
|
|
+ }
|
|
+
|
|
+ override fun put(key: Key, writer: DiskCache.Writer) {
|
|
+ val cachePath = getCachePathFromKey(key)
|
|
+ val cache = getCacheForPath(cachePath)
|
|
+ cache.put(key, writer)
|
|
+ }
|
|
+
|
|
+ override fun delete(key: Key) {
|
|
+ val cachePath = getCachePathFromKey(key)
|
|
+ val cache = getCacheForPath(cachePath)
|
|
+ cache.delete(key)
|
|
+ }
|
|
+
|
|
+ override fun clear() {
|
|
+ synchronized(lock) {
|
|
+ cacheInstances.values.forEach { it.clear() }
|
|
+ cacheInstances.clear()
|
|
+ }
|
|
+
|
|
+ // Delete all directories under rootDir
|
|
+ rootDir.deleteRecursively()
|
|
+ rootDir.mkdirs()
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Clear cache for a specific cachePath.
|
|
+ * This allows selective clearing of per-server caches.
|
|
+ */
|
|
+ fun clearPath(cachePath: String) {
|
|
+ synchronized(lock) {
|
|
+ cacheInstances.remove(cachePath)?.clear()
|
|
+ }
|
|
+
|
|
+ val cacheDir = File(File(rootDir, cachePath), "Images")
|
|
+ if (cacheDir.exists()) {
|
|
+ cacheDir.deleteRecursively()
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ServerImageKey.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ServerImageKey.kt
|
|
new file mode 100644
|
|
index 0000000..3bb655b
|
|
--- /dev/null
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ServerImageKey.kt
|
|
@@ -0,0 +1,45 @@
|
|
+package expo.modules.image
|
|
+
|
|
+import com.bumptech.glide.load.Key
|
|
+import java.security.MessageDigest
|
|
+
|
|
+/**
|
|
+ * A cache key that incorporates the server/cachePath, image URL/cacheKey, and optionally
|
|
+ * the app version signature for resource URIs.
|
|
+ * This allows Glide's disk cache to logically separate images by server.
|
|
+ */
|
|
+class ServerImageKey(
|
|
+ private val cachePath: String, // Server identifier (e.g., base64-encoded server URL)
|
|
+ private val imageKey: String, // Original cache key or URL
|
|
+ private val appVersionSignature: Key? = null // Optional app version for resources
|
|
+) : Key {
|
|
+
|
|
+ override fun equals(other: Any?): Boolean {
|
|
+ if (this === other) return true
|
|
+ if (other !is ServerImageKey) return false
|
|
+ return cachePath == other.cachePath &&
|
|
+ imageKey == other.imageKey &&
|
|
+ appVersionSignature == other.appVersionSignature
|
|
+ }
|
|
+
|
|
+ override fun hashCode(): Int {
|
|
+ var result = cachePath.hashCode()
|
|
+ result = 31 * result + imageKey.hashCode()
|
|
+ result = 31 * result + (appVersionSignature?.hashCode() ?: 0)
|
|
+ return result
|
|
+ }
|
|
+
|
|
+ override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
|
+ // Include cachePath in the disk cache key
|
|
+ messageDigest.update(cachePath.toByteArray(Key.CHARSET))
|
|
+ messageDigest.update(0.toByte()) // separator
|
|
+
|
|
+ // Include app version signature if provided (for resource URIs)
|
|
+ appVersionSignature?.updateDiskCacheKey(messageDigest)
|
|
+
|
|
+ // Include the image key
|
|
+ messageDigest.update(imageKey.toByteArray(Key.CHARSET))
|
|
+ }
|
|
+
|
|
+ fun getCachePath(): String = cachePath
|
|
+}
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/okhttp/ExpoImageOkHttpClientGlideModule.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/okhttp/ExpoImageOkHttpClientGlideModule.kt
|
|
index 74c6a3d..8d57a78 100644
|
|
--- a/node_modules/expo-image/android/src/main/java/expo/modules/image/okhttp/ExpoImageOkHttpClientGlideModule.kt
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/okhttp/ExpoImageOkHttpClientGlideModule.kt
|
|
@@ -95,10 +95,19 @@ private object SharedCookieJar : CookieJar {
|
|
|
|
@GlideModule
|
|
class ExpoImageOkHttpClientGlideModule : LibraryGlideModule() {
|
|
+ companion object {
|
|
+ @JvmField
|
|
+ var okHttpClient: OkHttpClient? = null
|
|
+ }
|
|
+
|
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
|
- val client = OkHttpClient.Builder()
|
|
- .cookieJar(SharedCookieJar)
|
|
- .build()
|
|
+ val client = if (okHttpClient != null) {
|
|
+ okHttpClient!!
|
|
+ } else {
|
|
+ OkHttpClient.Builder()
|
|
+ .cookieJar(SharedCookieJar)
|
|
+ .build()
|
|
+ }
|
|
// We don't use the `GlideUrl` directly but we want to replace the default okhttp loader anyway
|
|
// to make sure that the app will use only one client.
|
|
registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(client))
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/records/SourceMap.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/records/SourceMap.kt
|
|
index 72ad8fa..60ceaac 100644
|
|
--- a/node_modules/expo-image/android/src/main/java/expo/modules/image/records/SourceMap.kt
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/records/SourceMap.kt
|
|
@@ -64,7 +64,8 @@ data class SourceMap(
|
|
@Field override val height: Int = 0,
|
|
@Field override val scale: Double = 1.0,
|
|
@Field val headers: Map<String, String>? = null,
|
|
- @Field val cacheKey: String? = null
|
|
+ @Field val cacheKey: String? = null,
|
|
+ @Field val cachePath: String? = null
|
|
) : Source, Record {
|
|
private var parsedUri: Uri? = null
|
|
|
|
@@ -142,18 +143,41 @@ data class SourceMap(
|
|
|
|
override fun createGlideOptions(context: Context): RequestOptions {
|
|
parseUri(context)
|
|
- return RequestOptions().customize(`when` = width != 0 && height != 0) {
|
|
- // Override the size for local assets (apart from SVGs). This ensures that
|
|
- // resizeMode "center" displays the image in the correct size.
|
|
- override((width * scale).toInt(), (height * scale).toInt())
|
|
- }.customize(`when` = isResourceUri()) {
|
|
+
|
|
+ var options = RequestOptions()
|
|
+ .customize(`when` = width != 0 && height != 0) {
|
|
+ // Override the size for local assets (apart from SVGs). This ensures that
|
|
+ // resizeMode "center" displays the image in the correct size.
|
|
+ override((width * scale).toInt(), (height * scale).toInt())
|
|
+ }
|
|
+
|
|
+ // Build a per-server key if we have a cachePath
|
|
+ val hasServerPath = !cachePath.isNullOrBlank()
|
|
+ val imageKey = cacheKey ?: uri ?: ""
|
|
+
|
|
+ if (hasServerPath) {
|
|
+ val appVersionSig =
|
|
+ if (isResourceUri()) ApplicationVersionSignature.obtain(context) else null
|
|
+
|
|
+ val serverKey = expo.modules.image.ServerImageKey(
|
|
+ cachePath = cachePath!!,
|
|
+ imageKey = imageKey,
|
|
+ appVersionSignature = appVersionSig
|
|
+ )
|
|
+
|
|
+ options = options.signature(serverKey)
|
|
+ } else if (isResourceUri()) {
|
|
// Every local resource (drawable) in Android has its own unique numeric id, which are
|
|
// generated at build time. Although these ids are unique, they are not guaranteed unique
|
|
// across builds. The underlying glide implementation caches these resources. To make
|
|
// sure the cache does not return the wrong image, we should clear the cache when the
|
|
// application version changes.
|
|
- apply(RequestOptions.signatureOf(ApplicationVersionSignature.obtain(context)))
|
|
+ options = options.apply(
|
|
+ RequestOptions.signatureOf(ApplicationVersionSignature.obtain(context))
|
|
+ )
|
|
}
|
|
+
|
|
+ return options
|
|
}
|
|
|
|
private fun getCustomHeaders(): Headers {
|
|
diff --git a/node_modules/expo-image/build/Image.d.ts b/node_modules/expo-image/build/Image.d.ts
|
|
index eef536a..c3ce0eb 100644
|
|
--- a/node_modules/expo-image/build/Image.d.ts
|
|
+++ b/node_modules/expo-image/build/Image.d.ts
|
|
@@ -35,6 +35,16 @@ export declare class Image extends React.PureComponent<ImageProps> {
|
|
* finished prefetching.
|
|
*/
|
|
static prefetch(urls: string | string[], options?: ImagePrefetchOptions): Promise<boolean>;
|
|
+ /**
|
|
+ * Preloads images with detailed source information including cache keys.
|
|
+ * @param sources - An array of ImageSource objects with uri, cacheKey, cachePath, etc.
|
|
+ * @param options - Options for prefetching images.
|
|
+ * @return A promise resolving to `true` as soon as all images have been
|
|
+ * successfully prefetched. If an image fails to be prefetched, the promise
|
|
+ * will immediately resolve to `false` regardless of whether other images have
|
|
+ * finished prefetching.
|
|
+ */
|
|
+ static prefetch(sources: ImageSource[], options?: ImagePrefetchOptions): Promise<boolean>;
|
|
/**
|
|
* Asynchronously clears all images stored in memory.
|
|
* @platform android
|
|
@@ -52,7 +62,7 @@ export declare class Image extends React.PureComponent<ImageProps> {
|
|
* It may resolve to `false` on Android when the activity is no longer available.
|
|
* Resolves to `false` on Web.
|
|
*/
|
|
- static clearDiskCache(): Promise<boolean>;
|
|
+ static clearDiskCache(cachePath?: string): Promise<boolean>;
|
|
/**
|
|
* Asynchronously checks if an image exists in the disk cache and resolves to
|
|
* the path of the cached image if it does.
|
|
diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts
|
|
index 094988f..7fe8e90 100644
|
|
--- a/node_modules/expo-image/build/Image.types.d.ts
|
|
+++ b/node_modules/expo-image/build/Image.types.d.ts
|
|
@@ -60,6 +60,13 @@ export type ImageSource = {
|
|
* @platform ios
|
|
*/
|
|
isAnimated?: boolean;
|
|
+ /**
|
|
+ * A path used to determine which on-disk cache directory to use for this image.
|
|
+ * Images with the same cachePath will share a cache directory, allowing per-server cache isolation.
|
|
+ * @platform android
|
|
+ * @platform ios
|
|
+ */
|
|
+ cachePath?: string;
|
|
};
|
|
/**
|
|
* @hidden
|
|
diff --git a/node_modules/expo-image/ios/ExpoImage.podspec b/node_modules/expo-image/ios/ExpoImage.podspec
|
|
index 4f2f3cf..a1ab583 100644
|
|
--- a/node_modules/expo-image/ios/ExpoImage.podspec
|
|
+++ b/node_modules/expo-image/ios/ExpoImage.podspec
|
|
@@ -1,15 +1,6 @@
|
|
require 'json'
|
|
|
|
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
-podfile_properties = JSON.parse(File.read("#{Pod::Config.instance.installation_root}/Podfile.properties.json")) rescue {}
|
|
-property_override = podfile_properties['expo-image.disable-libdav1d']
|
|
-env_override = ENV['EXPO_IMAGE_DISABLE_LIBDAV1D']
|
|
-disable_libdav1d =
|
|
- if property_override.nil?
|
|
- env_override == '1' || env_override == 'true'
|
|
- else
|
|
- property_override == 'true'
|
|
- end
|
|
|
|
Pod::Spec.new do |s|
|
|
s.name = 'ExpoImage'
|
|
@@ -29,10 +20,8 @@ Pod::Spec.new do |s|
|
|
|
|
s.dependency 'ExpoModulesCore'
|
|
s.dependency 'SDWebImage', '~> 5.21.0'
|
|
- s.dependency 'SDWebImageAVIFCoder', '~> 0.11.0'
|
|
s.dependency 'SDWebImageSVGCoder', '~> 1.7.0'
|
|
s.dependency 'SDWebImageWebPCoder', '~> 0.14.6'
|
|
- s.dependency 'libavif/libdav1d' unless disable_libdav1d
|
|
|
|
# Swift/Objective-C compatibility
|
|
s.pod_target_xcconfig = {
|
|
diff --git a/node_modules/expo-image/ios/ImageLoader.swift b/node_modules/expo-image/ios/ImageLoader.swift
|
|
index aadc60f..7248ffa 100644
|
|
--- a/node_modules/expo-image/ios/ImageLoader.swift
|
|
+++ b/node_modules/expo-image/ios/ImageLoader.swift
|
|
@@ -14,7 +14,10 @@ internal final class ImageLoader {
|
|
func load(_ source: ImageSource, options: ImageLoadOptions) async throws -> UIImage {
|
|
// This loader uses only the disk cache. We may want to give more control on this, but the memory cache
|
|
// doesn't make much sense for shared refs as they're kept in memory as long as their JS objects.
|
|
- var context = createSDWebImageContext(forSource: source, cachePolicy: .disk)
|
|
+
|
|
+ // Get custom cache if cachePath is specified
|
|
+ let customCache = CachePathManager.shared.getCacheForPath(source.cachePath)
|
|
+ var context = createSDWebImageContext(forSource: source, cachePolicy: .disk, customCache: customCache)
|
|
|
|
if let maxSize = options.getMaxSize() {
|
|
// Note that setting the thumbnail size rasterizes vector images into a bitmap.
|
|
diff --git a/node_modules/expo-image/ios/ImageModule.swift b/node_modules/expo-image/ios/ImageModule.swift
|
|
index 6aa92cc..38dc2e4 100644
|
|
--- a/node_modules/expo-image/ios/ImageModule.swift
|
|
+++ b/node_modules/expo-image/ios/ImageModule.swift
|
|
@@ -2,7 +2,6 @@
|
|
|
|
import ExpoModulesCore
|
|
internal import SDWebImage
|
|
-internal import SDWebImageAVIFCoder
|
|
internal import SDWebImageSVGCoder
|
|
|
|
public final class ImageModule: Module {
|
|
@@ -200,6 +199,36 @@ public final class ImageModule: Module {
|
|
}
|
|
}
|
|
|
|
+ AsyncFunction("prefetchWithSources") { (sources: [ImageSource], cachePolicy: ImageCachePolicy, promise: Promise) in
|
|
+ var imagesLoaded = 0
|
|
+ var failed = false
|
|
+
|
|
+ sources.forEach { source in
|
|
+ var context = createSDWebImageContext(forSource: source, cachePolicy: cachePolicy)
|
|
+
|
|
+ // Override cache if cachePath is provided
|
|
+ let customCache = CachePathManager.shared.getCacheForPath(source.cachePath)
|
|
+ context[.imageCache] = customCache
|
|
+ context[.originalImageCache] = customCache
|
|
+
|
|
+ guard let url = source.uri else {
|
|
+ return
|
|
+ }
|
|
+
|
|
+ SDWebImagePrefetcher.shared.prefetchURLs([url], context: context, progress: nil, completed: { _, skipped in
|
|
+ if skipped > 0 && !failed {
|
|
+ failed = true
|
|
+ promise.resolve(false)
|
|
+ } else {
|
|
+ imagesLoaded = imagesLoaded + 1
|
|
+ if imagesLoaded == sources.count {
|
|
+ promise.resolve(true)
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+ }
|
|
+ }
|
|
+
|
|
AsyncFunction("generateBlurhashAsync") { (source: Either<Image, URL>, numberOfComponents: CGSize, promise: Promise) in
|
|
let parsedNumberOfComponents = (width: Int(numberOfComponents.width), height: Int(numberOfComponents.height))
|
|
generatePlaceholder(source: source) { (image: UIImage) in
|
|
@@ -223,9 +252,22 @@ public final class ImageModule: Module {
|
|
return true
|
|
}
|
|
|
|
- AsyncFunction("clearDiskCache") { (promise: Promise) in
|
|
- SDImageCache.shared.clearDisk {
|
|
- promise.resolve(true)
|
|
+ AsyncFunction("clearDiskCache") { (cachePath: String?, promise: Promise) in
|
|
+ if let cachePath = cachePath, !cachePath.isEmpty {
|
|
+ // Clear specific cache path
|
|
+ if let cache = CachePathManager.shared.getCacheIfExists(cachePath) {
|
|
+ cache.clearDisk {
|
|
+ promise.resolve(true)
|
|
+ }
|
|
+ } else {
|
|
+ // Cache doesn't exist yet, nothing to clear
|
|
+ promise.resolve(true)
|
|
+ }
|
|
+ } else {
|
|
+ // Clear default shared cache
|
|
+ SDImageCache.shared.clearDisk {
|
|
+ promise.resolve(true)
|
|
+ }
|
|
}
|
|
}
|
|
|
|
@@ -282,7 +324,6 @@ public final class ImageModule: Module {
|
|
static func registerCoders() {
|
|
SDImageCodersManager.shared.addCoder(WebPCoder.shared)
|
|
SDImageCodersManager.shared.addCoder(PSDCoder.shared)
|
|
- SDImageCodersManager.shared.addCoder(SDImageAVIFCoder.shared)
|
|
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
|
|
SDImageCodersManager.shared.addCoder(SDImageHEICCoder.shared)
|
|
}
|
|
diff --git a/node_modules/expo-image/ios/ImageSource.swift b/node_modules/expo-image/ios/ImageSource.swift
|
|
index 4d23e20..5e6d392 100644
|
|
--- a/node_modules/expo-image/ios/ImageSource.swift
|
|
+++ b/node_modules/expo-image/ios/ImageSource.swift
|
|
@@ -21,6 +21,9 @@ struct ImageSource: Record {
|
|
@Field
|
|
var cacheKey: String?
|
|
|
|
+ @Field
|
|
+ var cachePath: String?
|
|
+
|
|
var pixelCount: Double {
|
|
return width * height * scale * scale
|
|
}
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
|
|
--- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
|
|
@@ -69,6 +69,7 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
|
private var firstTarget = ImageViewWrapperTarget(WeakReference(this))
|
|
private var secondTarget = ImageViewWrapperTarget(WeakReference(this))
|
|
+ private var foregroundTarget: ImageViewWrapperTarget? = null
|
|
|
|
internal val onLoadStart by EventDispatcher<Unit>()
|
|
internal val onProgress by EventDispatcher<ImageProgressEvent>()
|
|
@@ -385,6 +386,7 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
it.applyTransformationMatrix()
|
|
}
|
|
target.isUsed = true
|
|
+ foregroundTarget = target
|
|
|
|
if (resource is Animatable) {
|
|
resource.start()
|
|
@@ -450,6 +452,19 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
requestManager.clear(secondTarget)
|
|
}
|
|
|
|
+ fun onTargetCleared(target: ImageViewWrapperTarget) {
|
|
+ if (foregroundTarget === target) {
|
|
+ foregroundTarget = null
|
|
+ }
|
|
+
|
|
+ if (firstView.currentTarget === target) {
|
|
+ firstView.recycleView()
|
|
+ }
|
|
+ if (secondView.currentTarget === target) {
|
|
+ secondView.recycleView()
|
|
+ }
|
|
+ }
|
|
+
|
|
private fun cleanIfNeeded(
|
|
newBestSource: Source?,
|
|
newBestSourceModel: GlideModelProvider?,
|
|
@@ -551,10 +566,10 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
}
|
|
|
|
onLoadStart.invoke(Unit)
|
|
- val newTarget = if (secondTarget.isUsed) {
|
|
- firstTarget
|
|
- } else {
|
|
- secondTarget
|
|
+ val newTarget = when (foregroundTarget) {
|
|
+ firstTarget -> secondTarget
|
|
+ secondTarget -> firstTarget
|
|
+ else -> if (secondTarget.isUsed) firstTarget else secondTarget
|
|
}
|
|
newTarget.hasSource = sourceToLoad != null
|
|
|
|
diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt
|
|
--- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt
|
|
+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt
|
|
@@ -128,7 +128,9 @@ class ImageViewWrapperTarget(
|
|
endLoadingNewImageTraceBlock()
|
|
}
|
|
|
|
- override fun onLoadCleared(placeholder: Drawable?) = Unit
|
|
+ override fun onLoadCleared(placeholder: Drawable?) {
|
|
+ imageViewHolder.get()?.onTargetCleared(this)
|
|
+ }
|
|
|
|
override fun getSize(cb: SizeReadyCallback) {
|
|
// If we can't resolve the image, we just return unknown size.
|
|
diff --git a/node_modules/expo-image/ios/ImageView.swift b/node_modules/expo-image/ios/ImageView.swift
|
|
index 034b662..efec047 100644
|
|
--- a/node_modules/expo-image/ios/ImageView.swift
|
|
+++ b/node_modules/expo-image/ios/ImageView.swift
|
|
@@ -9,6 +9,79 @@ import VisionKit
|
|
|
|
typealias SDWebImageContext = [SDWebImageContextOption: Any]
|
|
|
|
+// MARK: - Cache Path Management
|
|
+
|
|
+internal class CachePathManager {
|
|
+ static let shared = CachePathManager()
|
|
+
|
|
+ // Cache instances by path
|
|
+ private var caches: [String: SDImageCache] = [:]
|
|
+ private let lock = NSLock()
|
|
+
|
|
+ private init() {}
|
|
+
|
|
+ func getCacheForPath(_ cachePath: String?) -> SDImageCache? {
|
|
+ // No cachePath provided -> use default singleton (nil signals no override)
|
|
+ guard let cachePath = cachePath, !cachePath.isEmpty else {
|
|
+ return nil
|
|
+ }
|
|
+
|
|
+ lock.lock()
|
|
+ defer { lock.unlock() }
|
|
+
|
|
+ // Return existing cache for this path
|
|
+ if let existing = caches[cachePath] {
|
|
+ return existing
|
|
+ }
|
|
+
|
|
+ // Create base ImageCache directory
|
|
+ guard let cachesDir = FileManager.default.urls(
|
|
+ for: .cachesDirectory,
|
|
+ in: .userDomainMask
|
|
+ ).first else {
|
|
+ return nil
|
|
+ }
|
|
+
|
|
+ // Build path: Library/Caches/{cachePath}/Images/
|
|
+ let imageCacheDir = cachesDir
|
|
+ .appendingPathComponent(cachePath)
|
|
+ .appendingPathComponent("Images")
|
|
+
|
|
+ // Create new cache instance for this path
|
|
+ let cache = SDImageCache(
|
|
+ namespace: "custom",
|
|
+ diskCacheDirectory: imageCacheDir.path
|
|
+ )
|
|
+ caches[cachePath] = cache
|
|
+
|
|
+ return cache
|
|
+ }
|
|
+
|
|
+ func getCacheIfExists(_ cachePath: String) -> SDImageCache? {
|
|
+ lock.lock()
|
|
+ defer { lock.unlock() }
|
|
+ return caches[cachePath]
|
|
+ }
|
|
+
|
|
+ func clearAllCaches(completion: @escaping () -> Void) {
|
|
+ lock.lock()
|
|
+ let allCaches = Array(caches.values)
|
|
+ lock.unlock()
|
|
+
|
|
+ let group = DispatchGroup()
|
|
+ for cache in allCaches {
|
|
+ group.enter()
|
|
+ cache.clearDisk {
|
|
+ group.leave()
|
|
+ }
|
|
+ }
|
|
+
|
|
+ group.notify(queue: .main) {
|
|
+ completion()
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
// swiftlint:disable:next type_body_length
|
|
public final class ImageView: ExpoView {
|
|
nonisolated static let contextSourceKey = SDWebImageContextOption(rawValue: "source")
|
|
@@ -771,10 +844,13 @@ public final class ImageView: ExpoView {
|
|
Creates a base SDWebImageContext for this view. It should include options that are shared by both placeholders and final images.
|
|
*/
|
|
private func createBaseImageContext(source: ImageSource, cachePolicy: ImageCachePolicy? = nil) -> SDWebImageContext {
|
|
+ // Get custom cache if cachePath is specified
|
|
+ let customCache = CachePathManager.shared.getCacheForPath(source.cachePath)
|
|
var context = createSDWebImageContext(
|
|
forSource: source,
|
|
cachePolicy: cachePolicy ?? self.cachePolicy,
|
|
- useAppleWebpCodec: useAppleWebpCodec
|
|
+ useAppleWebpCodec: useAppleWebpCodec,
|
|
+ customCache: customCache
|
|
)
|
|
|
|
// Decode to HDR if the `preferHighDynamicRange` prop is on (in this case `preferredImageDynamicRange` is set to high).
|
|
diff --git a/node_modules/expo-image/ios/Utils/ImageUtils.swift b/node_modules/expo-image/ios/Utils/ImageUtils.swift
|
|
index 6ddef04..774b9ae 100644
|
|
--- a/node_modules/expo-image/ios/Utils/ImageUtils.swift
|
|
+++ b/node_modules/expo-image/ios/Utils/ImageUtils.swift
|
|
@@ -158,7 +158,7 @@ func createCacheKeyFilter(_ cacheKey: String?) -> SDWebImageCacheKeyFilter? {
|
|
/**
|
|
Creates a default image context based on the source and the cache policy.
|
|
*/
|
|
-func createSDWebImageContext(forSource source: ImageSource, cachePolicy: ImageCachePolicy = .disk, useAppleWebpCodec: Bool = true) -> SDWebImageContext {
|
|
+func createSDWebImageContext(forSource source: ImageSource, cachePolicy: ImageCachePolicy = .disk, useAppleWebpCodec: Bool = true, customCache: SDImageCache? = nil) -> SDWebImageContext {
|
|
var context = SDWebImageContext()
|
|
|
|
// Modify URL request to add headers.
|
|
@@ -198,6 +198,12 @@ func createSDWebImageContext(forSource source: ImageSource, cachePolicy: ImageCa
|
|
// Some loaders (e.g. blurhash) may need access to the source.
|
|
context[ImageView.contextSourceKey] = source
|
|
|
|
+ // Override cache instances if custom cache is provided
|
|
+ if let customCache = customCache {
|
|
+ context[.imageCache] = customCache
|
|
+ context[.originalImageCache] = customCache
|
|
+ }
|
|
+
|
|
return context
|
|
}
|
|
|
|
diff --git a/node_modules/expo-image/src/Image.tsx b/node_modules/expo-image/src/Image.tsx
|
|
index 15e034d..c991437 100644
|
|
--- a/node_modules/expo-image/src/Image.tsx
|
|
+++ b/node_modules/expo-image/src/Image.tsx
|
|
@@ -96,8 +96,18 @@ export class Image extends React.PureComponent<ImageProps> {
|
|
* finished prefetching.
|
|
*/
|
|
static async prefetch(urls: string | string[], options?: ImagePrefetchOptions): Promise<boolean>;
|
|
+ /**
|
|
+ * Preloads images with detailed source information including cache keys.
|
|
+ * @param sources - An array of ImageSource objects with uri, cacheKey, cachePath, etc.
|
|
+ * @param options - Options for prefetching images.
|
|
+ * @return A promise resolving to `true` as soon as all images have been
|
|
+ * successfully prefetched. If an image fails to be prefetched, the promise
|
|
+ * will immediately resolve to `false` regardless of whether other images have
|
|
+ * finished prefetching.
|
|
+ */
|
|
+ static async prefetch(sources: ImageSource[], options?: ImagePrefetchOptions): Promise<boolean>;
|
|
static async prefetch(
|
|
- urls: string | string[],
|
|
+ urlsOrSources: string | string[] | ImageSource[],
|
|
options?: ImagePrefetchOptions['cachePolicy'] | ImagePrefetchOptions
|
|
): Promise<boolean> {
|
|
let cachePolicy: ImagePrefetchOptions['cachePolicy'] = 'memory-disk';
|
|
@@ -112,7 +122,18 @@ export class Image extends React.PureComponent<ImageProps> {
|
|
break;
|
|
}
|
|
|
|
- return ImageModule.prefetch(Array.isArray(urls) ? urls : [urls], cachePolicy, headers);
|
|
+ // Normalize input to array
|
|
+ const sources = Array.isArray(urlsOrSources) ? urlsOrSources : [urlsOrSources];
|
|
+
|
|
+ // Check if we have ImageSource objects or plain strings
|
|
+ const firstItem = sources[0];
|
|
+ if (typeof firstItem === 'string') {
|
|
+ // plain URL strings
|
|
+ return ImageModule.prefetch(sources as string[], cachePolicy, headers);
|
|
+ } else {
|
|
+ // ImageSource objects with cacheKey and cachePath per image
|
|
+ return ImageModule.prefetchWithSources(sources as ImageSource[], cachePolicy);
|
|
+ }
|
|
}
|
|
|
|
/**
|
|
@@ -135,8 +156,8 @@ export class Image extends React.PureComponent<ImageProps> {
|
|
* It may resolve to `false` on Android when the activity is no longer available.
|
|
* Resolves to `false` on Web.
|
|
*/
|
|
- static async clearDiskCache(): Promise<boolean> {
|
|
- return await ImageModule.clearDiskCache();
|
|
+ static async clearDiskCache(cachePath?: string): Promise<boolean> {
|
|
+ return await ImageModule.clearDiskCache(cachePath ?? null);
|
|
}
|
|
|
|
/**
|
|
diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts
|
|
index 58c0b8f..5e1bd21 100644
|
|
--- a/node_modules/expo-image/src/Image.types.ts
|
|
+++ b/node_modules/expo-image/src/Image.types.ts
|
|
@@ -73,6 +73,13 @@ export type ImageSource = {
|
|
* @platform ios
|
|
*/
|
|
isAnimated?: boolean;
|
|
+ /**
|
|
+ * A path used to determine which on-disk cache directory to use for this image.
|
|
+ * Images with the same cachePath will share a cache directory, allowing per-server cache isolation.
|
|
+ * @platform android
|
|
+ * @platform ios
|
|
+ */
|
|
+ cachePath?: string;
|
|
};
|
|
|
|
/**
|