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
69 changes: 51 additions & 18 deletions android/src/main/java/com/turboimage/TurboImageViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<TurboImageView>(), LifecycleEventListener {
override fun getName() = REACT_CLASS
Expand Down Expand Up @@ -60,6 +63,7 @@ class TurboImageViewManager : SimpleViewManager<TurboImageView>(), LifecycleEven
override fun onDropViewInstance(view: TurboImageView) {
super.onDropViewInstance(view)
view.resetLoadSignature()
ProgressListeners.unregister(view.id)
view.dispose()
}

Expand All @@ -77,30 +81,31 @@ class TurboImageViewManager : SimpleViewManager<TurboImageView>(), 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)
Expand Down Expand Up @@ -252,6 +257,34 @@ class TurboImageViewManager : SimpleViewManager<TurboImageView>(), 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<Boolean, ImageLoader>()

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<Int, ProgressListener>()

fun register(viewTag: Int, listener: ProgressListener) {
listeners[viewTag] = listener
}

fun unregister(viewTag: Int) {
listeners.remove(viewTag)
}

fun get(viewTag: Int): ProgressListener? = listeners[viewTag]
}
Loading