git.iko.soy/opencode-android
on 366f92e help sign in
136 lines 6.5 kB history raw
1package soy.iko.opencode
3import android.app.Application
4import coil.ImageLoader
5import coil.ImageLoaderFactory
6import coil.disk.DiskCache
7import coil.memory.MemoryCache
8import okhttp3.Call
9import okhttp3.CertificatePinner
10import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
11import okhttp3.OkHttpClient
12import okhttp3.Request
13import soy.iko.opencode.data.model.ServerProfile
14import soy.iko.opencode.data.network.HttpClientFactory
15import soy.iko.opencode.data.repo.CrashLogger
16import soy.iko.opencode.di.AppContainer
17import java.util.concurrent.atomic.AtomicBoolean
19class OpencodeApp : Application(), ImageLoaderFactory {
20 lateinit var container: AppContainer
21 private set
23 private val shutdownCalled = AtomicBoolean(false)
25 override fun onCreate() {
26 super.onCreate()
27 CrashLogger.get(this).install()
28 container = AppContainer(this)
29 // onTerminate() is only called in emulated process environments, not on real
30 // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
31 // scope cancellation, connection close) runs when the process is actually killed.
32 Runtime.getRuntime().addShutdownHook(Thread {
33 shutdown()
34 })
35 }
37 // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
38 // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
39 // the (auth-protected) network every time. Data-URI (base64) images skip the disk
40 // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
41 //
42 // Route fetches through a Call.Factory that applies the SAME per-profile certificate
43 // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry the
44 // server's HTTP Basic auth header (see RemoteImage), so without this they would travel over
45 // an UNPINNED TLS connection, silently defeating the pin the user opted into for that host.
46 override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
47 .memoryCache {
48 MemoryCache.Builder(this)
49 .maxSizePercent(0.25)
50 .build()
51 }
52 .diskCache {
53 DiskCache.Builder()
54 .directory(cacheDir.resolve("image_cache"))
55 .maxSizeBytes(50L * 1024 * 1024)
56 .build()
57 }
58 .callFactory { PinnedImageCallFactory(container) }
59 .build()
61 override fun onTerminate() {
62 super.onTerminate()
63 shutdown()
64 }
66 private fun shutdown() {
67 // Guard against double shutdown — onTerminate and the shutdown hook can both
68 // fire in emulated environments.
69 if (!shutdownCalled.compareAndSet(false, true)) return
70 container.shutdown()
71 }
74/**
75 * A Coil [Call.Factory] that pins image loads exactly like [HttpClientFactory] pins the
76 * REST/SSE channel. The [ImageLoader] is a process singleton built once, but the active
77 * [ServerProfile] — and therefore its pins — changes when the user switches servers, so the
78 * certificate pinner is resolved *per request* from [AppContainer.activeConnection] rather than
79 * baked in at build time. This fails closed: a profile that configures pins always gets a pinned
80 * connection for its images (an unparseable pinned host throws, matching HttpClientFactory), so
81 * image loads for a pinned host are never silently downgraded to an unpinned channel — and a
82 * later server switch is picked up immediately instead of retaining the first profile's pins.
83 */
84private class PinnedImageCallFactory(private val container: AppContainer) : Call.Factory {
85 // A single shared base client; each derived pinned client reuses this one's connection pool
86 // and dispatcher via newBuilder(), so per-profile pinning doesn't spin up parallel resources.
87 private val base = OkHttpClient()
89 // Cache the last derived client keyed by (host, pins) so we don't rebuild it on every request;
90 // a server switch changes the signature and triggers a rebuild. Guarded because Coil invokes
91 // newCall from arbitrary background threads, possibly concurrently.
92 private val lock = Any()
93 private var cachedSignature: String? = null
94 private var cachedClient: OkHttpClient = base
96 override fun newCall(request: Request): Call =
97 clientFor(container.activeConnection.value?.profile).newCall(request)
99 private fun clientFor(profile: ServerProfile?): OkHttpClient {
100 val pins = parsePins(profile?.certPin)
101 if (pins.isEmpty()) return base
102 // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
103 // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
104 // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
105 val effectiveUrl = effectiveBaseUrl(profile!!)
106 val host = effectiveUrl.toHttpUrlOrNull()?.host
107 ?: throw IllegalStateException(
108 "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
109 )
110 val signature = "$host\n${pins.sorted().joinToString(" ")}"
111 synchronized(lock) {
112 if (signature != cachedSignature) {
113 val pinner = CertificatePinner.Builder().apply { pins.forEach { add(host, it) } }.build()
114 cachedClient = base.newBuilder().certificatePinner(pinner).build()
115 cachedSignature = signature
116 }
117 return cachedClient
118 }
119 }
121 // parsePins / effectiveBaseUrl mirror the private helpers in HttpClientFactory (the source of
122 // truth). This file may not edit HttpClientFactory and those helpers aren't exposed, so the
123 // pin-parsing and http->https upgrade logic is replicated here and must be kept in sync.
124 private fun parsePins(raw: String?): List<String> =
125 raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
127 private fun effectiveBaseUrl(profile: ServerProfile): String {
128 val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
129 val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
130 return if (forceHttps && normalized.lowercase().startsWith("http://")) {
131 "https://" + normalized.substring("http://".length)
132 } else {
133 normalized
134 }
135 }