diff --git a/android/src/main/java/com/turboimage/TurboImageViewManager.kt b/android/src/main/java/com/turboimage/TurboImageViewManager.kt index 99654fa0..5531faa2 100644 --- a/android/src/main/java/com/turboimage/TurboImageViewManager.kt +++ b/android/src/main/java/com/turboimage/TurboImageViewManager.kt @@ -28,8 +28,11 @@ import com.turboimage.decoder.APNGDecoder import com.turboimage.events.ProgressEvent import com.turboimage.events.interceptor.ProgressInterceptor import com.turboimage.events.interceptor.ProgressListener +import com.turboimage.events.interceptor.ProgressListeners import okhttp3.OkHttpClient import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import java.util.concurrent.ConcurrentHashMap class TurboImageViewManager : SimpleViewManager(), LifecycleEventListener { override fun getName() = REACT_CLASS @@ -60,6 +63,7 @@ class TurboImageViewManager : SimpleViewManager(), LifecycleEven override fun onDropViewInstance(view: TurboImageView) { super.onDropViewInstance(view) view.resetLoadSignature() + ProgressListeners.unregister(view.id) view.dispose() } @@ -77,30 +81,31 @@ class TurboImageViewManager : SimpleViewManager(), LifecycleEven view.markLoadStarted(loadSignature) - val okHttpClient = OkHttpClient.Builder() - .addInterceptor(ProgressInterceptor(object : ProgressListener { - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { - val reactContext = view.context as ReactContext - UIManagerHelper.getEventDispatcher(reactContext, view.id)?.let { - val payload = Arguments.createMap().apply { - putDouble("completed", bytesRead.toDouble()) - putDouble("total", contentLength.toDouble()) - } - val surfaceId = UIManagerHelper.getSurfaceId(reactContext) - it.dispatchEvent(ProgressEvent(surfaceId, view.id, payload)) + // Progress events are routed through a shared OkHttpClient: the request + // is tagged with this view's tag (stripped again in ProgressInterceptor + // before it reaches the network) and the listener is looked up from + // ProgressListeners. Building a client + ImageLoader per load leaked one + // ConnectivityManager network callback per ImageLoader — Android caps an + // app at 100 and then throws a fatal TooManyRequestsException (#440). + ProgressListeners.register(view.id, object : ProgressListener { + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + val reactContext = view.context as ReactContext + UIManagerHelper.getEventDispatcher(reactContext, view.id)?.let { + val payload = Arguments.createMap().apply { + putDouble("completed", bytesRead.toDouble()) + putDouble("total", contentLength.toDouble()) } + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + it.dispatchEvent(ProgressEvent(surfaceId, view.id, payload)) } - })) - .build() + } + }) - val imageLoader = Coil.imageLoader(view.context).newBuilder() - .networkObserverEnabled(false) - .respectCacheHeaders(view.cachePolicy == "urlCache") - .okHttpClient(okHttpClient) - .build() + val imageLoader = sharedImageLoader(view, view.cachePolicy == "urlCache") view.load(view.uri, imageLoader) { view.headers?.let { headers(it) } + addHeader(ProgressInterceptor.VIEW_TAG_HEADER, view.id.toString()) view.cacheKey?.let { memoryCacheKey(it) diskCacheKey(it) @@ -252,6 +257,34 @@ class TurboImageViewManager : SimpleViewManager(), LifecycleEven companion object { private const val REACT_CLASS = "TurboImageView" + + // One client and (at most) two ImageLoaders — one per cachePolicy — for + // the whole app. Each Coil ImageLoader registers a ConnectivityManager + // network callback; instantiating one per image load exhausts the + // OS-wide limit of 100 callbacks per app (fatal TooManyRequestsException) + // and defeats OkHttp connection pooling. + private val progressClient by lazy { + OkHttpClient.Builder() + .addInterceptor(ProgressInterceptor()) + .build() + } + + private val sharedLoaders = ConcurrentHashMap() + + private fun sharedImageLoader( + view: TurboImageView, + respectCacheHeaders: Boolean + ): ImageLoader { + return sharedLoaders.getOrPut(respectCacheHeaders) { + Coil.imageLoader(view.context.applicationContext) + .newBuilder() + .networkObserverEnabled(false) + .respectCacheHeaders(respectCacheHeaders) + .okHttpClient(progressClient) + .build() + } + } + private val RESIZE_MODE = mapOf( "contain" to ScaleType.FIT_CENTER, "cover" to ScaleType.CENTER_CROP, diff --git a/android/src/main/java/com/turboimage/events/interceptor/ProgressInterceptor.kt b/android/src/main/java/com/turboimage/events/interceptor/ProgressInterceptor.kt index 19b01fd4..132b889f 100644 --- a/android/src/main/java/com/turboimage/events/interceptor/ProgressInterceptor.kt +++ b/android/src/main/java/com/turboimage/events/interceptor/ProgressInterceptor.kt @@ -3,11 +3,32 @@ package com.turboimage.events.interceptor import okhttp3.Interceptor import okhttp3.Response -class ProgressInterceptor(private val listener: ProgressListener) : Interceptor { +/** + * Reports download progress for requests tagged with [VIEW_TAG_HEADER]. + * + * The header carries the React view tag of the TurboImageView that issued + * the request; it is stripped before the request goes to the network. The + * matching [ProgressListener] is resolved through [ProgressListeners], which + * lets a single shared OkHttpClient serve every view instead of building a + * client (and a Coil ImageLoader) per image load. + */ +class ProgressInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val originalResponse = chain.proceed(chain.request()) - return originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body!!, listener)) + val originalRequest = chain.request() + val viewTag = originalRequest.header(VIEW_TAG_HEADER)?.toIntOrNull() + val request = if (viewTag != null) { + originalRequest.newBuilder().removeHeader(VIEW_TAG_HEADER).build() + } else { + originalRequest + } + val response = chain.proceed(request) + val listener = viewTag?.let { ProgressListeners.get(it) } ?: return response + return response.newBuilder() + .body(ProgressResponseBody(response.body!!, listener)) .build() } + + companion object { + const val VIEW_TAG_HEADER = "X-TurboImage-View-Tag" + } } diff --git a/android/src/main/java/com/turboimage/events/interceptor/ProgressListeners.kt b/android/src/main/java/com/turboimage/events/interceptor/ProgressListeners.kt new file mode 100644 index 00000000..f8115349 --- /dev/null +++ b/android/src/main/java/com/turboimage/events/interceptor/ProgressListeners.kt @@ -0,0 +1,22 @@ +package com.turboimage.events.interceptor + +import java.util.concurrent.ConcurrentHashMap + +/** + * Maps React view tags to their [ProgressListener] so the shared + * [ProgressInterceptor] can route download progress to the right view. + * Entries are registered on every load and removed when the view is dropped. + */ +object ProgressListeners { + private val listeners = ConcurrentHashMap() + + fun register(viewTag: Int, listener: ProgressListener) { + listeners[viewTag] = listener + } + + fun unregister(viewTag: Int) { + listeners.remove(viewTag) + } + + fun get(viewTag: Int): ProgressListener? = listeners[viewTag] +}