git.iko.soy/opencode-android
on master help sign in
Add crash reporting with diagnostics, completion notifications, foreground SSE service, adaptive two-pane layout, deep linking, accessibility pass, and CI tooling (lint, detekt, Renovate)
44de46eiko
.github/workflows/build.yml+11−2
@@ -24,6 +24,12 @@ jobs:
24 - name: Run unit tests
24 - name: Run unit tests
25 run: ./gradlew testDebugUnitTest --no-daemon
25 run: ./gradlew testDebugUnitTest --no-daemon
26
26
27+ - name: Static analysis (detekt)
28+ run: ./gradlew detekt --no-daemon
29+
30+ - name: Lint
31+ run: ./gradlew lintDebug --no-daemon
32+
27 - name: Build debug APK
33 - name: Build debug APK
28 run: ./gradlew assembleDebug --no-daemon
34 run: ./gradlew assembleDebug --no-daemon
29
35
@@ -37,9 +43,12 @@ jobs:
37 path: app/build/outputs/apk/debug/*.apk
43 path: app/build/outputs/apk/debug/*.apk
38 if-no-files-found: error
44 if-no-files-found: error
39
45
46+ # The release output dir is `release/` when unsigned but `release-signed/` (or a
47+ # variant name) once a signing config is applied. Match both so CI keeps working
48+ # once the OPENCODE_STORE_* secrets are wired up.
40 - name: Upload release APK
49 - name: Upload release APK
41 uses: actions/upload-artifact@v4
50 uses: actions/upload-artifact@v4
42 with:
51 with:
43− name: opencode-release-unsigned.apk
52+ name: opencode-release.apk
44− path: app/build/outputs/apk/release/*.apk
53+ path: app/build/outputs/apk/**/*.apk
45 if-no-files-found: error
54 if-no-files-found: error
.gitignore+1
@@ -7,6 +7,7 @@ build/
7
7
8# Local configuration (signing keys, api keys, etc.)
8# Local configuration (signing keys, api keys, etc.)
9local.properties
9local.properties
10+.buildenv.sh
10*.keystore
11*.keystore
11*.jks
12*.jks
12
13
README.md+32−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M10 complete:
9+Milestones M0–M11 complete:
10
10
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -69,6 +69,34 @@ Milestones M0–M10 complete:
69 sheet); and an **auto-incrementing `versionCode`** derived from the git commit count
69 sheet); and an **auto-incrementing `versionCode`** derived from the git commit count
70 so each release is strictly newer.
70 so each release is strictly newer.
71
71
72+- **M11:** **Reliability, a11y & tooling pass.**
73+ - **Completion notifications** — when a background session finishes a run it posts a
74+ system notification; tapping it opens the conversation. POST_NOTIFICATIONS is
75+ requested on Android 13+ and respected if denied.
76+ - **Foreground SSE service** — while an agent is running a low-priority foreground
77+ service holds process priority so Doze can't choke the long-lived `/event` stream
78+ mid-run when the app is backgrounded.
79+ - **Crash reporting + Diagnostics** — an uncaught-exception handler writes crash
80+ reports (stack trace + device/app metadata) to app-private storage, surfaced in a
81+ new Settings → Diagnostics screen where they can be viewed, shared, or cleared. No
82+ hosted backend required.
83+ - **Adaptive large-screen layout** — on wide screens (tablets / unfolded foldables,
84+ ≥ 840dp) the session list and the open conversation show side by side; compact
85+ widths keep the single-pane back stack.
86+ - **Deep linking** — `opencode://session/{id}` opens a conversation from any app, and
87+ notification taps / share-in reuse the same open-session path.
88+ - **Accessibility** — every clickable row is announced as a button to TalkBack
89+ (`role` semantics); all icon buttons carry content descriptions.
90+ - **Crash bug fix** — `StateFlow.value` was read inside composition (server switch
91+ wouldn't recompose the image context); now collected as state.
92+ - **App icon** — an adaptive launcher icon was added (lint flagged its absence).
93+ - **Tooling/CI:** Android **Lint** and **detekt** now run in CI (detekt with a
94+ baseline so only new findings block); a **Renovate** config keeps dependencies
95+ current (grouped, weekly, AndroidX majors held for review); the deprecated
96+ `kotlinOptions` DSL was replaced; and the release-artifact upload matches the
97+ signed-output path. Instrumented tests (androidTest) cover the crash logger and
98+ app bootstrap.
99+
72## Architecture
100## Architecture
73
101
74- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
102- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
@@ -92,6 +120,9 @@ nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
92./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
120./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
93./gradlew assembleRelease # R8-minified + resource-shrunk release APK
121./gradlew assembleRelease # R8-minified + resource-shrunk release APK
94./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
122./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
123+./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
124+./gradlew lintDebug # Android Lint
125+./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
95```
126```
96
127
97## Install & run
128## Install & run
app/build.gradle.kts+28−2
@@ -3,6 +3,7 @@ plugins {
3 alias(libs.plugins.kotlin.android)
3 alias(libs.plugins.kotlin.android)
4 alias(libs.plugins.kotlin.serialization)
4 alias(libs.plugins.kotlin.serialization)
5 alias(libs.plugins.kotlin.compose)
5 alias(libs.plugins.kotlin.compose)
6+ alias(libs.plugins.detekt)
6}
7}
7
8
8/**
9/**
@@ -63,14 +64,26 @@ android {
63 targetCompatibility = JavaVersion.VERSION_17
64 targetCompatibility = JavaVersion.VERSION_17
64 }
65 }
65
66
66− kotlinOptions {
67+ // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
67− jvmTarget = "17"
68+ // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
69+ kotlin {
70+ compilerOptions {
71+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
72+ }
68 }
73 }
69
74
70 buildFeatures {
75 buildFeatures {
71 compose = true
76 compose = true
72 }
77 }
73
78
79+ lint {
80+ // Fail CI on real errors. Warnings are reported but don't break the build so
81+ // minor/style issues don't gate development.
82+ abortOnError = true
83+ // Dependency upgrades are tracked by Renovate, so don't lint for them here.
84+ disable += setOf("GradleDependency", "AndroidGradlePluginVersion")
85+ }
86+
74 packaging {
87 packaging {
75 resources {
88 resources {
76 excludes += "/META-INF/{AL2.0,LGPL2.1}"
89 excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -113,4 +126,17 @@ dependencies {
113 testImplementation(libs.junit)
126 testImplementation(libs.junit)
114 testImplementation(libs.kotlinx.coroutines.test)
127 testImplementation(libs.kotlinx.coroutines.test)
115 testImplementation(libs.ktor.client.mock)
128 testImplementation(libs.ktor.client.mock)
129+
130+ androidTestImplementation(libs.androidx.test.ext.junit)
131+ androidTestImplementation(libs.androidx.test.runner)
132+ androidTestImplementation(libs.androidx.test.core)
133+ androidTestImplementation(libs.kotlinx.coroutines.test)
134+}
135+
136+detekt {
137+ buildUponDefaultConfig = true
138+ // Carry forward the current baseline so CI only fails on *new* findings;
139+ // existing issues are grandfathered rather than blocking the migration.
140+ baseline = file("detekt-baseline.xml")
141+ config.setFrom("$rootDir/config/detekt/detekt.yml")
116}
142}
app/detekt-baseline.xmlnew file+15
@@ -0,0 +1,15 @@
1+<?xml version="1.0" ?>
2+<SmellBaseline>
3+ <ManuallySuppressedIssues/>
4+ <CurrentIssues>
5+ <ID>ComplexCondition:ChatScreen.kt$event.type == KeyEventType.KeyDown &amp;&amp; event.key == Key.Enter &amp;&amp; !event.isShiftPressed &amp;&amp; enabled &amp;&amp; value.isNotBlank()</ID>
6+ <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, )</ID>
8+ <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
9+ <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
10+ <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
11+ <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
12+ <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
13+ <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
14+ </CurrentIssues>
15+</SmellBaseline>
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import org.junit.Assert.assertNotNull
6+import org.junit.Assert.assertTrue
7+import org.junit.Test
8+import org.junit.runner.RunWith
9+
10+/**
11+ * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12+ * are initialized on startup, and the notification channels are created.
13+ */
14+@RunWith(AndroidJUnit4::class)
15+class ApplicationBootstrapTest {
16+
17+ @Test
18+ fun containerIsInitialized() {
19+ val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20+ assertNotNull(app.container)
21+ assertNotNull(app.container.settingsStore)
22+ }
23+
24+ @Test
25+ fun notificationChannelsExist() {
26+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27+ val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28+ // Touching the app instance runs Application.onCreate, which creates the channels.
29+ ApplicationProvider.getApplicationContext<OpencodeApp>()
30+ val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31+ as android.app.NotificationManager
32+ val ids = nm.notificationChannels.map { it.id }
33+ assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34+ assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35+ }
36+}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktnew file+67
@@ -0,0 +1,67 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import org.junit.Assert.assertEquals
6+import org.junit.Assert.assertNotNull
7+import org.junit.Assert.assertNull
8+import org.junit.Assert.assertTrue
9+import org.junit.Test
10+import org.junit.runner.RunWith
11+
12+/**
13+ * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14+ * real app-private filesystem: a simulated crash is written, then read back, listed, and
15+ * deleted through the public API.
16+ */
17+@RunWith(AndroidJUnit4::class)
18+class CrashLoggerTest {
19+
20+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21+ private val logger = CrashLogger.get(context)
22+
23+ @Test
24+ fun writeReadDeleteRoundTrip() {
25+ logger.clearAll()
26+
27+ // Simulate an uncaught exception by invoking the report path directly.
28+ val report = writeFakeReport()
29+
30+ logger.refresh()
31+ assertEquals(1, logger.reportCount())
32+
33+ val content = logger.readReport(report)
34+ assertNotNull(content)
35+ assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36+ assertTrue("report should contain device metadata", content.contains("Android:"))
37+
38+ logger.deleteReport(report)
39+ assertEquals(0, logger.reportCount())
40+ assertNull(logger.readReport(report))
41+ }
42+
43+ @Test
44+ fun clearAllRemovesEveryReport() {
45+ writeFakeReport("a")
46+ writeFakeReport("b")
47+ logger.refresh()
48+ assertTrue(logger.reportCount() >= 2)
49+
50+ logger.clearAll()
51+ assertEquals(0, logger.reportCount())
52+ }
53+
54+ private fun writeFakeReport(tag: String = "test"): String {
55+ val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56+ val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57+ java.io.File(dir, name).writeText(
58+ """
59+ opencode-android crash report
60+ Android: test
61+ java.lang.IllegalStateException: boom $tag
62+ at Crasher.crash(Crasher.kt:1)
63+ """.trimIndent(),
64+ )
65+ return name
66+ }
67+}
app/src/main/AndroidManifest.xml+18−1
@@ -3,10 +3,16 @@
3
3
4 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.INTERNET" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
8+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
6
9
7 <application
10 <application
8 android:name=".OpencodeApp"
11 android:name=".OpencodeApp"
9 android:allowBackup="false"
12 android:allowBackup="false"
13+ android:dataExtractionRules="@xml/data_extraction_rules"
14+ android:fullBackupContent="false"
15+ android:icon="@mipmap/ic_launcher"
10 android:label="@string/app_name"
16 android:label="@string/app_name"
11 android:networkSecurityConfig="@xml/network_security_config"
17 android:networkSecurityConfig="@xml/network_security_config"
12 android:supportsRtl="true"
18 android:supportsRtl="true"
@@ -15,7 +21,6 @@
15 <activity
21 <activity
16 android:name=".MainActivity"
22 android:name=".MainActivity"
17 android:exported="true"
23 android:exported="true"
18− android:label="@string/app_name"
19 android:launchMode="singleTop"
24 android:launchMode="singleTop"
20 android:theme="@style/Theme.OpencodeAndroid.Splash">
25 android:theme="@style/Theme.OpencodeAndroid.Splash">
21 <intent-filter>
26 <intent-filter>
@@ -27,7 +32,19 @@
27 <category android:name="android.intent.category.DEFAULT" />
32 <category android:name="android.intent.category.DEFAULT" />
28 <data android:mimeType="text/plain" />
33 <data android:mimeType="text/plain" />
29 </intent-filter>
34 </intent-filter>
35+ <!-- Deep link to a session: opencode://session/{sessionId} -->
36+ <intent-filter>
37+ <action android:name="android.intent.action.VIEW" />
38+ <category android:name="android.intent.category.DEFAULT" />
39+ <category android:name="android.intent.category.BROWSABLE" />
40+ <data android:scheme="opencode" android:host="session" />
41+ </intent-filter>
30 </activity>
42 </activity>
43+
44+ <service
45+ android:name=".notification.RunForegroundService"
46+ android:exported="false"
47+ android:foregroundServiceType="dataSync" />
31 </application>
48 </application>
32
49
33</manifest>
50</manifest>
app/src/main/java/soy/iko/opencode/MainActivity.kt+41−7
@@ -1,12 +1,17 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3+import android.Manifest
3import android.content.Intent
4import android.content.Intent
5+import android.content.pm.PackageManager
6+import android.os.Build
4import android.os.Bundle
7import android.os.Bundle
5import androidx.activity.ComponentActivity
8import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
9import androidx.activity.compose.setContent
7import androidx.activity.enableEdgeToEdge
10import androidx.activity.enableEdgeToEdge
11+import androidx.activity.result.contract.ActivityResultContracts
8import androidx.compose.foundation.isSystemInDarkTheme
12import androidx.compose.foundation.isSystemInDarkTheme
9import androidx.compose.runtime.getValue
13import androidx.compose.runtime.getValue
14+import androidx.core.content.ContextCompat
10import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
15import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
11import androidx.lifecycle.compose.collectAsStateWithLifecycle
16import androidx.lifecycle.compose.collectAsStateWithLifecycle
12import soy.iko.opencode.data.repo.ThemeMode
17import soy.iko.opencode.data.repo.ThemeMode
@@ -14,12 +19,18 @@ import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
14import soy.iko.opencode.ui.theme.OpencodeTheme
19import soy.iko.opencode.ui.theme.OpencodeTheme
15
20
16class MainActivity : ComponentActivity() {
21class MainActivity : ComponentActivity() {
22+
23+ private val requestNotificationPermission = registerForActivityResult(
24+ ActivityResultContracts.RequestPermission(),
25+ ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
26+
17 override fun onCreate(savedInstanceState: Bundle?) {
27 override fun onCreate(savedInstanceState: Bundle?) {
18 installSplashScreen()
28 installSplashScreen()
19 super.onCreate(savedInstanceState)
29 super.onCreate(savedInstanceState)
20 enableEdgeToEdge()
30 enableEdgeToEdge()
21 val container = (application as OpencodeApp).container
31 val container = (application as OpencodeApp).container
22− if (savedInstanceState == null) handleShareIntent(intent)
32+ if (savedInstanceState == null) handleIntent(intent)
33+ maybeRequestNotificationPermission()
23 setContent {
34 setContent {
24 val themeMode by container.settingsStore.themeMode
35 val themeMode by container.settingsStore.themeMode
25 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
36 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
@@ -39,13 +50,36 @@ class MainActivity : ComponentActivity() {
39 override fun onNewIntent(intent: Intent) {
50 override fun onNewIntent(intent: Intent) {
40 super.onNewIntent(intent)
51 super.onNewIntent(intent)
41 setIntent(intent)
52 setIntent(intent)
42− handleShareIntent(intent)
53+ handleIntent(intent)
54+ }
55+
56+ /** Capture text shared from another app so it can be prefilled into a session draft,
57+ * and session ids from notification taps / deep links so we can open them. */
58+ private fun handleIntent(intent: Intent?) {
59+ val container = (application as OpencodeApp).container
60+ if (intent?.action == Intent.ACTION_SEND) {
61+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
62+ if (text != null) container.setPendingShare(text)
63+ }
64+ // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
65+ val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
66+ ?.data
67+ ?.takeIf { it.host == "session" }
68+ ?.lastPathSegment
69+ val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
70+ id?.let { container.requestOpenSession(it) }
71+ }
72+
73+ companion object {
74+ /** Intent extra carrying a session id to open (notifications / deep links). */
75+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
43 }
76 }
44
77
45− /** Capture text shared from another app so it can be prefilled into a session draft. */
78+ /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
46− private fun handleShareIntent(intent: Intent?) {
79+ private fun maybeRequestNotificationPermission() {
47− if (intent?.action != Intent.ACTION_SEND) return
80+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
48− val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() } ?: return
81+ val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
49− (application as OpencodeApp).container.setPendingShare(text)
82+ PackageManager.PERMISSION_GRANTED
83+ if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
50 }
84 }
51}
85}
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+2
@@ -1,6 +1,7 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3import android.app.Application
3import android.app.Application
4+import soy.iko.opencode.data.repo.CrashLogger
4import soy.iko.opencode.di.AppContainer
5import soy.iko.opencode.di.AppContainer
5
6
6class OpencodeApp : Application() {
7class OpencodeApp : Application() {
@@ -9,6 +10,7 @@ class OpencodeApp : Application() {
9
10
10 override fun onCreate() {
11 override fun onCreate() {
11 super.onCreate()
12 super.onCreate()
13+ CrashLogger.get(this).install()
12 container = AppContainer(this)
14 container = AppContainer(this)
13 }
15 }
14}
16}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktnew file+111
@@ -0,0 +1,111 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.os.Build
5+import kotlinx.coroutines.flow.MutableStateFlow
6+import kotlinx.coroutines.flow.StateFlow
7+import kotlinx.coroutines.flow.asStateFlow
8+import java.io.File
9+import java.io.PrintWriter
10+import java.io.StringWriter
11+import java.text.SimpleDateFormat
12+import java.util.Date
13+import java.util.Locale
14+
15+/**
16+ * A self-contained crash reporter with no network or third-party backend. An uncaught
17+ * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
18+ * text report (timestamp, device/app metadata, and the full stack trace), then the
19+ * previous handler is re-invoked so the process still dies normally.
20+ *
21+ * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
22+ * shared, or deleted. This closes the "no idea what's crashing in the field" gap
23+ * without pulling in a hosted analytics SDK.
24+ */
25+class CrashLogger private constructor(private val appContext: Context) {
26+
27+ data class CrashReport(
28+ val fileName: String,
29+ val timestamp: Long,
30+ val preview: String,
31+ )
32+
33+ private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
34+
35+ private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
36+ val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
37+
38+ fun install() {
39+ val previous = Thread.getDefaultUncaughtExceptionHandler()
40+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
41+ runCatching { writeReport(thread, throwable) }
42+ previous?.uncaughtException(thread, throwable)
43+ }
44+ refresh()
45+ }
46+
47+ fun refresh() {
48+ _reports.value = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
49+ ?.sortedByDescending { it.lastModified() }
50+ ?.map { f ->
51+ CrashReport(
52+ fileName = f.name,
53+ timestamp = f.lastModified(),
54+ preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
55+ )
56+ }
57+ .orEmpty()
58+ }
59+
60+ fun readReport(fileName: String): String? {
61+ val file = File(crashDir, fileName)
62+ if (!file.isFile) return null
63+ return runCatching { file.readText() }.getOrNull()
64+ }
65+
66+ fun deleteReport(fileName: String) {
67+ File(crashDir, fileName).delete()
68+ refresh()
69+ }
70+
71+ fun clearAll() {
72+ crashDir.listFiles()?.forEach { it.delete() }
73+ refresh()
74+ }
75+
76+ fun reportCount(): Int = _reports.value.size
77+
78+ private fun writeReport(thread: Thread, throwable: Throwable) {
79+ val now = Date()
80+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(now)
81+ val file = File(crashDir, "crash-$stamp.txt")
82+ val sw = StringWriter()
83+ PrintWriter(sw).use { pw ->
84+ pw.println("opencode-android crash report")
85+ pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
86+ pw.println("Thread: ${thread.name}")
87+ pw.println("App version: ${appVersion()}")
88+ pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
89+ pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
90+ pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
91+ pw.println()
92+ pw.println(throwable.javaClass.name + ": " + throwable.message)
93+ throwable.printStackTrace(pw)
94+ }
95+ file.writeText(sw.toString())
96+ }
97+
98+ private fun appVersion(): String = runCatching {
99+ val pi = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
100+ "${pi.versionName} (${if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode else @Suppress("DEPRECATION") pi.versionCode})"
101+ }.getOrDefault("unknown")
102+
103+ companion object {
104+ private var instance: CrashLogger? = null
105+
106+ fun get(context: Context): CrashLogger =
107+ instance ?: synchronized(this) {
108+ instance ?: CrashLogger(context.applicationContext).also { instance = it }
109+ }
110+ }
111+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+37−1
@@ -8,12 +8,15 @@ import soy.iko.opencode.data.model.BusEvent
8import soy.iko.opencode.data.model.MessagePartUpdated
8import soy.iko.opencode.data.model.MessagePartUpdated
9import soy.iko.opencode.data.model.MessageUpdated
9import soy.iko.opencode.data.model.MessageUpdated
10import soy.iko.opencode.data.model.ServerProfile
10import soy.iko.opencode.data.model.ServerProfile
11+import soy.iko.opencode.data.model.SessionIdle
11import soy.iko.opencode.data.repo.DraftStore
12import soy.iko.opencode.data.repo.DraftStore
12import soy.iko.opencode.data.repo.ErrorKind
13import soy.iko.opencode.data.repo.ErrorKind
13import soy.iko.opencode.data.repo.ProfileStore
14import soy.iko.opencode.data.repo.ProfileStore
14import soy.iko.opencode.data.repo.SettingsStore
15import soy.iko.opencode.data.repo.SettingsStore
15import soy.iko.opencode.data.repo.classifyError
16import soy.iko.opencode.data.repo.classifyError
16import soy.iko.opencode.data.repo.responseStatusCode
17import soy.iko.opencode.data.repo.responseStatusCode
18+import soy.iko.opencode.notification.NotificationChannels
19+import soy.iko.opencode.notification.SessionNotifications
17import soy.iko.opencode.R
20import soy.iko.opencode.R
18import kotlinx.coroutines.CoroutineScope
21import kotlinx.coroutines.CoroutineScope
19import kotlinx.coroutines.Dispatchers
22import kotlinx.coroutines.Dispatchers
@@ -65,6 +68,15 @@ class AppContainer(context: Context) {
65 fun setPendingShare(text: String) { _pendingShare.value = text }
68 fun setPendingShare(text: String) { _pendingShare.value = text }
66 fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
69 fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
67
70
71+ /**
72+ * A session id to open from an external trigger (a notification tap or a deep link).
73+ * The nav host consumes it once a connection is active.
74+ */
75+ private val _pendingOpenSession = MutableStateFlow<String?>(null)
76+ val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
77+ fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
78+ fun consumePendingOpenSession(): String? = _pendingOpenSession.value.also { _pendingOpenSession.value = null }
79+
68 /**
80 /**
69 * The session the user is currently viewing (or null when not in a chat). Drives the
81 * The session the user is currently viewing (or null when not in a chat). Drives the
70 * unread tracker: messages arriving for any *other* session mark it unread, and
82 * unread tracker: messages arriving for any *other* session mark it unread, and
@@ -79,7 +91,10 @@ class AppContainer(context: Context) {
79
91
80 fun setCurrentSession(id: String?) {
92 fun setCurrentSession(id: String?) {
81 _currentSession.value = id
93 _currentSession.value = id
94+ if (id != null) {
82− if (id != null) _unread.update { it - id }
95+ _unread.update { it - id }
96+ SessionNotifications.cancel(appContext, id)
97+ }
83 }
98 }
84
99
85 /** Resolve a localized string — view models reach resources through the container. */
100 /** Resolve a localized string — view models reach resources through the container. */
@@ -108,6 +123,7 @@ class AppContainer(context: Context) {
108 }
123 }
109
124
110 init {
125 init {
126+ NotificationChannels.create(appContext)
111 registerNetworkMonitor()
127 registerNetworkMonitor()
112 observeMessageActivity()
128 observeMessageActivity()
113 appScope.launch { autoConnect() }
129 appScope.launch { autoConnect() }
@@ -131,10 +147,30 @@ class AppContainer(context: Context) {
131 if (sid != _currentSession.value) {
147 if (sid != _currentSession.value) {
132 _unread.update { it + sid }
148 _unread.update { it + sid }
133 }
149 }
150+ // Track sessions actively streaming so we know which idle events
151+ // represent a finished run worth notifying about.
152+ if (event is MessagePartUpdated || event is MessageUpdated) {
153+ activeRuns.add(sid)
154+ }
155+ if (event is SessionIdle && sid != _currentSession.value && activeRuns.remove(sid)) {
156+ notifySessionCompleted(sid)
157+ }
134 }
158 }
135 }
159 }
136 }
160 }
137
161
162+ /** Session ids currently streaming an assistant run (best-effort, in-process). */
163+ private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
164+
165+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
166+ private suspend fun notifySessionCompleted(sessionId: String) {
167+ val conn = activeConnection.value ?: return
168+ val title = runCatching {
169+ conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
170+ }.getOrNull() ?: sessionId
171+ SessionNotifications.postCompleted(appContext, sessionId, title)
172+ }
173+
138 /** Extract the session id an event pertains to, for message-activity events. */
174 /** Extract the session id an event pertains to, for message-activity events. */
139 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
175 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
140
176
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.NotificationChannel
4+import android.app.NotificationManager
5+import android.content.Context
6+import android.os.Build
7+import soy.iko.opencode.R
8+
9+/**
10+ * The app's notification channels. Created once at startup (idempotent — recreating an
11+ * existing channel is a no-op). Two channels keep user control granular:
12+ *
13+ * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
14+ * "agent running" indicator.
15+ * - [COMPLETED]: default importance. Used when a background session finishes a run.
16+ */
17+object NotificationChannels {
18+ const val STATUS = "run_status"
19+ const val COMPLETED = "session_completed"
20+
21+ fun create(context: Context) {
22+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
23+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
24+ nm.createNotificationChannel(
25+ NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
26+ description = context.getString(R.string.notif_channel_status_desc)
27+ setShowBadge(false)
28+ },
29+ )
30+ nm.createNotificationChannel(
31+ NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
32+ description = context.getString(R.string.notif_channel_completed_desc)
33+ },
34+ )
35+ }
36+}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktnew file+62
@@ -0,0 +1,62 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.Notification
4+import android.app.Service
5+import android.content.Context
6+import android.content.Intent
7+import android.content.pm.ServiceInfo
8+import android.os.Build
9+import android.os.IBinder
10+import androidx.core.app.NotificationCompat
11+import soy.iko.opencode.R
12+
13+/**
14+ * A foreground service kept alive while an opencode agent is actively running. Holding a
15+ * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
16+ * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
17+ *
18+ * The service is started when a run begins ([start]) and stopped when it goes idle
19+ * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
20+ * Started/stopped via Compose from the chat screen based on the `running` state.
21+ */
22+class RunForegroundService : Service() {
23+
24+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
25+ val notification = buildNotification()
26+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
27+ startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
28+ } else {
29+ startForeground(NOTIF_ID, notification)
30+ }
31+ return START_NOT_STICKY
32+ }
33+
34+ private fun buildNotification(): Notification =
35+ NotificationCompat.Builder(this, NotificationChannels.STATUS)
36+ .setSmallIcon(R.drawable.ic_launcher_foreground)
37+ .setContentTitle(getString(R.string.notif_running_title))
38+ .setContentText(getString(R.string.notif_running_text))
39+ .setOngoing(true)
40+ .setPriority(NotificationCompat.PRIORITY_LOW)
41+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
42+ .build()
43+
44+ override fun onBind(intent: Intent?): IBinder? = null
45+
46+ companion object {
47+ private const val NOTIF_ID = 1
48+
49+ fun start(context: Context) {
50+ val intent = Intent(context, RunForegroundService::class.java)
51+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
52+ context.startForegroundService(intent)
53+ } else {
54+ context.startService(intent)
55+ }
56+ }
57+
58+ fun stop(context: Context) {
59+ context.stopService(Intent(context, RunForegroundService::class.java))
60+ }
61+ }
62+}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktnew file+63
@@ -0,0 +1,63 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.PendingIntent
4+import android.content.Context
5+import android.content.Intent
6+import android.os.Build
7+import androidx.core.app.NotificationCompat
8+import androidx.core.app.NotificationManagerCompat
9+import soy.iko.opencode.MainActivity
10+import soy.iko.opencode.R
11+
12+/**
13+ * Builds and posts the "session completed" notification when a background session
14+ * finishes a run. Tapping it opens the session via [MainActivity] with an
15+ * [MainActivity.EXTRA_SESSION_ID] extra.
16+ *
17+ * Notification posting is guarded for Android 13+ runtime permission: if the user
18+ * hasn't granted POST_NOTIFICATIONS, the post is silently skipped rather than throwing.
19+ */
20+object SessionNotifications {
21+
22+ private const val NOTIF_ID_PREFIX = 4000
23+
24+ fun postCompleted(context: Context, sessionId: String, title: String) {
25+ // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
26+ // denied it, skip the post instead of throwing. Foreground-service notifications
27+ // are exempt, so the running indicator still shows during a run.
28+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
29+ androidx.core.content.ContextCompat.checkSelfPermission(
30+ context, android.Manifest.permission.POST_NOTIFICATIONS,
31+ ) != android.content.pm.PackageManager.PERMISSION_GRANTED
32+ ) {
33+ return
34+ }
35+ val notifId = (NOTIF_ID_PREFIX + sessionId.hashCode()).and(0x7FFFFFFF)
36+ val openIntent = Intent(context, MainActivity::class.java).apply {
37+ action = Intent.ACTION_VIEW
38+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
39+ putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
40+ }
41+ val pendingIntent = PendingIntent.getActivity(
42+ context, sessionId.hashCode(), openIntent,
43+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
44+ )
45+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
46+ .setSmallIcon(R.drawable.ic_launcher_foreground)
47+ .setContentTitle(context.getString(R.string.notif_completed_title))
48+ .setContentText(context.getString(R.string.notif_completed_text, title))
49+ .setAutoCancel(true)
50+ .setContentIntent(pendingIntent)
51+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
52+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
53+ .build()
54+
55+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
56+ }
57+
58+ /** Cancel a session's completion notification (e.g. when the user opens it). */
59+ fun cancel(context: Context, sessionId: String) {
60+ val notifId = (NOTIF_ID_PREFIX + sessionId.hashCode()).and(0x7FFFFFFF)
61+ NotificationManagerCompat.from(context).cancel(notifId)
62+ }
63+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+52−14
@@ -1,8 +1,10 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3+import androidx.compose.foundation.layout.BoxWithConstraints
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Composable
4import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.getValue
6import androidx.compose.runtime.getValue
7+import androidx.compose.ui.unit.dp
6import androidx.navigation.NavType
8import androidx.navigation.NavType
7import androidx.navigation.compose.NavHost
9import androidx.navigation.compose.NavHost
8import androidx.navigation.compose.composable
10import androidx.navigation.compose.composable
@@ -16,12 +18,16 @@ import soy.iko.opencode.ui.file.FileViewScreen
16import soy.iko.opencode.ui.server.ServerEditScreen
18import soy.iko.opencode.ui.server.ServerEditScreen
17import soy.iko.opencode.ui.server.ServerListScreen
19import soy.iko.opencode.ui.server.ServerListScreen
18import soy.iko.opencode.ui.session.SessionListScreen
20import soy.iko.opencode.ui.session.SessionListScreen
21+import soy.iko.opencode.ui.session.TwoPaneSessionChat
22+import soy.iko.opencode.ui.settings.DiagnosticsScreen
19import soy.iko.opencode.ui.settings.SettingsScreen
23import soy.iko.opencode.ui.settings.SettingsScreen
20
24
21@Composable
25@Composable
22fun OpencodeApp(container: AppContainer) {
26fun OpencodeApp(container: AppContainer) {
23 val navController = rememberNavController()
27 val navController = rememberNavController()
24 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
28 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
29+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
30+ val connection by container.activeConnection.collectAsStateWithLifecycle()
25
31
26 // When text is shared into the app, surface the session list so the user can pick
32 // When text is shared into the app, surface the session list so the user can pick
27 // (or create) a conversation to drop it into. The chosen session's draft is set
33 // (or create) a conversation to drop it into. The chosen session's draft is set
@@ -34,7 +40,21 @@ fun OpencodeApp(container: AppContainer) {
34 }
40 }
35 }
41 }
36
42
43+ // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
44+ // the open conversation side by side instead of a single-pane back stack.
45+ BoxWithConstraints {
46+ val isTwoPane = maxWidth >= 840.dp && connection != null
47+
48+ // Open a session requested by a notification tap or deep link, once connected.
49+ // In two-pane mode the detail pane consumes the request instead of navigating.
50+ LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
51+ val id = pendingOpenSession ?: return@LaunchedEffect
52+ if (connection == null || isTwoPane) return@LaunchedEffect
53+ container.consumePendingOpenSession()
54+ navController.navigate(Routes.chat(id)) { launchSingleTop = true }
55+ }
56+
37− NavHost(navController = navController, startDestination = Routes.SERVERS) {
57+ NavHost(navController = navController, startDestination = Routes.SERVERS) {
38
58
39 composable(Routes.SERVERS) {
59 composable(Routes.SERVERS) {
40 ServerListScreen(
60 ServerListScreen(
@@ -57,19 +77,31 @@ fun OpencodeApp(container: AppContainer) {
57 }
77 }
58
78
59 composable(Routes.SESSIONS) {
79 composable(Routes.SESSIONS) {
80+ if (isTwoPane) {
81+ TwoPaneSessionChat(
82+ container = container,
83+ onOpenFiles = { navController.navigate(Routes.FILES) },
84+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
85+ onDisconnect = {
86+ container.disconnect()
87+ navController.popBackStack(Routes.SERVERS, inclusive = false)
88+ },
89+ )
90+ } else {
60− SessionListScreen(
91+ SessionListScreen(
61− container = container,
92+ container = container,
62− onOpenSession = { id ->
93+ onOpenSession = { id ->
63− container.consumePendingShare()?.let { container.draftStore.set(id, it) }
94+ container.consumePendingShare()?.let { container.draftStore.set(id, it) }
64− navController.navigate(Routes.chat(id))
95+ navController.navigate(Routes.chat(id))
65− },
96+ },
66− onDisconnect = {
97+ onDisconnect = {
67− container.disconnect()
98+ container.disconnect()
68− navController.popBackStack(Routes.SERVERS, inclusive = false)
99+ navController.popBackStack(Routes.SERVERS, inclusive = false)
69− },
100+ },
70− onOpenFiles = { navController.navigate(Routes.FILES) },
101+ onOpenFiles = { navController.navigate(Routes.FILES) },
71− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
102+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
72− )
103+ )
104+ }
73 }
105 }
74
106
75 composable(
107 composable(
@@ -109,7 +141,13 @@ fun OpencodeApp(container: AppContainer) {
109 onManageServers = {
141 onManageServers = {
110 navController.popBackStack(Routes.SERVERS, inclusive = false)
142 navController.popBackStack(Routes.SERVERS, inclusive = false)
111 },
143 },
144+ onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
112 )
145 )
113 }
146 }
147+
148+ composable(Routes.DIAGNOSTICS) {
149+ DiagnosticsScreen(onBack = { navController.popBackStack() })
150+ }
151+ }
114 }
152 }
115}
153}
app/src/main/java/soy/iko/opencode/ui/Routes.kt+1
@@ -5,6 +5,7 @@ object Routes {
5 const val SERVERS = "servers"
5 const val SERVERS = "servers"
6 const val SESSIONS = "sessions"
6 const val SESSIONS = "sessions"
7 const val SETTINGS = "settings"
7 const val SETTINGS = "settings"
8+ const val DIAGNOSTICS = "diagnostics"
8 const val FILES = "files"
9 const val FILES = "files"
9
10
10 const val SERVER_EDIT = "server_edit"
11 const val SERVER_EDIT = "server_edit"
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−4
@@ -117,8 +117,10 @@ fun ChatScreen(
117 val defaultShareSubject = stringResource(R.string.share_subject)
117 val defaultShareSubject = stringResource(R.string.share_subject)
118
118
119 // Connection profile (with auth) for rendering inline image attachments.
119 // Connection profile (with auth) for rendering inline image attachments.
120+ // Collect as state so a server switch recomposes the image context.
121+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
120− val imageContext = remember(container.activeConnection.value?.profile) {
122+ val imageContext = remember(activeConnection?.profile) {
121− container.activeConnection.value?.profile?.toImageContext()
123+ activeConnection?.profile?.toImageContext()
122 }
124 }
123
125
124 // Mark this session as read while the user is viewing it; clear on leave so new
126 // Mark this session as read while the user is viewing it; clear on leave so new
@@ -135,11 +137,17 @@ fun ChatScreen(
135 var showCommandPicker by remember { mutableStateOf(false) }
137 var showCommandPicker by remember { mutableStateOf(false) }
136 var showExitConfirm by remember { mutableStateOf(false) }
138 var showExitConfirm by remember { mutableStateOf(false) }
137
139
138− // Keep the screen awake while the agent is actively working.
140+ // Keep the screen awake and hold a foreground priority while the agent is working,
141+ // so backgrounding mid-run doesn't let Doze choke the SSE stream.
139 val currentView = LocalView.current
142 val currentView = LocalView.current
143+ val appContext = LocalContext.current.applicationContext
140 DisposableEffect(running) {
144 DisposableEffect(running) {
141 currentView.keepScreenOn = running
145 currentView.keepScreenOn = running
146+ if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
147+ onDispose {
142− onDispose { currentView.keepScreenOn = false }
148+ currentView.keepScreenOn = false
149+ soy.iko.opencode.notification.RunForegroundService.stop(appContext)
150+ }
143 }
151 }
144
152
145 // Track whether the list is pinned to the bottom so streaming auto-scroll
153 // Track whether the list is pinned to the bottom so streaming auto-scroll
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+3−1
@@ -17,6 +17,8 @@ import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.res.stringResource
19import androidx.compose.ui.res.stringResource
20+import androidx.compose.ui.semantics.Role
21+import androidx.compose.ui.semantics.role
20import androidx.compose.ui.text.style.TextOverflow
22import androidx.compose.ui.text.style.TextOverflow
21import androidx.compose.ui.unit.dp
23import androidx.compose.ui.unit.dp
22import soy.iko.opencode.data.model.Command
24import soy.iko.opencode.data.model.Command
@@ -49,7 +51,7 @@ fun CommandPickerSheet(
49 Column(
51 Column(
50 modifier = Modifier
52 modifier = Modifier
51 .fillMaxWidth()
53 .fillMaxWidth()
52− .clickable { onSelect(cmd); onDismiss() }
54+ .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
53 .padding(horizontal = 24.dp, vertical = 12.dp),
55 .padding(horizontal = 24.dp, vertical = 12.dp),
54 ) {
56 ) {
55 androidx.compose.foundation.layout.Row(
57 androidx.compose.foundation.layout.Row(
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−1
@@ -25,8 +25,8 @@ import soy.iko.opencode.ui.components.rememberRelativeTime
25@Composable
25@Composable
26fun MessageBubble(
26fun MessageBubble(
27 message: MessageWithParts,
27 message: MessageWithParts,
28− isRunning: Boolean = false,
29 modifier: Modifier = Modifier,
28 modifier: Modifier = Modifier,
29+ isRunning: Boolean = false,
30 imageContext: ImageLoadContext? = null,
30 imageContext: ImageLoadContext? = null,
31) {
31) {
32 when (message.info) {
32 when (message.info) {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1
@@ -69,7 +69,7 @@ private const val COLLAPSED_LIMIT = 600
69 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
69 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
70 */
70 */
71@Composable
71@Composable
72−fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier, imageContext: ImageLoadContext? = null) {
72+fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
73 when (part) {
73 when (part) {
74 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
74 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
75 MarkdownText(part.text, modifier = modifier)
75 MarkdownText(part.text, modifier = modifier)
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+3−1
@@ -35,7 +35,9 @@ import androidx.compose.runtime.getValue
35import androidx.compose.ui.Alignment
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.graphics.Color
38+import androidx.compose.ui.semantics.Role
38import androidx.compose.ui.semantics.contentDescription
39import androidx.compose.ui.semantics.contentDescription
40+import androidx.compose.ui.semantics.role
39import androidx.compose.ui.semantics.semantics
41import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.text.font.FontFamily
42import androidx.compose.ui.text.font.FontFamily
41import androidx.compose.ui.text.input.ImeAction
43import androidx.compose.ui.text.input.ImeAction
@@ -135,7 +137,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
135 segment,
137 segment,
136 style = MaterialTheme.typography.bodyMedium,
138 style = MaterialTheme.typography.bodyMedium,
137 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
139 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
138− modifier = Modifier.clickable { onNavigate(target) }.padding(vertical = 2.dp),
140+ modifier = Modifier.clickable(role = Role.Button) { onNavigate(target) }.padding(vertical = 2.dp),
139 )
141 )
140 }
142 }
141 }
143 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1
@@ -337,7 +337,7 @@ private fun SessionCard(
337 Card(
337 Card(
338 modifier = Modifier
338 modifier = Modifier
339 .fillMaxWidth()
339 .fillMaxWidth()
340− .clickable { onClick() },
340+ .clickable(role = Role.Button) { onClick() },
341 ) {
341 ) {
342 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
342 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
343 Row(
343 Row(
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktnew file+111
@@ -0,0 +1,111 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.widthIn
10+import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.Chat
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.Text
15+import androidx.compose.runtime.Composable
16+import androidx.compose.runtime.LaunchedEffect
17+import androidx.compose.runtime.getValue
18+import androidx.compose.runtime.key
19+import androidx.compose.runtime.mutableStateOf
20+import androidx.compose.runtime.saveable.rememberSaveable
21+import androidx.compose.runtime.setValue
22+import androidx.compose.ui.Alignment
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.res.stringResource
25+import androidx.compose.ui.unit.dp
26+import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+import soy.iko.opencode.R
28+import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.chat.ChatScreen
30+
31+/**
32+ * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
33+ * the session list on the left, the selected conversation on the right. On compact
34+ * widths the app uses the single-pane [androidx.navigation] back stack instead.
35+ *
36+ * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
37+ * two panes are self-contained. Selecting a session updates the detail pane rather than
38+ * pushing a destination; the chat [onBack] clears the selection. The chat composition is
39+ * keyed by session id so each conversation gets its own ViewModel.
40+ */
41+@Composable
42+fun TwoPaneSessionChat(
43+ container: AppContainer,
44+ onOpenFiles: () -> Unit,
45+ onOpenSettings: () -> Unit,
46+ onDisconnect: () -> Unit,
47+ modifier: Modifier = Modifier,
48+) {
49+ var selected by rememberSaveable { mutableStateOf<String?>(null) }
50+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
51+
52+ // A notification tap / deep link requests a session: open it in the detail pane.
53+ LaunchedEffect(pendingOpenSession) {
54+ pendingOpenSession?.let {
55+ selected = it
56+ container.consumePendingOpenSession()
57+ }
58+ }
59+
60+ Row(modifier = modifier.fillMaxSize()) {
61+ Box(
62+ modifier = Modifier
63+ .weight(0.38f)
64+ .widthIn(max = 460.dp),
65+ ) {
66+ SessionListScreen(
67+ container = container,
68+ onOpenSession = { id -> selected = id },
69+ onDisconnect = onDisconnect,
70+ onOpenFiles = onOpenFiles,
71+ onOpenSettings = onOpenSettings,
72+ )
73+ }
74+
75+ Box(modifier = Modifier.weight(0.62f).fillMaxSize()) {
76+ val sessionId = selected
77+ if (sessionId == null) {
78+ EmptyDetail()
79+ } else {
80+ key(sessionId) {
81+ ChatScreen(
82+ container = container,
83+ sessionId = sessionId,
84+ onBack = { selected = null },
85+ )
86+ }
87+ }
88+ }
89+ }
90+}
91+
92+@Composable
93+private fun EmptyDetail() {
94+ Column(
95+ modifier = Modifier.fillMaxSize().padding(32.dp),
96+ verticalArrangement = Arrangement.Center,
97+ horizontalAlignment = Alignment.CenterHorizontally,
98+ ) {
99+ Icon(
100+ Icons.AutoMirrored.Filled.Chat,
101+ contentDescription = null,
102+ tint = MaterialTheme.colorScheme.outline,
103+ )
104+ Text(
105+ stringResource(R.string.empty_detail_pane),
106+ style = MaterialTheme.typography.bodyLarge,
107+ color = MaterialTheme.colorScheme.onSurfaceVariant,
108+ modifier = Modifier.padding(top = 12.dp),
109+ )
110+ }
111+}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktnew file+178
@@ -0,0 +1,178 @@
1+package soy.iko.opencode.ui.settings
2+
3+import android.content.Intent
4+import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.lazy.LazyColumn
14+import androidx.compose.foundation.lazy.items
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.automirrored.filled.ArrowBack
17+import androidx.compose.material.icons.filled.BugReport
18+import androidx.compose.material.icons.filled.DeleteSweep
19+import androidx.compose.material.icons.filled.Share
20+import androidx.compose.material3.AlertDialog
21+import androidx.compose.material3.ExperimentalMaterial3Api
22+import androidx.compose.material3.HorizontalDivider
23+import androidx.compose.material3.Icon
24+import androidx.compose.material3.IconButton
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.Scaffold
27+import androidx.compose.material3.Text
28+import androidx.compose.material3.TextButton
29+import androidx.compose.material3.TopAppBar
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
35+import androidx.compose.ui.Alignment
36+import androidx.compose.ui.Modifier
37+import androidx.compose.ui.platform.LocalContext
38+import androidx.compose.ui.res.stringResource
39+import androidx.compose.ui.semantics.Role
40+import androidx.compose.ui.semantics.heading
41+import androidx.compose.ui.semantics.role
42+import androidx.compose.ui.semantics.semantics
43+import androidx.compose.ui.unit.dp
44+import androidx.lifecycle.compose.collectAsStateWithLifecycle
45+import soy.iko.opencode.R
46+import soy.iko.opencode.data.repo.CrashLogger
47+import soy.iko.opencode.ui.components.rememberRelativeTime
48+
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun DiagnosticsScreen(onBack: () -> Unit) {
52+ val context = LocalContext.current
53+ val logger = remember { CrashLogger.get(context) }
54+ val reports by logger.reports.collectAsStateWithLifecycle()
55+ var viewing by remember { mutableStateOf<String?>(null) }
56+ val shareLabel = stringResource(R.string.share)
57+
58+ Scaffold(
59+ topBar = {
60+ TopAppBar(
61+ title = { Text(stringResource(R.string.diagnostics)) },
62+ navigationIcon = {
63+ IconButton(onClick = onBack) {
64+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
65+ }
66+ },
67+ actions = {
68+ if (reports.isNotEmpty()) {
69+ IconButton(onClick = { logger.clearAll() }) {
70+ Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
71+ }
72+ }
73+ },
74+ )
75+ },
76+ ) { padding ->
77+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
78+ Text(
79+ stringResource(R.string.diagnostics_desc),
80+ style = MaterialTheme.typography.bodySmall,
81+ color = MaterialTheme.colorScheme.onSurfaceVariant,
82+ modifier = Modifier.padding(16.dp),
83+ )
84+ Text(
85+ stringResource(R.string.crash_reports),
86+ style = MaterialTheme.typography.titleSmall,
87+ color = MaterialTheme.colorScheme.primary,
88+ modifier = Modifier
89+ .padding(start = 16.dp, bottom = 8.dp)
90+ .semantics { heading() },
91+ )
92+ if (reports.isEmpty()) {
93+ Text(
94+ stringResource(R.string.no_crash_reports),
95+ style = MaterialTheme.typography.bodyMedium,
96+ color = MaterialTheme.colorScheme.onSurfaceVariant,
97+ modifier = Modifier.padding(24.dp),
98+ )
99+ } else {
100+ LazyColumn(modifier = Modifier.fillMaxSize()) {
101+ items(reports, key = { it.fileName }) { report ->
102+ Row(
103+ modifier = Modifier
104+ .fillMaxWidth()
105+ .clickable(role = Role.Button) { viewing = report.fileName }
106+ .padding(horizontal = 16.dp, vertical = 12.dp),
107+ verticalAlignment = Alignment.CenterVertically,
108+ horizontalArrangement = Arrangement.spacedBy(12.dp),
109+ ) {
110+ Icon(
111+ Icons.Filled.BugReport,
112+ contentDescription = null,
113+ tint = MaterialTheme.colorScheme.error,
114+ )
115+ Column(modifier = Modifier.weight(1f)) {
116+ Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
117+ Text(
118+ rememberRelativeTime(report.timestamp),
119+ style = MaterialTheme.typography.bodySmall,
120+ color = MaterialTheme.colorScheme.onSurfaceVariant,
121+ )
122+ }
123+ val content = remember(report.fileName) { logger.readReport(report.fileName).orEmpty() }
124+ IconButton(onClick = {
125+ val send = Intent(Intent.ACTION_SEND).apply {
126+ type = "text/plain"
127+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
128+ putExtra(Intent.EXTRA_TEXT, content)
129+ }
130+ context.startActivity(Intent.createChooser(send, shareLabel))
131+ }) {
132+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
133+ }
134+ }
135+ HorizontalDivider()
136+ }
137+ }
138+ }
139+ }
140+ }
141+
142+ val reportName = viewing
143+ if (reportName != null) {
144+ val content = remember(reportName) { logger.readReport(reportName) }
145+ AlertDialog(
146+ onDismissRequest = { viewing = null },
147+ confirmButton = {
148+ TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.back)) }
149+ },
150+ dismissButton = {
151+ Row {
152+ val shareLabel2 = stringResource(R.string.share)
153+ TextButton(onClick = {
154+ val send = Intent(Intent.ACTION_SEND).apply {
155+ type = "text/plain"
156+ putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
157+ putExtra(Intent.EXTRA_TEXT, content.orEmpty())
158+ }
159+ context.startActivity(Intent.createChooser(send, shareLabel2))
160+ }) { Text(stringResource(R.string.share)) }
161+ Spacer(Modifier.size(8.dp))
162+ TextButton(onClick = {
163+ logger.deleteReport(reportName)
164+ viewing = null
165+ }) { Text(stringResource(R.string.delete)) }
166+ }
167+ },
168+ title = { Text(stringResource(R.string.crash_report)) },
169+ text = {
170+ Text(
171+ content ?: "",
172+ style = MaterialTheme.typography.bodySmall,
173+ modifier = Modifier.fillMaxWidth(),
174+ )
175+ },
176+ )
177+ }
178+}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+18−2
@@ -13,6 +13,7 @@ import androidx.compose.foundation.selection.selectable
13import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
15import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
15import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
16+import androidx.compose.material.icons.filled.BugReport
16import androidx.compose.material.icons.filled.Dns
17import androidx.compose.material.icons.filled.Dns
17import androidx.compose.material3.ExperimentalMaterial3Api
18import androidx.compose.material3.ExperimentalMaterial3Api
18import androidx.compose.material3.HorizontalDivider
19import androidx.compose.material3.HorizontalDivider
@@ -33,7 +34,9 @@ import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.Modifier
34import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
36import androidx.compose.ui.semantics.heading
38import androidx.compose.ui.semantics.heading
39+import androidx.compose.ui.semantics.role
37import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.semantics.semantics
38import androidx.compose.ui.unit.dp
41import androidx.compose.ui.unit.dp
39import androidx.lifecycle.compose.collectAsStateWithLifecycle
42import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -44,7 +47,7 @@ import soy.iko.opencode.R
44
47
45@OptIn(ExperimentalMaterial3Api::class)
48@OptIn(ExperimentalMaterial3Api::class)
46@Composable
49@Composable
47−fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit) {
50+fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
48 val scope = rememberCoroutineScope()
51 val scope = rememberCoroutineScope()
49 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
52 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
50 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
53 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
@@ -128,7 +131,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
128 modifier = Modifier
131 modifier = Modifier
129 .fillMaxWidth()
132 .fillMaxWidth()
130 .padding(top = 8.dp)
133 .padding(top = 8.dp)
131− .clickable { onManageServers() }
134+ .clickable(role = Role.Button) { onManageServers() }
132 .padding(vertical = 8.dp),
135 .padding(vertical = 8.dp),
133 verticalAlignment = Alignment.CenterVertically,
136 verticalAlignment = Alignment.CenterVertically,
134 ) {
137 ) {
@@ -156,6 +159,19 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
156 color = MaterialTheme.colorScheme.onSurfaceVariant,
159 color = MaterialTheme.colorScheme.onSurfaceVariant,
157 modifier = Modifier.padding(top = 4.dp),
160 modifier = Modifier.padding(top = 4.dp),
158 )
161 )
162+
163+ Spacer(Modifier.size(16.dp))
164+ Row(
165+ modifier = Modifier
166+ .fillMaxWidth()
167+ .clickable(role = Role.Button) { onOpenDiagnostics() }
168+ .padding(vertical = 8.dp),
169+ verticalAlignment = Alignment.CenterVertically,
170+ ) {
171+ Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
172+ Text(" " + stringResource(R.string.diagnostics), modifier = Modifier.weight(1f))
173+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
174+ }
159 }
175 }
160 }
176 }
161}
177}
app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+13
@@ -0,0 +1,13 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="108dp"
3+ android:height="108dp"
4+ android:viewportWidth="108"
5+ android:viewportHeight="108">
6+ <!-- A terminal prompt chevron + underscore cursor, the opencode motif. -->
7+ <path
8+ android:fillColor="#FFFFFF"
9+ android:pathData="M34,38 L52,54 L34,70 L40,70 L58,54 L40,38 Z" />
10+ <path
11+ android:fillColor="#FFFFFF"
12+ android:pathData="M62,66 L78,66 L78,72 L62,72 Z" />
13+</vector>
app/src/main/res/mipmap-anydpi/ic_launcher.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@color/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+ <monochrome android:drawable="@drawable/ic_launcher_foreground" />
6+</adaptive-icon>
app/src/main/res/values/colors.xml+1
@@ -1,4 +1,5 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3 <color name="splash_background">#34548A</color>
3 <color name="splash_background">#34548A</color>
4+ <color name="ic_launcher_background">#34548A</color>
4</resources>
5</resources>
app/src/main/res/values/strings.xml+20−8
@@ -1,10 +1,7 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3 <string name="app_name">opencode</string>
3 <string name="app_name">opencode</string>
4− <string name="untitled_session">Untitled session</string>
5− <string name="custom_agent">Custom agent</string>
6 <string name="unknown_version">0.1.0</string>
4 <string name="unknown_version">0.1.0</string>
7− <string name="error_output">error</string>
8
5
9 <!-- Common -->
6 <!-- Common -->
10 <string name="back">Back</string>
7 <string name="back">Back</string>
@@ -75,6 +72,7 @@
75 <string name="message_placeholder">Message opencode…</string>
72 <string name="message_placeholder">Message opencode…</string>
76 <string name="empty_chat_title">Start a conversation</string>
73 <string name="empty_chat_title">Start a conversation</string>
77 <string name="empty_chat_hint">Type a message below to begin.</string>
74 <string name="empty_chat_hint">Type a message below to begin.</string>
75+ <string name="empty_detail_pane">Select a session to view the conversation.</string>
78 <string name="working">working…</string>
76 <string name="working">working…</string>
79 <string name="latest">Latest</string>
77 <string name="latest">Latest</string>
80 <string name="stop_and_exit_title">Stop and exit?</string>
78 <string name="stop_and_exit_title">Stop and exit?</string>
@@ -91,14 +89,9 @@
91 <string name="error_client_status">Request failed (%1$d).</string>
89 <string name="error_client_status">Request failed (%1$d).</string>
92 <string name="error_failed_to_send">Failed to send</string>
90 <string name="error_failed_to_send">Failed to send</string>
93 <string name="error_agent_reported">The agent reported an error.</string>
91 <string name="error_agent_reported">The agent reported an error.</string>
94− <string name="error_failed_command">Failed to run command</string>
95 <string name="error_not_reachable">Could not reach %1$s</string>
92 <string name="error_not_reachable">Could not reach %1$s</string>
96 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
93 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
97 <string name="error_server">The server reported an error. Try again later.</string>
94 <string name="error_server">The server reported an error. Try again later.</string>
98− <string name="error_load_sessions">Failed to load sessions</string>
99− <string name="error_create_session">Failed to create session</string>
100− <string name="error_delete_session">Failed to delete session</string>
101− <string name="error_rename_session">Failed to rename session</string>
102
95
103 <!-- Part renderer -->
96 <!-- Part renderer -->
104 <string name="thinking">Thinking…</string>
97 <string name="thinking">Thinking…</string>
@@ -146,4 +139,23 @@
146 <string name="about">About</string>
139 <string name="about">About</string>
147 <string name="about_version">opencode-android %1$s</string>
140 <string name="about_version">opencode-android %1$s</string>
148 <string name="about_desc">A native Android client for the opencode serve HTTP API.</string>
141 <string name="about_desc">A native Android client for the opencode serve HTTP API.</string>
142+
143+ <!-- Diagnostics -->
144+ <string name="diagnostics">Diagnostics</string>
145+ <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
146+ <string name="crash_reports">Crash reports</string>
147+ <string name="no_crash_reports">No crashes recorded. \ud83d\udc4d</string>
148+ <string name="clear_all">Clear all</string>
149+ <string name="crash_report">Crash report</string>
150+ <string name="diagnostics_share_subject">opencode-android crash report</string>
151+
152+ <!-- Notifications -->
153+ <string name="notif_channel_status">Agent activity</string>
154+ <string name="notif_channel_status_desc">Shown while an opencode agent is running.</string>
155+ <string name="notif_channel_completed">Session completed</string>
156+ <string name="notif_channel_completed_desc">Shown when a background session finishes a run.</string>
157+ <string name="notif_running_title">opencode</string>
158+ <string name="notif_running_text">Agent is working…</string>
159+ <string name="notif_completed_title">Session ready</string>
160+ <string name="notif_completed_text">opencode replied in “%1$s”.</string>
149</resources>
161</resources>
app/src/main/res/xml/data_extraction_rules.xmlnew file+15
@@ -0,0 +1,15 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<data-extraction-rules>
3+ <cloud-backup>
4+ <exclude domain="root" />
5+ <exclude domain="file" />
6+ <exclude domain="database" />
7+ <exclude domain="sharedpref" />
8+ </cloud-backup>
9+ <device-transfer>
10+ <exclude domain="root" />
11+ <exclude domain="file" />
12+ <exclude domain="database" />
13+ <exclude domain="sharedpref" />
14+ </device-transfer>
15+</data-extraction-rules>
app/src/main/res/xml/network_security_config.xml+4−2
@@ -4,8 +4,10 @@
4 so we permit it. This is a developer tool talking to a self-hosted server on a
4 so we permit it. This is a developer tool talking to a self-hosted server on a
5 trusted network; cleartext is permitted broadly here.
5 trusted network; cleartext is permitted broadly here.
6-->
6-->
7−<network-security-config>
7+<network-security-config xmlns:tools="http://schemas.android.com/tools">
8+ <base-config
8− <base-config cleartextTrafficPermitted="true">
9+ cleartextTrafficPermitted="true"
10+ tools:ignore="InsecureBaseConfiguration">
9 <trust-anchors>
11 <trust-anchors>
10 <certificates src="system" />
12 <certificates src="system" />
11 </trust-anchors>
13 </trust-anchors>
config/detekt/detekt.ymlnew file+54
@@ -0,0 +1,54 @@
1+# detekt override config.
2+# buildUponDefaultConfig = true, so only the rules below differ from the defaults.
3+# The defaults are intentionally strict; this file relaxes rules that fight Jetpack
4+# Compose / Android idioms (lots of magic dp numbers, long @Composable functions,
5+# long parameter lists) while keeping the potential-bug and correctness rules on.
6+build:
7+ maxIssues: 0
8+ excludeCorrectable: false
9+
10+complexity:
11+ active: true
12+ LongMethod:
13+ active: false
14+ LongParameterList:
15+ active: false
16+ ComplexCondition:
17+ active: true
18+ CyclomaticComplexMethod:
19+ active: true
20+ threshold: 20
21+ NestedBlockDepth:
22+ active: true
23+ threshold: 6
24+
25+style:
26+ active: true
27+ MagicNumber:
28+ active: false
29+ ReturnCount:
30+ active: false
31+ ThrowsCount:
32+ active: false
33+ LoopWithTooManyJumpStatements:
34+ active: false
35+ UseCheckOrError:
36+ active: false
37+ MaxLineLength:
38+ active: false
39+ WildcardImport:
40+ active: false
41+ FunctionOnlyReturningConstant:
42+ active: false
43+
44+exceptions:
45+ active: true
46+ TooGenericExceptionCaught:
47+ active: false # runCatching { } is idiomatic in this codebase
48+
49+naming:
50+ active: true
51+ FunctionNaming:
52+ active: false # @Composable functions use PascalCase
53+ TopLevelPropertyNaming:
54+ active: false
gradle/libs.versions.toml+10
@@ -15,6 +15,11 @@ markdownRenderer = "0.27.0"
15coreKtx = "1.13.1"
15coreKtx = "1.13.1"
16splashscreen = "1.0.1"
16splashscreen = "1.0.1"
17junit = "4.13.2"
17junit = "4.13.2"
18+detekt = "1.23.8"
19+androidxTestExt = "1.2.1"
20+androidxTestRunner = "1.6.2"
21+androidxTestCore = "1.6.1"
22+espresso = "3.6.1"
18
23
19[libraries]
24[libraries]
20androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
25androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -51,9 +56,14 @@ markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-
51markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
56markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
52
57
53junit = { group = "junit", name = "junit", version.ref = "junit" }
58junit = { group = "junit", name = "junit", version.ref = "junit" }
59+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
60+androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
61+androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
62+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
54
63
55[plugins]
64[plugins]
56android-application = { id = "com.android.application", version.ref = "agp" }
65android-application = { id = "com.android.application", version.ref = "agp" }
57kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
66kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
58kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
67kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
59kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
68kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
69+detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
renovate.jsonnew file+32
@@ -0,0 +1,32 @@
1+{
2+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3+ "extends": ["config:recommended"],
4+ "labels": ["dependencies"],
5+ "dependencyDashboard": true,
6+ "prConcurrentLimit": 5,
7+ "commitMessagePrefix": "deps:",
8+ "schedule": ["before 6am on Monday"],
9+ "lockFileMaintenance": {
10+ "enabled": true,
11+ "schedule": ["before 6am on Monday"]
12+ },
13+ "packageRules": [
14+ {
15+ "groupName": "Kotlin and Compose compiler",
16+ "matchPackagePatterns": ["^org.jetbrains.kotlin", "^com.android.tools.build"]
17+ },
18+ {
19+ "groupName": "Jetpack Compose",
20+ "matchPackagePatterns": ["^androidx.compose"]
21+ },
22+ {
23+ "groupName": "Ktor",
24+ "matchPackagePatterns": ["^io.ktor"]
25+ },
26+ {
27+ "description": "Pin AndroidX to the set the app targets (compileSdk 35); review major bumps manually.",
28+ "matchPackagePatterns": ["^androidx."],
29+ "major": { "enabled": false }
30+ }
31+ ]
32+}