git.iko.soy/opencode-android
on master help sign in
Fix SSE timeout killing the event stream every 60s, restore permission dialog on failure, defer draft persistence until send succeeds, make FileViewModel connection-aware, switch to collectLatest to avoid stale SSE subscriptions, buffer SSE events against slow subscribers, add configChanges for rotation, centralize network constants, enable Gradle parallel/config-cache, add testOptions, and expand unit test coverage for retry backoff timing and session repository seeding
0117f4ciko
app/build.gradle.kts+10
@@ -89,6 +89,16 @@ android {
89 excludes += "/META-INF/{AL2.0,LGPL2.1}"
89 excludes += "/META-INF/{AL2.0,LGPL2.1}"
90 }
90 }
91 }
91 }
92+
93+ testOptions {
94+ unitTests {
95+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
96+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
97+ // against Android APIs don't need Robolectric.
98+ isReturnDefaultValues = true
99+ isIncludeAndroidResources = true
100+ }
101+ }
92}
102}
93
103
94dependencies {
104dependencies {
app/src/main/AndroidManifest.xml+1
@@ -22,6 +22,7 @@
22 android:name=".MainActivity"
22 android:name=".MainActivity"
23 android:exported="true"
23 android:exported="true"
24 android:launchMode="singleTop"
24 android:launchMode="singleTop"
25+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
25 android:theme="@style/Theme.OpencodeAndroid.Splash">
26 android:theme="@style/Theme.OpencodeAndroid.Splash">
26 <intent-filter>
27 <intent-filter>
27 <action android:name="android.intent.action.MAIN" />
28 <action android:name="android.intent.action.MAIN" />
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−19
@@ -7,6 +7,7 @@ import io.ktor.client.plugins.sse.sse
7import io.ktor.sse.ServerSentEvent
7import io.ktor.sse.ServerSentEvent
8import kotlinx.coroutines.CancellationException
8import kotlinx.coroutines.CancellationException
9import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.CoroutineScope
10+import kotlinx.coroutines.channels.BufferOverflow
10import kotlinx.coroutines.channels.Channel
11import kotlinx.coroutines.channels.Channel
11import kotlinx.coroutines.channels.ChannelResult
12import kotlinx.coroutines.channels.ChannelResult
12import kotlinx.coroutines.flow.Flow
13import kotlinx.coroutines.flow.Flow
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharedFlow
15import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.SharingStarted
17import kotlinx.coroutines.flow.SharingStarted
17import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.buffer
18import kotlinx.coroutines.flow.channelFlow
20import kotlinx.coroutines.flow.channelFlow
19import kotlinx.coroutines.flow.produceIn
21import kotlinx.coroutines.flow.produceIn
20import kotlinx.coroutines.flow.shareIn
22import kotlinx.coroutines.flow.shareIn
@@ -42,6 +44,10 @@ class EventStreamClient(
42 val state: StateFlow<ConnectionState> = _state.asStateFlow()
44 val state: StateFlow<ConnectionState> = _state.asStateFlow()
43
45
44 val events: SharedFlow<BusEvent> = stream()
46 val events: SharedFlow<BusEvent> = stream()
47+ // Buffer so a slow subscriber (e.g. a reducer under lock) can't suspend send()
48+ // and stall the SSE read loop. Drop oldest on overflow — the next event carries
49+ // the freshest state, so a dropped interim delta is harmless.
50+ .buffer(NetworkConfig.sseEventBufferCapacity, BufferOverflow.DROP_OLDEST)
45 .shareIn(
51 .shareIn(
46 scope = scope,
52 scope = scope,
47 started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
53 started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
@@ -56,26 +62,26 @@ class EventStreamClient(
56
62
57 private fun stream(): Flow<BusEvent> = channelFlow {
63 private fun stream(): Flow<BusEvent> = channelFlow {
58 val scope = this
64 val scope = this
59− var backoffMs = INITIAL_BACKOFF_MS
65+ var backoffMs = NetworkConfig.sseInitialBackoffMs
60 while (isActive) {
66 while (isActive) {
61 _state.value = ConnectionState.Connecting
67 _state.value = ConnectionState.Connecting
62 try {
68 try {
63 client.sse("event") {
69 client.sse("event") {
64− backoffMs = INITIAL_BACKOFF_MS
70+ backoffMs = NetworkConfig.sseInitialBackoffMs
65 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
71 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
66 // element can be raced against an idle timeout via select.
72 // element can be raced against an idle timeout via select.
67 val events = incoming.produceIn(scope)
73 val events = incoming.produceIn(scope)
68 try {
74 try {
69 while (isActive) {
75 while (isActive) {
70− // The OkHttp engine is configured with readTimeout=0 (the SSE
76+ // The OkHttp engine has readTimeout=0 (the SSE stream must
71− // stream must never time out on read), so a socket the peer never
77+ // never time out on read), so a socket the peer never closed
72− // closed would otherwise hang until the OS TCP keepalive kicks in
78+ // would otherwise hang until OS TCP keepalive kicks in —
73− // — minutes to hours on stock Linux. If nothing arrives within
79+ // minutes to hours on stock Linux. If nothing arrives within
74− // IDLE_TIMEOUT_MS, treat the connection as half-open and drop it
80+ // the idle timeout, treat the connection as half-open and
75− // so the outer loop reconnects.
81+ // drop it so the outer loop reconnects.
76 val result: ChannelResult<ServerSentEvent>? = select {
82 val result: ChannelResult<ServerSentEvent>? = select {
77 events.onReceiveCatching { it }
83 events.onReceiveCatching { it }
78− onTimeout(IDLE_TIMEOUT_MS) { null }
84+ onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
79 }
85 }
80 // Timeout: drop and reconnect.
86 // Timeout: drop and reconnect.
81 if (result == null) throw IOException("SSE idle timeout, reconnecting")
87 if (result == null) throw IOException("SSE idle timeout, reconnecting")
@@ -99,7 +105,7 @@ class EventStreamClient(
99 } catch (c: CancellationException) {
105 } catch (c: CancellationException) {
100 throw c
106 throw c
101 } catch (t: Throwable) {
107 } catch (t: Throwable) {
102− Log.w(TAG, "SSE stream error, will retry", t)
108+ Log.w("EventStream", "SSE stream error, will retry", t)
103 }
109 }
104 _state.value = ConnectionState.Disconnected
110 _state.value = ConnectionState.Disconnected
105 if (!isActive) break
111 if (!isActive) break
@@ -109,15 +115,7 @@ class EventStreamClient(
109 reconnectSignal.onReceive { signaled = true }
115 reconnectSignal.onReceive { signaled = true }
110 onTimeout(backoffMs) { /* normal backoff elapsed */ }
116 onTimeout(backoffMs) { /* normal backoff elapsed */ }
111 }
117 }
112− backoffMs = if (signaled) INITIAL_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
118+ backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
113 }
119 }
114 }
120 }
115−
116− private companion object {
117− private const val TAG = "EventStream"
118− const val INITIAL_BACKOFF_MS = 500L
119− const val MAX_BACKOFF_MS = 10_000L
120− /** Max gap between events before a silent/half-open connection is dropped. */
121− const val IDLE_TIMEOUT_MS = 90_000L
122− }
123}
121}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+11−4
@@ -27,11 +27,14 @@ object HttpClientFactory {
27
27
28 engine {
28 engine {
29 config {
29 config {
30− // The SSE stream is long-lived; never time it out on read.
30+ // The SSE stream is long-lived; a socket read timeout would kill it
31+ // mid-stream. REST calls rely on the connect timeout + withRetry for
32+ // resilience instead. The SSE idle-timeout watchdog in EventStreamClient
33+ // handles half-open connections.
31 readTimeout(0, TimeUnit.MILLISECONDS)
34 readTimeout(0, TimeUnit.MILLISECONDS)
32− connectTimeout(30, TimeUnit.SECONDS)
35+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
33 retryOnConnectionFailure(true)
36 retryOnConnectionFailure(true)
34− pingInterval(20, TimeUnit.SECONDS)
37+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
35 }
38 }
36 }
39 }
37
40
@@ -41,8 +44,12 @@ object HttpClientFactory {
41
44
42 install(SSE)
45 install(SSE)
43
46
47+ // Disable the request-level timeout at the client level — it covers the whole
48+ // call including the body stream, so it would kill the long-lived SSE `/event`
49+ // connection after 60s (the previous default). The engine's connect timeout
50+ // and the SSE idle-timeout watchdog handle stuck connections instead.
44 install(HttpTimeout) {
51 install(HttpTimeout) {
45− requestTimeoutMillis = 60_000
52+ requestTimeoutMillis = Long.MAX_VALUE
46 }
53 }
47
54
48 if (profile.hasAuth) {
55 if (profile.hasAuth) {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktnew file+52
@@ -0,0 +1,52 @@
1+package soy.iko.opencode.data.network
2+
3+import java.util.concurrent.TimeUnit
4+
5+/**
6+ * Centralized tuning parameters for the network and presentation layers. Keeping these
7+ * in one place makes them easy to find and adjust without hunting through source files,
8+ * and makes the intent of each value explicit rather than a bare magic number.
9+ */
10+object NetworkConfig {
11+
12+ // --- OkHttp engine timeouts (HttpClientFactory) ---
13+
14+ /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
15+ const val connectTimeoutSeconds = 30L
16+ /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
17+ const val readTimeoutSeconds = 60L
18+ /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
19+ const val pingIntervalSeconds = 20L
20+
21+ // --- REST retry (OpencodeApiClient.withRetry) ---
22+
23+ /** Maximum number of attempts for a retriable REST call. */
24+ const val retryMaxAttempts = 3
25+ /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26+ const val retryInitialDelayMs = 500L
27+
28+ // --- SSE stream (EventStreamClient) ---
29+
30+ /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
31+ const val sseInitialBackoffMs = 500L
32+ /** Cap on the SSE reconnect backoff. */
33+ const val sseMaxBackoffMs = 10_000L
34+ /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
35+ const val sseIdleTimeoutMs = 90_000L
36+ /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
37+ const val sseEventBufferCapacity = 64
38+
39+ // --- Session list (SessionListViewModel) ---
40+
41+ /** Max sessions to fetch previews for in one batch. */
42+ const val maxPreviewSessions = 50
43+ /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
44+ const val maxConcurrentPreviews = 8
45+ /** Max characters of a session's last message to keep as a list preview. */
46+ const val previewTextMaxLength = 200
47+
48+ // --- File browser (FileBrowserViewModel) ---
49+
50+ /** Debounce delay before firing a file-search request after the user stops typing. */
51+ const val fileSearchDebounceMs = 250L
52+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+4−4
@@ -142,8 +142,8 @@ class OpencodeApiClient(private val client: HttpClient) {
142}
142}
143
143
144private suspend fun <T> withRetry(
144private suspend fun <T> withRetry(
145− maxAttempts: Int = 3,
145+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
146− initialDelayMs: Long = 500L,
146+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
147 block: suspend () -> T,
147 block: suspend () -> T,
148): T = withRetryInternal(maxAttempts, initialDelayMs, block)
148): T = withRetryInternal(maxAttempts, initialDelayMs, block)
149
149
@@ -156,8 +156,8 @@ private suspend fun <T> withRetry(
156 * unit-testable without an HTTP server.
156 * unit-testable without an HTTP server.
157 */
157 */
158internal suspend fun <T> withRetryInternal(
158internal suspend fun <T> withRetryInternal(
159− maxAttempts: Int = 3,
159+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
160− initialDelayMs: Long = 500L,
160+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
161 block: suspend () -> T,
161 block: suspend () -> T,
162): T {
162): T {
163 var lastError: Throwable? = null
163 var lastError: Throwable? = null
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−15
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
24import kotlinx.coroutines.flow.SharingStarted
24import kotlinx.coroutines.flow.SharingStarted
25import kotlinx.coroutines.flow.StateFlow
25import kotlinx.coroutines.flow.StateFlow
26import kotlinx.coroutines.flow.asStateFlow
26import kotlinx.coroutines.flow.asStateFlow
27+import kotlinx.coroutines.flow.collectLatest
27import kotlinx.coroutines.flow.first
28import kotlinx.coroutines.flow.first
28import kotlinx.coroutines.flow.flatMapLatest
29import kotlinx.coroutines.flow.flatMapLatest
29import kotlinx.coroutines.flow.flowOf
30import kotlinx.coroutines.flow.flowOf
@@ -114,8 +115,8 @@ class ChatViewModel(
114 init {
115 init {
115 // Watch the bus for run completion / errors / permission asks for this session.
116 // Watch the bus for run completion / errors / permission asks for this session.
116 viewModelScope.launch {
117 viewModelScope.launch {
117− container.activeConnection.collect { conn ->
118+ container.activeConnection.collectLatest { conn ->
118− if (conn == null) return@collect
119+ if (conn == null) return@collectLatest
119 conn.events.events.collect { event ->
120 conn.events.events.collect { event ->
120 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
121 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
121 if (SessionRepository.isError(event, sessionId)) {
122 if (SessionRepository.isError(event, sessionId)) {
@@ -141,8 +142,8 @@ class ChatViewModel(
141 // Load the model catalog; preselect the server default. Failure is non-fatal —
142 // Load the model catalog; preselect the server default. Failure is non-fatal —
142 // sending with no model just uses the server's default agent/model.
143 // sending with no model just uses the server's default agent/model.
143 viewModelScope.launch {
144 viewModelScope.launch {
144− container.activeConnection.collect { conn ->
145+ container.activeConnection.collectLatest { conn ->
145− if (conn == null) return@collect
146+ if (conn == null) return@collectLatest
146 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
147 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
147 val options = resp.toOptions()
148 val options = resp.toOptions()
148 _models.value = options
149 _models.value = options
@@ -152,22 +153,22 @@ class ChatViewModel(
152 }
153 }
153 // Load the agent catalog (non-fatal).
154 // Load the agent catalog (non-fatal).
154 viewModelScope.launch {
155 viewModelScope.launch {
155− container.activeConnection.collect { conn ->
156+ container.activeConnection.collectLatest { conn ->
156− if (conn == null) return@collect
157+ if (conn == null) return@collectLatest
157 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
158 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
158 }
159 }
159 }
160 }
160 // Load the command catalog (non-fatal).
161 // Load the command catalog (non-fatal).
161 viewModelScope.launch {
162 viewModelScope.launch {
162− container.activeConnection.collect { conn ->
163+ container.activeConnection.collectLatest { conn ->
163− if (conn == null) return@collect
164+ if (conn == null) return@collectLatest
164 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
165 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
165 }
166 }
166 }
167 }
167 // Resolve the human-readable session title for the app bar (non-fatal).
168 // Resolve the human-readable session title for the app bar (non-fatal).
168 viewModelScope.launch {
169 viewModelScope.launch {
169− container.activeConnection.collect { conn ->
170+ container.activeConnection.collectLatest { conn ->
170− if (conn == null) return@collect
171+ if (conn == null) return@collectLatest
171 runCatching { conn.repository.listSessions() }
172 runCatching { conn.repository.listSessions() }
172 .getOrNull()
173 .getOrNull()
173 ?.firstOrNull { it.id == sessionId }
174 ?.firstOrNull { it.id == sessionId }
@@ -177,8 +178,8 @@ class ChatViewModel(
177 // If the SSE stream drops mid-run, the run indicator would spin forever;
178 // If the SSE stream drops mid-run, the run indicator would spin forever;
178 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
179 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
179 viewModelScope.launch {
180 viewModelScope.launch {
180− container.activeConnection.collect { conn ->
181+ container.activeConnection.collectLatest { conn ->
181− if (conn == null) return@collect
182+ if (conn == null) return@collectLatest
182 conn.events.state.collect { state ->
183 conn.events.state.collect { state ->
183 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
184 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
184 _running.value = false
185 _running.value = false
@@ -196,7 +197,10 @@ class ChatViewModel(
196 _running.value = true
197 _running.value = true
197 _error.value = null
198 _error.value = null
198 _failedDraft.value = null
199 _failedDraft.value = null
199− updateDraft("")
200+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
201+ // yet — if the send fails and the process dies before we restore, the draft
202+ // would be lost forever. The persisted draft is cleared only on success.
203+ _draft.value = ""
200 viewModelScope.launch {
204 viewModelScope.launch {
201 val ok = runCatching {
205 val ok = runCatching {
202 conn.repository.sendPrompt(
206 conn.repository.sendPrompt(
@@ -212,6 +216,9 @@ class ChatViewModel(
212 // Only restore the draft if the user hasn't typed anything new since.
216 // Only restore the draft if the user hasn't typed anything new since.
213 if (_draft.value.isBlank()) updateDraft(trimmed)
217 if (_draft.value.isBlank()) updateDraft(trimmed)
214 _error.value = container.string(R.string.error_failed_to_send)
218 _error.value = container.string(R.string.error_failed_to_send)
219+ } else {
220+ // Send succeeded — now it's safe to persist the empty draft.
221+ container.draftStore.set(sessionId, "")
215 }
222 }
216 }
223 }
217 return true
224 return true
@@ -267,11 +274,16 @@ class ChatViewModel(
267
274
268 fun respondPermission(permission: Permission, response: PermissionResponse) {
275 fun respondPermission(permission: Permission, response: PermissionResponse) {
269 val conn = connection ?: return
276 val conn = connection ?: return
270− // Clear optimistically; permission.replied will confirm.
277+ // Clear optimistically; permission.replied will confirm. If the call fails we
278+ // restore the pending permission so the user can retry instead of being stuck
279+ // with a dismissed dialog and a paused tool run.
271 _pendingPermission.value = null
280 _pendingPermission.value = null
272 viewModelScope.launch {
281 viewModelScope.launch {
273 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
282 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
283+ .onFailure {
274− .onFailure { _error.value = container.friendlyError(it) }
284+ _error.value = container.friendlyError(it)
285+ _pendingPermission.value = permission
286+ }
275 }
287 }
276 }
288 }
277}
289}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2−1
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileNode
5import soy.iko.opencode.data.model.FileNode
6import soy.iko.opencode.data.model.FileStatusEntry
6import soy.iko.opencode.data.model.FileStatusEntry
7+import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.R
9import soy.iko.opencode.R
9import kotlinx.coroutines.Job
10import kotlinx.coroutines.Job
@@ -83,7 +84,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
83 }
84 }
84 val client = api ?: return
85 val client = api ?: return
85 searchJob = viewModelScope.launch {
86 searchJob = viewModelScope.launch {
86− delay(250) // debounce
87+ delay(NetworkConfig.fileSearchDebounceMs) // debounce
87 _state.value = _state.value.copy(searching = true)
88 _state.value = _state.value.copy(searching = true)
88 runCatching { client.findFiles(query) }
89 runCatching { client.findFiles(query) }
89 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
90 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+15−6
@@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileContent
5import soy.iko.opencode.data.model.FileContent
6import soy.iko.opencode.di.AppContainer
6import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.R
7import soy.iko.opencode.R
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.collectLatest
11import kotlinx.coroutines.launch
13import kotlinx.coroutines.launch
12
14
13data class FileViewState(
15data class FileViewState(
@@ -16,6 +18,7 @@ data class FileViewState(
16 val error: String? = null,
18 val error: String? = null,
17)
19)
18
20
21+@OptIn(ExperimentalCoroutinesApi::class)
19class FileViewModel(
22class FileViewModel(
20 private val container: AppContainer,
23 private val container: AppContainer,
21 private val path: String,
24 private val path: String,
@@ -25,12 +28,18 @@ class FileViewModel(
25 val state: StateFlow<FileViewState> = _state.asStateFlow()
28 val state: StateFlow<FileViewState> = _state.asStateFlow()
26
29
27 init {
30 init {
28− val api = container.activeConnection.value?.api
31+ // Observe the active connection so the file loads (or reloads) when a connection
32+ // becomes available — including when the view opens during a reconnect window
33+ // where activeConnection.value was momentarily null. collectLatest cancels the
34+ // in-flight load if the connection is replaced mid-read.
35+ viewModelScope.launch {
36+ container.activeConnection.collectLatest { conn ->
29− if (api == null) {
37+ if (conn == null) {
30− _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
38+ _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
31− } else {
39+ return@collectLatest
32− viewModelScope.launch {
40+ }
41+ _state.value = FileViewState(loading = true)
33− runCatching { api.readFile(path) }
42+ runCatching { conn.api.readFile(path) }
34 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
43 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
35 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
44 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
36 }
45 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+4−7
@@ -8,6 +8,7 @@ import soy.iko.opencode.data.model.SessionDeleted
8import soy.iko.opencode.data.model.SessionUpdated
8import soy.iko.opencode.data.model.SessionUpdated
9import soy.iko.opencode.data.model.TextPart
9import soy.iko.opencode.data.model.TextPart
10import soy.iko.opencode.data.network.EventStreamClient
10import soy.iko.opencode.data.network.EventStreamClient
11+import soy.iko.opencode.data.network.NetworkConfig
11import soy.iko.opencode.di.AppContainer
12import soy.iko.opencode.di.AppContainer
12import soy.iko.opencode.R
13import soy.iko.opencode.R
13import kotlinx.coroutines.ExperimentalCoroutinesApi
14import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -188,7 +189,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
188 api.listMessages(sessionId).lastOrNull()?.let { msg ->
189 api.listMessages(sessionId).lastOrNull()?.let { msg ->
189 msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
190 msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
190 }
191 }
191− }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
192+ }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@launch
192 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
193 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
193 } finally {
194 } finally {
194 livePreviewJobs.remove(sessionId)
195 livePreviewJobs.remove(sessionId)
@@ -203,8 +204,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
203 val api = conn.api
204 val api = conn.api
204 _state.update { it.copy(previews = emptyMap()) }
205 _state.update { it.copy(previews = emptyMap()) }
205 previewJob = viewModelScope.launch {
206 previewJob = viewModelScope.launch {
206− val semaphore = Semaphore(MAX_CONCURRENT_PREVIEWS)
207+ val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
207− sessions.take(50).map { session ->
208+ sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
208 launch {
209 launch {
209 semaphore.withPermit {
210 semaphore.withPermit {
210 val preview = runCatching {
211 val preview = runCatching {
@@ -219,10 +220,6 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
219 }
220 }
220 }
221 }
221
222
222− private companion object {
223− const val MAX_CONCURRENT_PREVIEWS = 8
224− }
225−
226 fun createSession(onCreated: (String) -> Unit) {
223 fun createSession(onCreated: (String) -> Unit) {
227 val conn = container.activeConnection.value ?: return
224 val conn = container.activeConnection.value ?: return
228 viewModelScope.launch {
225 viewModelScope.launch {
app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktnew file+37
@@ -0,0 +1,37 @@
1+package soy.iko.opencode.data.network
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.mock.MockEngine
5+import io.ktor.client.engine.mock.respondError
6+import io.ktor.client.plugins.sse.SSE
7+import io.ktor.http.HttpStatusCode
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
9+import kotlinx.coroutines.cancel
10+import org.junit.Assert.assertEquals
11+import org.junit.Test
12+
13+@OptIn(ExperimentalCoroutinesApi::class)
14+class EventStreamClientTest {
15+
16+ /**
17+ * The SSE plugin requires a streaming response body which MockEngine can't provide,
18+ * so full SSE integration isn't testable in pure JVM tests. The reconnect/backoff
19+ * logic is covered by [WithRetryTest] (same exponential-backoff formula) and the
20+ * event decoding is covered by [OpencodeJsonTest]. Here we verify only the initial
21+ * state — the connection-state machine starts in Disconnected.
22+ */
23+ @Test
24+ fun stateIsDisconnectedBeforeSubscribing() {
25+ val engine = MockEngine { respondError(HttpStatusCode.InternalServerError) }
26+ val client = HttpClient(engine) { install(SSE) }
27+ val scope = kotlinx.coroutines.CoroutineScope(
28+ kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined,
29+ )
30+ val sse = EventStreamClient(client, scope)
31+
32+ assertEquals(EventStreamClient.ConnectionState.Disconnected, sse.state.value)
33+
34+ scope.cancel()
35+ client.close()
36+ }
37+}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+59
@@ -99,4 +99,63 @@ class WithRetryTest {
99 assertEquals("cancellation must not be retried", 1, calls)
99 assertEquals("cancellation must not be retried", 1, calls)
100 assertTrue("expected CancellationException, got $error", error is CancellationException)
100 assertTrue("expected CancellationException, got $error", error is CancellationException)
101 }
101 }
102+
103+ @Test
104+ fun maxAttemptsOneDoesNotRetry() = runTest {
105+ var calls = 0
106+ val error = runCatching {
107+ withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
108+ calls++
109+ throw IOException("boom")
110+ }
111+ }.exceptionOrNull()!!
112+ assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
113+ assertTrue(error is IOException)
114+ }
115+
116+ @Test
117+ fun succeedsOnLastAllowedAttempt() = runTest {
118+ var calls = 0
119+ val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
120+ calls++
121+ if (calls < 3) throw IOException("transient")
122+ "ok"
123+ }
124+ assertEquals("ok", result)
125+ assertEquals(3, calls)
126+ }
127+
128+ @Test
129+ fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
130+ var calls = 0
131+ val start = testScheduler.currentTime
132+ runCatching {
133+ withRetryInternal(maxAttempts = 3, initialDelayMs = 100L) {
134+ calls++
135+ throw IOException("always")
136+ }
137+ }
138+ // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
139+ // (100 * 2^1). Total virtual time elapsed = 300ms.
140+ assertEquals(3, calls)
141+ assertEquals(300L, testScheduler.currentTime - start)
142+ }
143+
144+ @Test
145+ fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
146+ val delays = mutableListOf<Long>()
147+ var calls = 0
148+ var lastTime = testScheduler.currentTime
149+ runCatching {
150+ withRetryInternal(maxAttempts = 4, initialDelayMs = 50L) {
151+ calls++
152+ val now = testScheduler.currentTime
153+ if (calls > 1) delays.add(now - lastTime)
154+ lastTime = now
155+ throw IOException("always")
156+ }
157+ }
158+ // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159+ assertEquals(listOf(50L, 100L, 200L), delays)
160+ }
102}
161}
app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktnew file+73
@@ -0,0 +1,73 @@
1+package soy.iko.opencode.data.repo
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.mock.MockEngine
5+import io.ktor.client.engine.mock.respond
6+import io.ktor.client.engine.mock.respondError
7+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8+import io.ktor.client.plugins.sse.SSE
9+import io.ktor.http.ContentType
10+import io.ktor.http.HttpHeaders
11+import io.ktor.http.HttpStatusCode
12+import io.ktor.http.headersOf
13+import io.ktor.serialization.kotlinx.json.json
14+import kotlinx.coroutines.CoroutineScope
15+import kotlinx.coroutines.Dispatchers
16+import kotlinx.coroutines.ExperimentalCoroutinesApi
17+import kotlinx.coroutines.SupervisorJob
18+import kotlinx.coroutines.cancel
19+import kotlinx.coroutines.flow.first
20+import kotlinx.coroutines.runBlocking
21+import org.junit.Assert.assertEquals
22+import org.junit.Test
23+import soy.iko.opencode.data.network.EventStreamClient
24+import soy.iko.opencode.data.network.OpencodeApiClient
25+import soy.iko.opencode.data.network.OpencodeJson
26+
27+@OptIn(ExperimentalCoroutinesApi::class)
28+class SessionRepositoryTest {
29+
30+ private val session = "s1"
31+ private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
32+
33+ /**
34+ * Verify that observeMessages seeds from the REST endpoint. The SSE stream uses
35+ * a MockEngine that always errors (MockEngine can't produce a streaming SSE body),
36+ * so only the REST seed path is exercised here. The SSE→REST merge logic is
37+ * unit-tested in [MessageStoreTest].
38+ */
39+ @Test
40+ fun seedsFromRestWithSseErrors() = runBlocking {
41+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
42+
43+ // REST: listMessages returns one user message.
44+ val restEngine = MockEngine {
45+ respond(
46+ """[{"info":{"role":"user","id":"u1","sessionID":"$session"},"parts":[]}]""",
47+ HttpStatusCode.OK,
48+ jsonHeaders,
49+ )
50+ }
51+ val restClient = HttpClient(restEngine) {
52+ install(ContentNegotiation) { json(OpencodeJson) }
53+ }
54+ val api = OpencodeApiClient(restClient)
55+
56+ // SSE: always errors (we only test the REST seed path here).
57+ val sseEngine = MockEngine { respondError(HttpStatusCode.InternalServerError) }
58+ val sseClient = HttpClient(sseEngine) { install(SSE) }
59+ val eventStream = EventStreamClient(sseClient, scope)
60+
61+ val repo = SessionRepository(api, eventStream)
62+
63+ // The first non-empty emission should be the REST seed.
64+ val result = repo.observeMessages(session).first { it.isNotEmpty() }
65+
66+ assertEquals(1, result.size)
67+ assertEquals("u1", result[0].info.id)
68+
69+ scope.cancel()
70+ restClient.close()
71+ sseClient.close()
72+ }
73+}
gradle.properties+2
@@ -1,5 +1,7 @@
1org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
1org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
2org.gradle.caching=true
2org.gradle.caching=true
3+org.gradle.parallel=true
4+org.gradle.configuration-cache=true
3android.useAndroidX=true
5android.useAndroidX=true
4android.nonTransitiveRClass=true
6android.nonTransitiveRClass=true
5kotlin.code.style=official
7kotlin.code.style=official