perf: tune image/video OkHttp dispatcher and connection pool

OkHttp's default dispatcher caps inflight requests per host at 5 and
total inflight at 64. Amethyst feeds typically pull most media from a
single host (e.g. a primary Blossom/imgproxy server), so that per-host
cap serialized feed loading — the browser-feels-faster effect.

- Give the images/videos factory a dedicated Dispatcher (16 per host,
  128 total on device; 5/64 on emulator).
- Give it a larger ConnectionPool (32 idle, 5 min keep-alive) so HTTP/2
  connections to the common media host stay warm across scrolls and
  avoid repeated TLS handshakes.
- Extract isEmulator() to a shared helper used by both the relay and
  image/video factories.

The relay factory is untouched (already tuned for websockets).

https://claude.ai/code/session_01PUbqGyUc6oq6V1MdmLi8sw
This commit is contained in:
Claude
2026-04-18 19:34:12 +00:00
parent 8c71d490a7
commit 9cca64f6ac
3 changed files with 63 additions and 17 deletions
@@ -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")
@@ -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,30 @@ 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)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
@@ -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 =