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