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("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: //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() + 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? = 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 { * finished prefetching. */ static prefetch(urls: string | string[], options?: ImagePrefetchOptions): Promise; + /** + * 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; /** * Asynchronously clears all images stored in memory. * @platform android @@ -52,7 +62,7 @@ export declare class Image extends React.PureComponent { * It may resolve to `false` on Android when the activity is no longer available. * Resolves to `false` on Web. */ - static clearDiskCache(): Promise; + static clearDiskCache(cachePath?: string): Promise; /** * 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, 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() internal val onProgress by EventDispatcher() @@ -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 { * finished prefetching. */ static async prefetch(urls: string | string[], options?: ImagePrefetchOptions): Promise; + /** + * 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; static async prefetch( - urls: string | string[], + urlsOrSources: string | string[] | ImageSource[], options?: ImagePrefetchOptions['cachePolicy'] | ImagePrefetchOptions ): Promise { let cachePolicy: ImagePrefetchOptions['cachePolicy'] = 'memory-disk'; @@ -112,7 +122,18 @@ export class Image extends React.PureComponent { 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 { * It may resolve to `false` on Android when the activity is no longer available. * Resolves to `false` on Web. */ - static async clearDiskCache(): Promise { - return await ImageModule.clearDiskCache(); + static async clearDiskCache(cachePath?: string): Promise { + 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; }; /**