git.iko.soy/opencode-android
on master help sign in
Update all dependencies to latest: AGP 9.2, Kotlin 2.4, Gradle 9.6.1, compileSdk 37, Coil 3, Compose BOM 2026.06, markdown-renderer 0.43
commit2781a1a8208b1c7757586167d0314902cdad7f23 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 16:49 (3 months ago) parent16c6bac
13 files changed+107−66
AGENTS.md+5−5 README.md+1−1 app/build.gradle.kts+16−5 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+20−10 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+24−10 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+11−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+3−2 build.gradle.kts−1 flake.nix+2−2 gradle/libs.versions.toml+22−22 gradle/wrapper/gradle-wrapper.properties+1−1 renovate.json+1−1
AGENTS.md+5−5
@@ -12,12 +12,12 @@ an adaptive single-pane / two-pane Material 3 UI.
1212
1313- Application id / namespace: `soy.iko.opencode`
1414- Package root: `app/src/main/java/soy/iko/opencode`
15−- minSdk 26, compileSdk/targetSdk 35, JDK 17, Kotlin 2.0.x, AGP 8.7.x
15+- minSdk 26, compileSdk/targetSdk 37, JDK 17, Kotlin 2.2.x, AGP 9.2.x
1616- Single Gradle module (`:app`); no dynamic feature modules.
1717
1818## Build & environment
1919
20−A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 35, and a
20+A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 37, and a
2121Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
2222
2323```bash
@@ -26,8 +26,8 @@ nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HO
2626./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
2727```
2828
29−Outside Nix, you need JDK 17 and the Android SDK (platform 35, build-tools
30−35.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
29+Outside Nix, you need JDK 17 and the Android SDK (platform 37, build-tools
30+37.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
3131
3232Install on a connected device/emulator:
3333
@@ -194,4 +194,4 @@ util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
194194
195195`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196196Ktor is grouped; AndroidX majors are held for manual review (pinned to
197−compileSdk 35). Dependency upgrades land via Renovate PRs, not manual bumps.
197+compileSdk 37). Dependency upgrades land via Renovate PRs, not manual bumps.
README.md+1−1
@@ -73,7 +73,7 @@ Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/
7373## Build
7474
7575```bash
76−nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
76+nix develop # JDK 17, Gradle 9.6.1, Android SDK 37, aapt2 override
7777./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
7878./gradlew assembleRelease # R8-minified + resource-shrunk release APK
7979./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
app/build.gradle.kts+16−5
@@ -1,6 +1,5 @@
11plugins {
22 alias(libs.plugins.android.application)
3− alias(libs.plugins.kotlin.android)
43 alias(libs.plugins.kotlin.serialization)
54 alias(libs.plugins.kotlin.compose)
65 alias(libs.plugins.detekt)
@@ -24,13 +23,13 @@ val versionNameOverride: String? = providers.gradleProperty("versionName").orNul
2423
2524android {
2625 namespace = "soy.iko.opencode"
27− compileSdk = 35
28− buildToolsVersion = "35.0.0"
26+ compileSdk = 37
27+ buildToolsVersion = "37.0.0"
2928
3029 defaultConfig {
3130 applicationId = "soy.iko.opencode"
3231 minSdk = 26
33− targetSdk = 35
32+ targetSdk = 37
3433 // Monotonically increasing across builds: derived from the git commit count so
3534 // each release ships a strictly higher versionCode without manual bookkeeping
3635 // (and never triggers a "downgrade" install block). Falls back to 1 when the
@@ -90,7 +89,18 @@ android {
9089 // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
9190 // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
9291 // match-index scan), so it's disabled rather than worked around at each call site.
93− disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "ProduceStateDoesNotAssignValue")
92+ // InvalidFragmentVersionForActivityResult false-positives on FragmentActivity
93+ // (the app doesn't use fragments, but BiometricPrompt requires FragmentActivity).
94+ // LocalContextGetResourceValueCall flags context.getString() inside LaunchedEffect /
95+ // remember lambdas where stringResource() can't be called (not a @Composable scope);
96+ // the check is overly aggressive for valid non-composable coroutine contexts.
97+ disable += setOf(
98+ "GradleDependency",
99+ "AndroidGradlePluginVersion",
100+ "ProduceStateDoesNotAssignValue",
101+ "InvalidFragmentVersionForActivityResult",
102+ "LocalContextGetResourceValueCall",
103+ )
94104 }
95105
96106 packaging {
@@ -140,6 +150,7 @@ dependencies {
140150 implementation(libs.androidx.security.crypto)
141151 implementation(libs.androidx.biometric)
142152 implementation(libs.coil.compose)
153+ implementation(libs.coil.network.okhttp)
143154 implementation(libs.markdown.renderer)
144155 implementation(libs.markdown.renderer.m3)
145156 implementation(libs.zxing.core)
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+20−10
@@ -1,22 +1,25 @@
11package soy.iko.opencode
22
33import android.app.Application
4−import coil.ImageLoader
5−import coil.ImageLoaderFactory
6−import coil.disk.DiskCache
7−import coil.memory.MemoryCache
4+import android.content.Context
5+import coil3.ImageLoader
6+import coil3.SingletonImageLoader
7+import coil3.disk.DiskCache
8+import coil3.memory.MemoryCache
9+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
810import okhttp3.Call
911import okhttp3.CertificatePinner
1012import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
1113import okhttp3.OkHttpClient
1214import okhttp3.Request
15+import okio.Path.Companion.toOkioPath
1316import soy.iko.opencode.data.model.ServerProfile
1417import soy.iko.opencode.data.network.CertPinUtil
1518import soy.iko.opencode.data.repo.CrashLogger
1619import soy.iko.opencode.di.AppContainer
1720import java.util.concurrent.atomic.AtomicBoolean
1821
19−class OpencodeApp : Application(), ImageLoaderFactory {
22+class OpencodeApp : Application(), SingletonImageLoader.Factory {
2023 lateinit var container: AppContainer
2124 private set
2225
@@ -43,19 +46,26 @@ class OpencodeApp : Application(), ImageLoaderFactory {
4346 // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry the
4447 // server's HTTP Basic auth header (see RemoteImage), so without this they would travel over
4548 // an UNPINNED TLS connection, silently defeating the pin the user opted into for that host.
46− override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
49+ override fun newImageLoader(context: Context): ImageLoader = ImageLoader.Builder(context)
4750 .memoryCache {
48− MemoryCache.Builder(this)
49− .maxSizePercent(0.25)
51+ MemoryCache.Builder()
52+ .maxSizePercent(context, 0.25)
5053 .build()
5154 }
5255 .diskCache {
5356 DiskCache.Builder()
54− .directory(cacheDir.resolve("image_cache"))
57+ .directory(cacheDir.resolve("image_cache").toOkioPath())
5558 .maxSizeBytes(50L * 1024 * 1024)
5659 .build()
5760 }
58− .callFactory { PinnedImageCallFactory(container) }
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+ }
5969 .build()
6070
6171 override fun onTerminate() {
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+1−1
@@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
3838import androidx.compose.ui.unit.dp
3939import androidx.compose.ui.window.Dialog
4040import androidx.compose.ui.window.DialogProperties
41−import coil.compose.AsyncImage
41+import coil3.compose.AsyncImage
4242import soy.iko.opencode.R
4343import soy.iko.opencode.data.network.NetworkConfig
4444import java.util.Locale
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+24−10
@@ -72,6 +72,9 @@ import androidx.compose.ui.text.font.FontFamily
7272import androidx.compose.ui.unit.dp
7373import kotlinx.coroutines.flow.conflate
7474import kotlinx.coroutines.delay
75+import com.mikepenz.markdown.annotator.AnnotatorSettings
76+import com.mikepenz.markdown.annotator.DefaultAnnotatorSettings
77+import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
7578import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
7679import com.mikepenz.markdown.compose.LocalMarkdownColors
7780import com.mikepenz.markdown.compose.LocalMarkdownTypography
@@ -80,7 +83,7 @@ import com.mikepenz.markdown.compose.components.markdownComponents
8083import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownText
8184import com.mikepenz.markdown.m3.Markdown
8285import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
83−import com.mikepenz.markdown.utils.buildMarkdownAnnotatedString
86+import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig
8487import org.intellij.markdown.MarkdownElementTypes
8588import org.intellij.markdown.ast.ASTNode
8689import org.intellij.markdown.ast.getTextInNode
@@ -217,7 +220,7 @@ private fun MarkdownBody(
217220 // rather than extracted to a helper to keep this file under detekt's TooManyFunctions threshold.
218221 val inlineCodeAnnotator = remember(baseAnnotator, inlineCodeTypography, inlineCodeColors) {
219222 val codeStyle = inlineCodeTypography.inlineCode.copy(
220− color = inlineCodeColors.inlineCodeText,
223+ color = inlineCodeTypography.inlineCode.color,
221224 background = inlineCodeColors.inlineCodeBackground,
222225 ).toSpanStyle()
223226 DefaultMarkdownAnnotator(
@@ -247,6 +250,7 @@ private fun MarkdownBody(
247250 baseAnnotator.annotate?.invoke(this, content, node) ?: false
248251 }
249252 },
253+ config = DefaultMarkdownAnnotatorConfig(),
250254 )
251255 }
252256 // Compose the inline-code tagger with the search-highlight wrapper when a query is active.
@@ -271,6 +275,7 @@ private fun MarkdownBody(
271275 }
272276 handled
273277 },
278+ config = DefaultMarkdownAnnotatorConfig(),
274279 )
275280 }
276281 } else {
@@ -317,12 +322,12 @@ private fun MarkdownBody(
317322 // interrupting on every token; the reader hears the reply grow in measured chunks.
318323 CompositionLocalProvider(
319324 LocalUriHandler provides safeUriHandler,
320− LocalMarkdownAnnotator provides effectiveAnnotator,
321325 ) {
322326 Markdown(
323327 content = renderedContent,
324328 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
325329 components = components,
330+ annotator = effectiveAnnotator,
326331 )
327332 }
328333 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
@@ -336,12 +341,12 @@ private fun MarkdownBody(
336341 content = markdown,
337342 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
338343 components = components,
344+ annotator = effectiveAnnotator,
339345 )
340346 }
341347 }
342348 CompositionLocalProvider(
343349 LocalUriHandler provides safeUriHandler,
344− LocalMarkdownAnnotator provides effectiveAnnotator,
345350 ) {
346351 content()
347352 }
@@ -465,12 +470,20 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
465470 val context = LocalContext.current
466471 val haptics = LocalHapticFeedback.current
467472 val style = model.typography.paragraph
468− // buildMarkdownAnnotatedString is @Composable (it reads LocalMarkdownAnnotator to run the
469− // inline-code tagger installed in MarkdownBody), so the annotated string can't be built in
470− // a `remember` block. The library's own MarkdownParagraph does the same.
473+ val typography = LocalMarkdownTypography.current
474+ val annotator = LocalMarkdownAnnotator.current
475+ // buildMarkdownAnnotatedString needs an explicit AnnotatorSettings in 0.43+ (the old
476+ // overload that read LocalMarkdownAnnotator internally was removed). Build one from the
477+ // current composition locals so the inline-code tagger installed in MarkdownBody still
478+ // runs. The library's own MarkdownParagraph does the same.
479+ val settings = DefaultAnnotatorSettings(
480+ linkTextSpanStyle = typography.textLink,
481+ codeSpanStyle = typography.inlineCode.toSpanStyle(),
482+ annotator = annotator,
483+ )
471484 val styledText = buildAnnotatedString {
472485 pushStyle(style.toSpanStyle())
473− buildMarkdownAnnotatedString(model.content, model.node)
486+ buildMarkdownAnnotatedString(model.content, model.node, settings)
474487 pop()
475488 }
476489 // Capture the layout result so a long-press position can be mapped to a character offset,
@@ -478,7 +491,7 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
478491 var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
479492 LibraryMarkdownText(
480493 content = styledText,
481− style = style,
494+ node = model.node,
482495 modifier = Modifier.pointerInput(styledText) {
483496 detectTapGestures(
484497 onLongPress = { pos ->
@@ -493,7 +506,8 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
493506 },
494507 )
495508 },
496− onTextLayout = { layoutResult = it },
509+ style = style,
510+ onTextLayout = { result, _ -> layoutResult = result },
497511 )
498512}
499513
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+11−5
@@ -46,8 +46,12 @@ import androidx.compose.ui.text.style.TextOverflow
4646import androidx.compose.ui.unit.dp
4747import androidx.compose.ui.window.Dialog
4848import androidx.compose.ui.window.DialogProperties
49−import coil.compose.SubcomposeAsyncImage
50−import coil.request.ImageRequest
49+import coil3.compose.SubcomposeAsyncImage
50+import coil3.network.NetworkHeaders
51+import coil3.network.httpHeaders
52+import coil3.request.ImageRequest
53+import coil3.request.crossfade
54+import coil3.toBitmap
5155import kotlinx.coroutines.Dispatchers
5256import kotlinx.coroutines.launch
5357import kotlin.math.absoluteValue
@@ -189,7 +193,9 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
189193 // resolveModel already verified same-origin, so a String model can only point at
190194 // the user's own server — attaching Basic auth over http (not just https) is safe.
191195 // A data-URI model is a ByteArray, so gate on the model being a URL String.
192− if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
196+ if (model is String) ctx.basicAuthHeader?.let {
197+ httpHeaders(NetworkHeaders.Builder().add("Authorization", it).build())
198+ }
193199 }
194200 .crossfade(true)
195201 .build()
@@ -542,9 +548,9 @@ private suspend fun saveImageToGallery(
542548 * failure. Suspending so callers run it on a background dispatcher. */
543549private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
544550 return soy.iko.opencode.util.runCatchingCancellableSuspend {
545− val loader = coil.Coil.imageLoader(context)
551+ val loader = coil3.SingletonImageLoader.get(context)
546552 val result = loader.execute(request)
547− (result.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
553+ result.image?.toBitmap()
548554 }.getOrNull()
549555}
550556
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+3−2
@@ -96,8 +96,9 @@ import androidx.compose.ui.unit.dp
9696import androidx.compose.ui.res.stringResource
9797import androidx.lifecycle.compose.collectAsStateWithLifecycle
9898import androidx.lifecycle.viewmodel.compose.viewModel
99−import coil.compose.SubcomposeAsyncImage
100−import coil.request.ImageRequest
99+import coil3.compose.SubcomposeAsyncImage
100+import coil3.request.ImageRequest
101+import coil3.request.crossfade
101102import soy.iko.opencode.di.AppContainer
102103import soy.iko.opencode.R
103104import soy.iko.opencode.ui.components.ConnectionBannerFor
build.gradle.kts−1
@@ -1,6 +1,5 @@
11plugins {
22 alias(libs.plugins.android.application) apply false
3− alias(libs.plugins.kotlin.android) apply false
43 alias(libs.plugins.kotlin.serialization) apply false
54 alias(libs.plugins.kotlin.compose) apply false
65}
flake.nix+2−2
@@ -10,8 +10,8 @@
1010 flake-utils.lib.eachDefaultSystem (system:
1111 let
1212 # Versions kept in sync with the Gradle project (compileSdk / build-tools).
13− platformVersion = "35";
14− buildToolsVersion = "35.0.0";
13+ platformVersion = "37";
14+ buildToolsVersion = "37.0.0";
1515
1616 pkgs = import nixpkgs {
1717 inherit system;
gradle/libs.versions.toml+22−22
@@ -1,28 +1,28 @@
11[versions]
2−agp = "8.7.3"
3−kotlin = "2.0.21"
4−ktor = "3.0.3"
5−kotlinxSerialization = "1.7.3"
6−kotlinxCoroutines = "1.9.0"
7−composeBom = "2024.12.01"
8−activityCompose = "1.9.3"
9−lifecycle = "2.8.7"
10−navigationCompose = "2.8.5"
11−datastore = "1.1.1"
2+agp = "9.2.0"
3+kotlin = "2.4.0"
4+ktor = "3.1.3"
5+kotlinxSerialization = "1.9.0"
6+kotlinxCoroutines = "1.10.2"
7+composeBom = "2026.06.01"
8+activityCompose = "1.13.0"
9+lifecycle = "2.11.0"
10+navigationCompose = "2.9.8"
11+datastore = "1.2.1"
1212securityCrypto = "1.1.0"
13−coil = "2.7.0"
13+coil = "3.5.0"
1414biometric = "1.1.0"
15−markdownRenderer = "0.27.0"
16−zxingCore = "3.5.3"
17−coreKtx = "1.13.1"
18−splashscreen = "1.0.1"
19−window = "1.3.0"
15+markdownRenderer = "0.43.0"
16+zxingCore = "3.5.4"
17+coreKtx = "1.19.0"
18+splashscreen = "1.2.0"
19+window = "1.5.1"
2020junit = "4.13.2"
2121detekt = "1.23.8"
22−androidxTestExt = "1.2.1"
23−androidxTestRunner = "1.6.2"
24−androidxTestCore = "1.6.1"
25−espresso = "3.6.1"
22+androidxTestExt = "1.3.0"
23+androidxTestRunner = "1.7.0"
24+androidxTestCore = "1.7.0"
25+espresso = "3.7.0"
2626
2727[libraries]
2828androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -55,7 +55,8 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
5555
5656androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
5757androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
58−coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
58+coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
59+coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
5960androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
6061markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
6162markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
@@ -69,7 +70,6 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
6970
7071[plugins]
7172android-application = { id = "com.android.application", version.ref = "agp" }
72−kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
7373kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
7474kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
7575detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
gradle/wrapper/gradle-wrapper.properties+1−1
@@ -1,6 +1,6 @@
11distributionBase=GRADLE_USER_HOME
22distributionPath=wrapper/dists
3−distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
3+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
44networkTimeout=10000
55validateDistributionUrl=true
66zipStoreBase=GRADLE_USER_HOME
renovate.json+1−1
@@ -24,7 +24,7 @@
2424 "matchPackagePatterns": ["^io.ktor"]
2525 },
2626 {
27− "description": "Pin AndroidX to the set the app targets (compileSdk 35); review major bumps manually.",
27+ "description": "Pin AndroidX to the set the app targets (compileSdk 37); review major bumps manually.",
2828 "matchPackagePatterns": ["^androidx."],
2929 "major": { "enabled": false }
3030 }