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.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
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 = 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",
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
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)
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