1package soy.iko.opencode
3import android.app.Application
5import coil.ImageLoaderFactory
6import coil.disk.DiskCache
7import coil.memory.MemoryCache
9import okhttp3.CertificatePinner
10import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
11import okhttp3.OkHttpClient
13import soy.iko.opencode.data.model.ServerProfile
14import soy.iko.opencode.data.network.CertPinUtil
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
23 private val shutdownCalled = AtomicBoolean(false)
25 override fun 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 {
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.
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)
48 MemoryCache.Builder(this)
54 .directory(cacheDir.resolve("image_cache"))
55 .maxSizeBytes(50L * 1024 * 1024)
58 .callFactory { PinnedImageCallFactory(container) }
61 override fun onTerminate() {
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
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.
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 = CertPinUtil.parseCertPins(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 = CertPinUtil.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",
110 val signature = "$host\n${pins.sorted().joinToString(" ")}"
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
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list