mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
Merge pull request #2444 from vitorpamplona/claude/optimize-okhttp-images-N3KJA
Optimize media loading with improved HTTP connection pooling
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.os.Build
|
||||
|
||||
internal fun isEmulator(): Boolean =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.lowercase().contains("emulator") ||
|
||||
Build.MODEL.contains("google_sdk") ||
|
||||
Build.MODEL.lowercase().contains("droid4x") ||
|
||||
Build.MODEL.contains("Emulator") ||
|
||||
Build.MODEL.contains("Android SDK built for x86") ||
|
||||
Build.MANUFACTURER.contains("Genymotion") ||
|
||||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
|
||||
"google_sdk" == Build.PRODUCT ||
|
||||
Build.HARDWARE.contains("goldfish") ||
|
||||
Build.HARDWARE.contains("ranchu") ||
|
||||
Build.HARDWARE.contains("vbox86") ||
|
||||
Build.HARDWARE.contains("nox") ||
|
||||
Build.HARDWARE.contains("cuttlefish")
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okhttp3.Call
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Handshake
|
||||
import okhttp3.Protocol
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
/**
|
||||
* Records phase timings per call so we can see when media loads are slow
|
||||
* because of DNS, TCP, TLS, first-byte, or because the dispatcher queued
|
||||
* the call (i.e. we hit maxRequestsPerHost / maxRequests).
|
||||
*
|
||||
* Release builds log only slow calls, queued calls, and errors. Debug
|
||||
* builds log every completed call.
|
||||
*/
|
||||
class MediaCallEventListener(
|
||||
private val dispatcher: Dispatcher,
|
||||
private val connectionPool: ConnectionPool,
|
||||
) : EventListener() {
|
||||
private var callStartNanos = 0L
|
||||
private var dnsStartNanos = 0L
|
||||
private var dnsElapsedMs = -1L
|
||||
private var connectStartNanos = 0L
|
||||
private var connectElapsedMs = -1L
|
||||
private var secureStartNanos = 0L
|
||||
private var secureElapsedMs = -1L
|
||||
private var responseHeadersNanos = 0L
|
||||
|
||||
// stays true unless connectStart fires (a new connection was needed)
|
||||
private var connectionReused = true
|
||||
private var queuedAtStart = 0
|
||||
|
||||
override fun callStart(call: Call) {
|
||||
callStartNanos = System.nanoTime()
|
||||
queuedAtStart = dispatcher.queuedCallsCount()
|
||||
}
|
||||
|
||||
override fun dnsStart(
|
||||
call: Call,
|
||||
domainName: String,
|
||||
) {
|
||||
dnsStartNanos = System.nanoTime()
|
||||
}
|
||||
|
||||
override fun dnsEnd(
|
||||
call: Call,
|
||||
domainName: String,
|
||||
inetAddressList: List<InetAddress>,
|
||||
) {
|
||||
dnsElapsedMs = (System.nanoTime() - dnsStartNanos) / 1_000_000
|
||||
}
|
||||
|
||||
override fun connectStart(
|
||||
call: Call,
|
||||
inetSocketAddress: InetSocketAddress,
|
||||
proxy: Proxy,
|
||||
) {
|
||||
connectStartNanos = System.nanoTime()
|
||||
connectionReused = false
|
||||
}
|
||||
|
||||
override fun connectEnd(
|
||||
call: Call,
|
||||
inetSocketAddress: InetSocketAddress,
|
||||
proxy: Proxy,
|
||||
protocol: Protocol?,
|
||||
) {
|
||||
connectElapsedMs = (System.nanoTime() - connectStartNanos) / 1_000_000
|
||||
}
|
||||
|
||||
override fun secureConnectStart(call: Call) {
|
||||
secureStartNanos = System.nanoTime()
|
||||
}
|
||||
|
||||
override fun secureConnectEnd(
|
||||
call: Call,
|
||||
handshake: Handshake?,
|
||||
) {
|
||||
secureElapsedMs = (System.nanoTime() - secureStartNanos) / 1_000_000
|
||||
}
|
||||
|
||||
override fun responseHeadersStart(call: Call) {
|
||||
responseHeadersNanos = System.nanoTime()
|
||||
}
|
||||
|
||||
override fun callEnd(call: Call) = finish(call, null)
|
||||
|
||||
override fun callFailed(
|
||||
call: Call,
|
||||
ioe: IOException,
|
||||
) = finish(call, ioe)
|
||||
|
||||
private fun finish(
|
||||
call: Call,
|
||||
error: IOException?,
|
||||
) {
|
||||
val totalMs = (System.nanoTime() - callStartNanos) / 1_000_000
|
||||
val isSlow = totalMs >= SLOW_CALL_THRESHOLD_MS
|
||||
val wasQueued = queuedAtStart > 0
|
||||
|
||||
if (error == null && !isSlow && !wasQueued && !isDebug) return
|
||||
|
||||
val ttfbMs = if (responseHeadersNanos > 0) (responseHeadersNanos - callStartNanos) / 1_000_000 else -1L
|
||||
val host = call.request().url.host
|
||||
val reuseTag = if (connectionReused) "reused" else "new"
|
||||
|
||||
val msg =
|
||||
buildString {
|
||||
append(host)
|
||||
append(" total=").append(totalMs).append("ms")
|
||||
append(" ttfb=").append(ttfbMs).append("ms")
|
||||
append(" conn=").append(reuseTag)
|
||||
if (!connectionReused) {
|
||||
if (dnsElapsedMs >= 0) append(" dns=").append(dnsElapsedMs).append("ms")
|
||||
if (connectElapsedMs >= 0) append(" tcp=").append(connectElapsedMs).append("ms")
|
||||
if (secureElapsedMs >= 0) append(" tls=").append(secureElapsedMs).append("ms")
|
||||
}
|
||||
if (wasQueued) {
|
||||
append(" QUEUED(depth=").append(queuedAtStart)
|
||||
append(" running=").append(dispatcher.runningCallsCount())
|
||||
append(" pool=").append(connectionPool.connectionCount())
|
||||
append('/').append(connectionPool.idleConnectionCount())
|
||||
append(')')
|
||||
}
|
||||
if (error != null) append(" error=").append(error.javaClass.simpleName).append(':').append(error.message)
|
||||
}
|
||||
|
||||
when {
|
||||
error != null -> Log.w(TAG, msg)
|
||||
isSlow || wasQueued -> Log.i(TAG, msg)
|
||||
else -> Log.d(TAG, msg)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "MediaHttp"
|
||||
const val SLOW_CALL_THRESHOLD_MS = 1500L
|
||||
}
|
||||
}
|
||||
|
||||
class MediaCallEventListenerFactory(
|
||||
private val dispatcher: Dispatcher,
|
||||
private val connectionPool: ConnectionPool,
|
||||
) : EventListener.Factory {
|
||||
override fun create(call: Call): EventListener = MediaCallEventListener(dispatcher, connectionPool)
|
||||
}
|
||||
+25
@@ -24,10 +24,13 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OkHttpClientFactory(
|
||||
keyCache: EncryptionKeyCache,
|
||||
@@ -36,9 +39,31 @@ class OkHttpClientFactory(
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
|
||||
// Most images/videos in a feed come from a small set of hosts (e.g. a single
|
||||
// Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests
|
||||
// per host at 5, which serializes feed loading. Raise the limits so the feed
|
||||
// can parallelize downloads the way a browser does.
|
||||
private val dispatcher =
|
||||
Dispatcher().apply {
|
||||
if (!isEmulator()) {
|
||||
maxRequestsPerHost = 16
|
||||
maxRequests = 128
|
||||
} else {
|
||||
maxRequestsPerHost = 5
|
||||
maxRequests = 64
|
||||
}
|
||||
}
|
||||
|
||||
// Keep more HTTP/2 connections warm so scrolling doesn't repeatedly re-TLS
|
||||
// to the same media host.
|
||||
private val connectionPool = ConnectionPool(32, 5, TimeUnit.MINUTES)
|
||||
|
||||
private val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.dispatcher(dispatcher)
|
||||
.connectionPool(connectionPool)
|
||||
.eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
|
||||
-17
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -38,22 +37,6 @@ class OkHttpClientFactoryForRelays(
|
||||
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
|
||||
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
|
||||
const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120
|
||||
|
||||
private fun isEmulator(): Boolean =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.lowercase().contains("emulator") ||
|
||||
Build.MODEL.contains("google_sdk") ||
|
||||
Build.MODEL.lowercase().contains("droid4x") ||
|
||||
Build.MODEL.contains("Emulator") ||
|
||||
Build.MODEL.contains("Android SDK built for x86") ||
|
||||
Build.MANUFACTURER.contains("Genymotion") ||
|
||||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
|
||||
"google_sdk" == Build.PRODUCT ||
|
||||
Build.HARDWARE.contains("goldfish") ||
|
||||
Build.HARDWARE.contains("ranchu") ||
|
||||
Build.HARDWARE.contains("vbox86") ||
|
||||
Build.HARDWARE.contains("nox") ||
|
||||
Build.HARDWARE.contains("cuttlefish")
|
||||
}
|
||||
|
||||
val myDispatcher =
|
||||
|
||||
Reference in New Issue
Block a user