git.iko.soy/opencode-android
on master help sign in
Fix unsafe casts, cleartext credential exposure, SSE reconnect deadlock, main-thread disk I/O, preview job race, and add accessibility loading/expand states
2ec6140iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−4
@@ -139,11 +139,10 @@ class EventStreamClient(
139 throw c
139 throw c
140 } catch (e: Exception) {
140 } catch (e: Exception) {
141 if (isSseAuthFailure(e)) {
141 if (isSseAuthFailure(e)) {
142− Log.w("EventStream", "SSE auth failed, stopping reconnect", e)
142+ Log.w("EventStream", "SSE auth failed, will retry with backoff", e)
143− _state.value = ConnectionState.Disconnected
143+ } else {
144− throw e
144+ Log.w("EventStream", "SSE stream error, will retry", e)
145 }
145 }
146− Log.w("EventStream", "SSE stream error, will retry", e)
147 }
146 }
148 _state.value = ConnectionState.Disconnected
147 _state.value = ConnectionState.Disconnected
149 if (!isActive) break
148 if (!isActive) break
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+10−4
@@ -60,8 +60,12 @@ object HttpClientFactory {
60 password = profile.password.orEmpty(),
60 password = profile.password.orEmpty(),
61 )
61 )
62 }
62 }
63− // Send eagerly so opencode doesn't need a 401 challenge round-trip.
63+ // Send eagerly so opencode doesn't need a 401 challenge round-trip,
64+ // but only over HTTPS — sending credentials over cleartext HTTP
65+ // would expose them on the network.
64− sendWithoutRequest { true }
66+ sendWithoutRequest { request ->
67+ request.url.protocol.name == "https"
68+ }
65 }
69 }
66 }
70 }
67 }
71 }
@@ -71,13 +75,15 @@ object HttpClientFactory {
71 }
75 }
72 }
76 }
73
77
74− /** Ensure a scheme and a single trailing slash so relative paths resolve correctly. */
78+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
79+ * Defaults to [https] when no scheme is given so credentials aren't accidentally
80+ * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
75 fun normalizeBaseUrl(raw: String): String {
81 fun normalizeBaseUrl(raw: String): String {
76 val trimmed = raw.trim()
82 val trimmed = raw.trim()
77 val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
83 val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
78 trimmed
84 trimmed
79 } else {
85 } else {
80− "http://$trimmed"
86+ "https://$trimmed"
81 }
87 }
82 return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
88 return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
83 }
89 }
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+15−3
@@ -1,6 +1,8 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4+import java.util.concurrent.CountDownLatch
5+import java.util.concurrent.TimeUnit
4import kotlinx.coroutines.CoroutineScope
6import kotlinx.coroutines.CoroutineScope
5import kotlinx.coroutines.launch
7import kotlinx.coroutines.launch
6
8
@@ -15,19 +17,28 @@ import kotlinx.coroutines.launch
15class DraftStore(context: Context, scope: CoroutineScope) {
17class DraftStore(context: Context, scope: CoroutineScope) {
16
18
17 private val appContext = context.applicationContext
19 private val appContext = context.applicationContext
20+ private val prefsReady = CountDownLatch(1)
18 private val prefs by lazy {
21 private val prefs by lazy {
19 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
22 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
20 }
23 }
21
24
22 init {
25 init {
23 // Eagerly load the prefs file on a background thread so the first main-thread
26 // Eagerly load the prefs file on a background thread so the first main-thread
24− // get() doesn't block on disk I/O. The lazy delegate ensures the same instance.
27+ // get() doesn't block on disk I/O. The latch ensures get() waits for this
28+ // background load instead of triggering its own lazy load on the main thread.
25− scope.launch { prefs.all }
29+ scope.launch {
30+ prefs.all
31+ prefsReady.countDown()
32+ }
26 }
33 }
27
34
28− fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
35+ fun get(sessionId: String): String {
36+ prefsReady.await(2, TimeUnit.SECONDS)
37+ return prefs.getString(sessionId, "").orEmpty()
38+ }
29
39
30 fun set(sessionId: String, text: String) {
40 fun set(sessionId: String, text: String) {
41+ prefsReady.await(2, TimeUnit.SECONDS)
31 prefs.edit().apply {
42 prefs.edit().apply {
32 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
43 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
33 }.apply()
44 }.apply()
@@ -35,6 +46,7 @@ class DraftStore(context: Context, scope: CoroutineScope) {
35
46
36 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
47 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
37 fun remove(sessionId: String) {
48 fun remove(sessionId: String) {
49+ prefsReady.await(2, TimeUnit.SECONDS)
38 prefs.edit().remove(sessionId).apply()
50 prefs.edit().remove(sessionId).apply()
39 }
51 }
40}
52}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+1−1
@@ -20,7 +20,7 @@ object NotificationChannels {
20
20
21 fun create(context: Context) {
21 fun create(context: Context) {
22 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
22 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
23− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
23+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
24 nm.createNotificationChannel(
24 nm.createNotificationChannel(
25 NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
25 NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
26 description = context.getString(R.string.notif_channel_status_desc)
26 description = context.getString(R.string.notif_channel_status_desc)
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12−1
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Check
13import androidx.compose.material.icons.filled.Check
14+import androidx.compose.material3.CircularProgressIndicator
14import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.Icon
16import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.MaterialTheme
@@ -25,6 +26,8 @@ import androidx.compose.ui.semantics.role
25import androidx.compose.ui.semantics.selected
26import androidx.compose.ui.semantics.selected
26import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
29+import androidx.compose.foundation.layout.Box
30+import androidx.compose.foundation.layout.fillMaxWidth
28import soy.iko.opencode.data.model.Agent
31import soy.iko.opencode.data.model.Agent
29import soy.iko.opencode.R
32import soy.iko.opencode.R
30
33
@@ -34,6 +37,7 @@ import soy.iko.opencode.R
34fun AgentPickerSheet(
37fun AgentPickerSheet(
35 agents: List<Agent>,
38 agents: List<Agent>,
36 selected: String?,
39 selected: String?,
40+ loading: Boolean,
37 onSelect: (Agent?) -> Unit,
41 onSelect: (Agent?) -> Unit,
38 onDismiss: () -> Unit,
42 onDismiss: () -> Unit,
39) {
43) {
@@ -43,7 +47,14 @@ fun AgentPickerSheet(
43 style = MaterialTheme.typography.titleMedium,
47 style = MaterialTheme.typography.titleMedium,
44 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
48 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
45 )
49 )
50+ if (loading) {
51+ Box(
52+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
53+ contentAlignment = Alignment.Center,
54+ ) {
55+ CircularProgressIndicator()
56+ }
46− if (agents.isEmpty()) {
57+ } else if (agents.isEmpty()) {
47 Text(
58 Text(
48 stringResource(R.string.no_agents),
59 stringResource(R.string.no_agents),
49 style = MaterialTheme.typography.bodyMedium,
60 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+6
@@ -101,12 +101,15 @@ fun ChatScreen(
101 val error by vm.error.collectAsStateWithLifecycle()
101 val error by vm.error.collectAsStateWithLifecycle()
102 val loading by vm.loading.collectAsStateWithLifecycle()
102 val loading by vm.loading.collectAsStateWithLifecycle()
103 val models by vm.models.collectAsStateWithLifecycle()
103 val models by vm.models.collectAsStateWithLifecycle()
104+ val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
104 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
105 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
105 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
106 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
106 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
107 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
107 val agents by vm.agents.collectAsStateWithLifecycle()
108 val agents by vm.agents.collectAsStateWithLifecycle()
109+ val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
108 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
110 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
109 val commands by vm.commands.collectAsStateWithLifecycle()
111 val commands by vm.commands.collectAsStateWithLifecycle()
112+ val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
110 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
113 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
111 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
114 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
112 val draft by vm.draft.collectAsStateWithLifecycle()
115 val draft by vm.draft.collectAsStateWithLifecycle()
@@ -353,6 +356,7 @@ fun ChatScreen(
353 ModelPickerSheet(
356 ModelPickerSheet(
354 options = models,
357 options = models,
355 selected = selectedModel,
358 selected = selectedModel,
359+ loading = modelsLoading,
356 onSelect = { vm.selectModel(it) },
360 onSelect = { vm.selectModel(it) },
357 onDismiss = { showModelPicker = false },
361 onDismiss = { showModelPicker = false },
358 )
362 )
@@ -362,6 +366,7 @@ fun ChatScreen(
362 AgentPickerSheet(
366 AgentPickerSheet(
363 agents = agents,
367 agents = agents,
364 selected = selectedAgent,
368 selected = selectedAgent,
369+ loading = agentsLoading,
365 onSelect = { vm.selectAgent(it?.name) },
370 onSelect = { vm.selectAgent(it?.name) },
366 onDismiss = { showAgentPicker = false },
371 onDismiss = { showAgentPicker = false },
367 )
372 )
@@ -370,6 +375,7 @@ fun ChatScreen(
370 if (showCommandPicker) {
375 if (showCommandPicker) {
371 CommandPickerSheet(
376 CommandPickerSheet(
372 commands = commands,
377 commands = commands,
378+ loading = commandsLoading,
373 onSelect = { vm.runCommand(it) },
379 onSelect = { vm.runCommand(it) },
374 onDismiss = { showCommandPicker = false },
380 onDismiss = { showCommandPicker = false },
375 )
381 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−3
@@ -67,6 +67,9 @@ class ChatViewModel(
67 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
67 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
68 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
68 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
69
69
70+ private val _modelsLoading = MutableStateFlow(true)
71+ val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
72+
70 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
73 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
71 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
74 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
72
75
@@ -85,12 +88,18 @@ class ChatViewModel(
85 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
88 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
86 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
89 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
87
90
91+ private val _agentsLoading = MutableStateFlow(true)
92+ val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
93+
88 private val _selectedAgent = MutableStateFlow<String?>(null)
94 private val _selectedAgent = MutableStateFlow<String?>(null)
89 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
95 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
90
96
91 private val _commands = MutableStateFlow<List<Command>>(emptyList())
97 private val _commands = MutableStateFlow<List<Command>>(emptyList())
92 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
98 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
93
99
100+ private val _commandsLoading = MutableStateFlow(true)
101+ val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
102+
94 private val _sessionTitle = MutableStateFlow<String?>(null)
103 private val _sessionTitle = MutableStateFlow<String?>(null)
95 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
104 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
96
105
@@ -144,7 +153,8 @@ class ChatViewModel(
144 // sending with no model just uses the server's default agent/model.
153 // sending with no model just uses the server's default agent/model.
145 viewModelScope.launch {
154 viewModelScope.launch {
146 container.activeConnection.collectLatest { conn ->
155 container.activeConnection.collectLatest { conn ->
147− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; return@collectLatest }
156+ if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
157+ _modelsLoading.value = true
148 runCatching { conn.api.providers() }
158 runCatching { conn.api.providers() }
149 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
159 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150 .getOrNull()?.let { resp ->
160 .getOrNull()?.let { resp ->
@@ -152,24 +162,29 @@ class ChatViewModel(
152 _models.value = options
162 _models.value = options
153 _selectedModel.value = resp.defaultOption(options)
163 _selectedModel.value = resp.defaultOption(options)
154 }
164 }
165+ _modelsLoading.value = false
155 }
166 }
156 }
167 }
157 // Load the agent catalog (non-fatal).
168 // Load the agent catalog (non-fatal).
158 viewModelScope.launch {
169 viewModelScope.launch {
159 container.activeConnection.collectLatest { conn ->
170 container.activeConnection.collectLatest { conn ->
160− if (conn == null) { _agents.value = emptyList(); return@collectLatest }
171+ if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
172+ _agentsLoading.value = true
161 runCatching { conn.api.agents() }
173 runCatching { conn.api.agents() }
162 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
174 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163 .getOrNull()?.let { _agents.value = it }
175 .getOrNull()?.let { _agents.value = it }
176+ _agentsLoading.value = false
164 }
177 }
165 }
178 }
166 // Load the command catalog (non-fatal).
179 // Load the command catalog (non-fatal).
167 viewModelScope.launch {
180 viewModelScope.launch {
168 container.activeConnection.collectLatest { conn ->
181 container.activeConnection.collectLatest { conn ->
169− if (conn == null) { _commands.value = emptyList(); return@collectLatest }
182+ if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
183+ _commandsLoading.value = true
170 runCatching { conn.api.commands() }
184 runCatching { conn.api.commands() }
171 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
185 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172 .getOrNull()?.let { _commands.value = it }
186 .getOrNull()?.let { _commands.value = it }
187+ _commandsLoading.value = false
173 }
188 }
174 }
189 }
175 // Resolve the human-readable session title for the app bar (non-fatal).
190 // Resolve the human-readable session title for the app bar (non-fatal).
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12−1
@@ -9,18 +9,21 @@ import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
9import androidx.compose.foundation.lazy.items
10import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
11import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
12+import androidx.compose.material3.CircularProgressIndicator
12import androidx.compose.material3.ExperimentalMaterial3Api
13import androidx.compose.material3.ExperimentalMaterial3Api
13import androidx.compose.material3.Icon
14import androidx.compose.material3.Icon
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.ModalBottomSheet
16import androidx.compose.material3.ModalBottomSheet
16import androidx.compose.material3.Text
17import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.Composable
19+import androidx.compose.ui.Alignment
18import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
19import androidx.compose.ui.res.stringResource
21import androidx.compose.ui.res.stringResource
20import androidx.compose.ui.semantics.Role
22import androidx.compose.ui.semantics.Role
21import androidx.compose.ui.semantics.role
23import androidx.compose.ui.semantics.role
22import androidx.compose.ui.text.style.TextOverflow
24import androidx.compose.ui.text.style.TextOverflow
23import androidx.compose.ui.unit.dp
25import androidx.compose.ui.unit.dp
26+import androidx.compose.foundation.layout.Box
24import soy.iko.opencode.data.model.Command
27import soy.iko.opencode.data.model.Command
25import soy.iko.opencode.R
28import soy.iko.opencode.R
26
29
@@ -29,6 +32,7 @@ import soy.iko.opencode.R
29@Composable
32@Composable
30fun CommandPickerSheet(
33fun CommandPickerSheet(
31 commands: List<Command>,
34 commands: List<Command>,
35+ loading: Boolean,
32 onSelect: (Command) -> Unit,
36 onSelect: (Command) -> Unit,
33 onDismiss: () -> Unit,
37 onDismiss: () -> Unit,
34) {
38) {
@@ -38,7 +42,14 @@ fun CommandPickerSheet(
38 style = MaterialTheme.typography.titleMedium,
42 style = MaterialTheme.typography.titleMedium,
39 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
43 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
40 )
44 )
45+ if (loading) {
46+ Box(
47+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
48+ contentAlignment = Alignment.Center,
49+ ) {
50+ CircularProgressIndicator()
51+ }
41− if (commands.isEmpty()) {
52+ } else if (commands.isEmpty()) {
42 Text(
53 Text(
43 stringResource(R.string.no_commands),
54 stringResource(R.string.no_commands),
44 style = MaterialTheme.typography.bodyMedium,
55 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+12−1
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Check
13import androidx.compose.material.icons.filled.Check
14+import androidx.compose.material3.CircularProgressIndicator
14import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.Icon
16import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.MaterialTheme
@@ -25,6 +26,8 @@ import androidx.compose.ui.semantics.role
25import androidx.compose.ui.semantics.selected
26import androidx.compose.ui.semantics.selected
26import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
29+import androidx.compose.foundation.layout.Box
30+import androidx.compose.foundation.layout.fillMaxWidth
28import soy.iko.opencode.data.model.ModelOption
31import soy.iko.opencode.data.model.ModelOption
29import soy.iko.opencode.R
32import soy.iko.opencode.R
30
33
@@ -34,6 +37,7 @@ import soy.iko.opencode.R
34fun ModelPickerSheet(
37fun ModelPickerSheet(
35 options: List<ModelOption>,
38 options: List<ModelOption>,
36 selected: ModelOption?,
39 selected: ModelOption?,
40+ loading: Boolean,
37 onSelect: (ModelOption) -> Unit,
41 onSelect: (ModelOption) -> Unit,
38 onDismiss: () -> Unit,
42 onDismiss: () -> Unit,
39) {
43) {
@@ -43,7 +47,14 @@ fun ModelPickerSheet(
43 style = MaterialTheme.typography.titleMedium,
47 style = MaterialTheme.typography.titleMedium,
44 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
48 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
45 )
49 )
50+ if (loading) {
51+ Box(
52+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
53+ contentAlignment = Alignment.Center,
54+ ) {
55+ CircularProgressIndicator()
56+ }
46− if (options.isEmpty()) {
57+ } else if (options.isEmpty()) {
47 Text(
58 Text(
48 stringResource(R.string.no_models),
59 stringResource(R.string.no_models),
49 style = MaterialTheme.typography.bodyMedium,
60 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+30−5
@@ -34,8 +34,10 @@ import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
35import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.platform.LocalContext
36import androidx.compose.ui.semantics.Role
36import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.contentDescription
37import androidx.compose.ui.semantics.role
38import androidx.compose.ui.semantics.role
38import androidx.compose.ui.semantics.semantics
39import androidx.compose.ui.semantics.semantics
40+import androidx.compose.ui.semantics.stateDescription
39import androidx.compose.ui.text.font.FontFamily
41import androidx.compose.ui.text.font.FontFamily
40import androidx.compose.ui.res.stringResource
42import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.dp
@@ -92,12 +94,17 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
92 if (text.isBlank()) return
94 if (text.isBlank()) return
93 val context = LocalContext.current
95 val context = LocalContext.current
94 var expanded by remember { mutableStateOf(false) }
96 var expanded by remember { mutableStateOf(false) }
97+ val expandedState = stringResource(R.string.state_expanded)
98+ val collapsedState = stringResource(R.string.state_collapsed)
95 Column(modifier = modifier.fillMaxWidth()) {
99 Column(modifier = modifier.fillMaxWidth()) {
96 Row(
100 Row(
97 modifier = Modifier
101 modifier = Modifier
98 .clickable { expanded = !expanded }
102 .clickable { expanded = !expanded }
99 .padding(vertical = 4.dp)
103 .padding(vertical = 4.dp)
104+ .semantics {
100− .semantics { role = Role.Button },
105+ role = Role.Button
106+ stateDescription = if (expanded) expandedState else collapsedState
107+ },
101 verticalAlignment = Alignment.CenterVertically,
108 verticalAlignment = Alignment.CenterVertically,
102 ) {
109 ) {
103 if (streaming) {
110 if (streaming) {
@@ -167,6 +174,8 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
167 if (detail != null) {
174 if (detail != null) {
168 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
175 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
169 var expanded by remember(detail) { mutableStateOf(false) }
176 var expanded by remember(detail) { mutableStateOf(false) }
177+ val expandedState = stringResource(R.string.state_expanded)
178+ val collapsedState = stringResource(R.string.state_collapsed)
170 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
179 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
171 if (looksLikeDiff(display)) {
180 if (looksLikeDiff(display)) {
172 DiffView(display)
181 DiffView(display)
@@ -182,6 +191,9 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
182 TextButton(
191 TextButton(
183 onClick = { expanded = !expanded },
192 onClick = { expanded = !expanded },
184 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
193 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
194+ modifier = Modifier.semantics {
195+ stateDescription = if (expanded) expandedState else collapsedState
196+ },
185 ) {
197 ) {
186 Icon(
198 Icon(
187 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
199 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -210,13 +222,26 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
210private fun ToolStatusIcon(state: ToolState) {
222private fun ToolStatusIcon(state: ToolState) {
211 when (state) {
223 when (state) {
212 is ToolPending, is ToolRunning ->
224 is ToolPending, is ToolRunning ->
213− CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
225+ CircularProgressIndicator(
226+ modifier = Modifier.size(16.dp).semantics { contentDescription = "" },
227+ strokeWidth = 2.dp,
228+ )
214 is ToolCompleted ->
229 is ToolCompleted ->
230+ Icon(
231+ Icons.Filled.CheckCircle,
232+ contentDescription = stringResource(R.string.tool_completed),
233+ modifier = Modifier.size(16.dp),
215− Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
234+ tint = MaterialTheme.colorScheme.primary,
235+ )
216 is ToolError ->
236 is ToolError ->
237+ Icon(
238+ Icons.Filled.Error,
239+ contentDescription = stringResource(R.string.tool_error),
240+ modifier = Modifier.size(16.dp),
217− Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
241+ tint = MaterialTheme.colorScheme.error,
242+ )
218 is ToolUnknown ->
243 is ToolUnknown ->
219− Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
244+ Icon(Icons.Filled.Bolt, contentDescription = null, modifier = Modifier.size(16.dp))
220 }
245 }
221}
246}
222
247
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1
@@ -96,7 +96,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
96 ) {
96 ) {
97 IconButton(
97 IconButton(
98 onClick = { copyToClipboard(context, "code", code) },
98 onClick = { copyToClipboard(context, "code", code) },
99− modifier = Modifier.size(28.dp),
99+ modifier = Modifier.size(48.dp),
100 ) {
100 ) {
101 Icon(
101 Icon(
102 Icons.Filled.ContentCopy,
102 Icons.Filled.ContentCopy,
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+30−2
@@ -1,18 +1,26 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import android.util.Base64
3import android.util.Base64
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.heightIn
6import androidx.compose.foundation.layout.heightIn
5import androidx.compose.foundation.shape.RoundedCornerShape
7import androidx.compose.foundation.shape.RoundedCornerShape
8+import androidx.compose.material3.MaterialTheme
6import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
7import androidx.compose.runtime.remember
10import androidx.compose.runtime.remember
11+import androidx.compose.ui.Alignment
8import androidx.compose.ui.Modifier
12import androidx.compose.ui.Modifier
9import androidx.compose.ui.draw.clip
13import androidx.compose.ui.draw.clip
10import androidx.compose.ui.layout.ContentScale
14import androidx.compose.ui.layout.ContentScale
11import androidx.compose.ui.platform.LocalContext
15import androidx.compose.ui.platform.LocalContext
12import androidx.compose.ui.res.stringResource
16import androidx.compose.ui.res.stringResource
13import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
14−import coil.compose.AsyncImage
18+import coil.compose.SubcomposeAsyncImage
15import coil.request.ImageRequest
19import coil.request.ImageRequest
20+import androidx.compose.material3.CircularProgressIndicator
21+import androidx.compose.material3.Icon
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.BrokenImage
16import soy.iko.opencode.R
24import soy.iko.opencode.R
17import soy.iko.opencode.data.model.FilePart
25import soy.iko.opencode.data.model.FilePart
18import soy.iko.opencode.data.model.ServerProfile
26import soy.iko.opencode.data.model.ServerProfile
@@ -88,12 +96,32 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
88 .crossfade(true)
96 .crossfade(true)
89 .build()
97 .build()
90 }
98 }
91− AsyncImage(
99+ SubcomposeAsyncImage(
92 model = request,
100 model = request,
93 contentDescription = part.filename ?: stringResource(R.string.image),
101 contentDescription = part.filename ?: stringResource(R.string.image),
94 contentScale = ContentScale.FillWidth,
102 contentScale = ContentScale.FillWidth,
95 modifier = modifier
103 modifier = modifier
96 .heightIn(max = 320.dp)
104 .heightIn(max = 320.dp)
97 .clip(RoundedCornerShape(12.dp)),
105 .clip(RoundedCornerShape(12.dp)),
106+ loading = {
107+ Box(
108+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
109+ contentAlignment = Alignment.Center,
110+ ) {
111+ CircularProgressIndicator()
112+ }
113+ },
114+ error = {
115+ Box(
116+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
117+ contentAlignment = Alignment.Center,
118+ ) {
119+ Icon(
120+ Icons.Filled.BrokenImage,
121+ contentDescription = stringResource(R.string.image_failed),
122+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
123+ )
124+ }
125+ },
98 )
126 )
99}
127}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+5−3
@@ -133,8 +133,8 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
133 .padding(horizontal = 16.dp, vertical = 8.dp),
133 .padding(horizontal = 16.dp, vertical = 8.dp),
134 verticalAlignment = Alignment.CenterVertically,
134 verticalAlignment = Alignment.CenterVertically,
135 ) {
135 ) {
136− IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
136+ IconButton(onClick = { onNavigate("") }) {
137− Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(18.dp))
137+ Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
138 }
138 }
139 var acc = ""
139 var acc = ""
140 segments.forEachIndexed { index, segment ->
140 segments.forEachIndexed { index, segment ->
@@ -150,7 +150,9 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
150 segment,
150 segment,
151 style = MaterialTheme.typography.bodyMedium,
151 style = MaterialTheme.typography.bodyMedium,
152 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
152 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
153+ modifier = Modifier
153− modifier = Modifier.clickable(role = Role.Button) { onNavigate(target) }.padding(vertical = 2.dp),
154+ .clickable(role = Role.Button) { onNavigate(target) }
155+ .padding(horizontal = 6.dp, vertical = 14.dp),
154 )
156 )
155 }
157 }
156 }
158 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+14−3
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.imePadding
8import androidx.compose.foundation.layout.imePadding
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.widthIn
11import androidx.compose.foundation.layout.widthIn
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardActions
@@ -19,6 +20,7 @@ import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.ExperimentalMaterial3Api
20import androidx.compose.material3.ExperimentalMaterial3Api
20import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
21import androidx.compose.material3.IconButton
22import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.OutlinedTextField
24import androidx.compose.material3.OutlinedTextField
23import androidx.compose.material3.Scaffold
25import androidx.compose.material3.Scaffold
24import androidx.compose.material3.Text
26import androidx.compose.material3.Text
@@ -111,15 +113,24 @@ fun ServerEditScreen(
111 singleLine = true,
113 singleLine = true,
112 visualTransformation = PasswordVisualTransformation(),
114 visualTransformation = PasswordVisualTransformation(),
113 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
115 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
114− keyboardActions = KeyboardActions(onDone = { if (state.canSave) vm.save(onDone) }),
116+ keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
115 modifier = Modifier.fillMaxWidth(),
117 modifier = Modifier.fillMaxWidth(),
116 )
118 )
117 Button(
119 Button(
118 onClick = { vm.save(onDone) },
120 onClick = { vm.save(onDone) },
119− enabled = state.canSave,
121+ enabled = state.canSave && !state.saving,
120 modifier = Modifier.fillMaxWidth(),
122 modifier = Modifier.fillMaxWidth(),
121 ) {
123 ) {
124+ if (state.saving) {
125+ CircularProgressIndicator(
126+ modifier = Modifier.size(20.dp),
127+ color = MaterialTheme.colorScheme.onPrimary,
128+ strokeWidth = 2.dp,
129+ )
130+ Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
131+ } else {
122− Text(stringResource(R.string.save))
132+ Text(stringResource(R.string.save))
133+ }
123 }
134 }
124 state.error?.let { message ->
135 state.error?.let { message ->
125 Text(
136 Text(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+4−2
@@ -19,6 +19,7 @@ data class ServerEditState(
19 val username: String = "",
19 val username: String = "",
20 val password: String = "",
20 val password: String = "",
21 val loaded: Boolean = false,
21 val loaded: Boolean = false,
22+ val saving: Boolean = false,
22 val error: String? = null,
23 val error: String? = null,
23) {
24) {
24 val canSave: Boolean get() = baseUrl.isNotBlank()
25 val canSave: Boolean get() = baseUrl.isNotBlank()
@@ -61,7 +62,8 @@ class ServerEditViewModel(
61
62
62 fun save(onDone: () -> Unit) {
63 fun save(onDone: () -> Unit) {
63 val s = _state.value
64 val s = _state.value
64− if (!s.canSave) return
65+ if (!s.canSave || s.saving) return
66+ _state.value = s.copy(saving = true)
65 viewModelScope.launch {
67 viewModelScope.launch {
66 val result = runCatching {
68 val result = runCatching {
67 val existingLastUsed = if (s.id != null) {
69 val existingLastUsed = if (s.id != null) {
@@ -82,7 +84,7 @@ class ServerEditViewModel(
82 )
84 )
83 }
85 }
84 result.onSuccess { onDone() }
86 result.onSuccess { onDone() }
85− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save") }
87+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save", saving = false) }
86 }
88 }
87 }
89 }
88}
90}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+13−9
@@ -93,8 +93,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
93
93
94 /** Per-session in-flight preview loads, so rapid SSE events for the same session
94 /** Per-session in-flight preview loads, so rapid SSE events for the same session
95 * coalesce into a single request instead of firing one per event. Accessed from
95 * coalesce into a single request instead of firing one per event. Accessed from
96− * multiple coroutines (the SSE event handler and [loadPreview]), so kept concurrent. */
96+ * multiple coroutines (the SSE event handler and [loadPreview]), so the
97+ * remove-then-put sequence is guarded by [previewLock] to stay atomic. */
97 private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
98 private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
99+ private val previewLock = Any()
98
100
99 private val activeProfileId: String?
101 private val activeProfileId: String?
100 get() = container.activeConnection.value?.profile?.id
102 get() = container.activeConnection.value?.profile?.id
@@ -191,14 +193,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
191 private fun loadPreview(sessionId: String) {
193 private fun loadPreview(sessionId: String) {
192 val conn = container.activeConnection.value ?: return
194 val conn = container.activeConnection.value ?: return
193 val api = conn.api
195 val api = conn.api
196+ synchronized(previewLock) {
194− livePreviewJobs.remove(sessionId)?.cancel()
197+ livePreviewJobs.remove(sessionId)?.cancel()
195− livePreviewJobs[sessionId] = viewModelScope.launch {
198+ livePreviewJobs[sessionId] = viewModelScope.launch {
196− val job = coroutineContext[kotlinx.coroutines.Job]
199+ val job = coroutineContext[kotlinx.coroutines.Job]
197− try {
200+ try {
198− val preview = fetchSessionPreview(api, sessionId) ?: return@launch
201+ val preview = fetchSessionPreview(api, sessionId) ?: return@launch
199− _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
202+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
200− } finally {
203+ } finally {
201− if (job != null) livePreviewJobs.remove(sessionId, job)
204+ if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
205+ }
202 }
206 }
203 }
207 }
204 }
208 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+9−1
@@ -1,6 +1,8 @@
1package soy.iko.opencode.ui.theme
1package soy.iko.opencode.ui.theme
2
2
3import android.app.Activity
3import android.app.Activity
4+import android.content.Context
5+import android.content.ContextWrapper
4import android.os.Build
6import android.os.Build
5import androidx.compose.foundation.isSystemInDarkTheme
7import androidx.compose.foundation.isSystemInDarkTheme
6import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.MaterialTheme
@@ -45,7 +47,7 @@ fun OpencodeTheme(
45 val view = LocalView.current
47 val view = LocalView.current
46 if (!view.isInEditMode) {
48 if (!view.isInEditMode) {
47 SideEffect {
49 SideEffect {
48− val window = (view.context as Activity).window
50+ val window = view.context.findActivity()?.window ?: return@SideEffect
49 WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
51 WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
50 }
52 }
51 }
53 }
@@ -54,3 +56,9 @@ fun OpencodeTheme(
54 content = content,
56 content = content,
55 )
57 )
56}
58}
59+
60+private tailrec fun Context.findActivity(): Activity? = when (this) {
61+ is Activity -> this
62+ is ContextWrapper -> baseContext.findActivity()
63+ else -> null
64+}
app/src/main/res/values/strings.xml+10
@@ -100,6 +100,11 @@
100 <string name="thoughts">Thoughts</string>
100 <string name="thoughts">Thoughts</string>
101 <string name="show_more">Show more</string>
101 <string name="show_more">Show more</string>
102 <string name="show_less">Show less</string>
102 <string name="show_less">Show less</string>
103+ <string name="tool_completed">Completed</string>
104+ <string name="tool_error">Error</string>
105+ <string name="tool_running">In progress</string>
106+ <string name="state_expanded">Expanded</string>
107+ <string name="state_collapsed">Collapsed</string>
103
108
104 <!-- Permission dialog -->
109 <!-- Permission dialog -->
105 <string name="permission_title">Permission requested</string>
110 <string name="permission_title">Permission requested</string>
@@ -116,6 +121,11 @@
116 <string name="default_agent">Default</string>
121 <string name="default_agent">Default</string>
117 <string name="default_agent_desc">Server default agent</string>
122 <string name="default_agent_desc">Server default agent</string>
118 <string name="no_commands">No commands reported by the server.</string>
123 <string name="no_commands">No commands reported by the server.</string>
124+ <string name="loading_models">Loading models…</string>
125+ <string name="loading_agents">Loading agents…</string>
126+ <string name="loading_commands">Loading commands…</string>
127+ <string name="saving">Saving…</string>
128+ <string name="image_failed">Failed to load image</string>
119
129
120 <!-- File browser -->
130 <!-- File browser -->
121 <string name="search_files">Search files…</string>
131 <string name="search_files">Search files…</string>
app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt+3−3
@@ -7,7 +7,7 @@ class NormalizeBaseUrlTest {
7
7
8 @Test
8 @Test
9 fun addsHttpSchemeWhenAbsent() {
9 fun addsHttpSchemeWhenAbsent() {
10− assertEquals("http://192.168.1.10:4096/", HttpClientFactory.normalizeBaseUrl("192.168.1.10:4096"))
10+ assertEquals("https://192.168.1.10:4096/", HttpClientFactory.normalizeBaseUrl("192.168.1.10:4096"))
11 }
11 }
12
12
13 @Test
13 @Test
@@ -22,7 +22,7 @@ class NormalizeBaseUrlTest {
22
22
23 @Test
23 @Test
24 fun appendsTrailingSlash() {
24 fun appendsTrailingSlash() {
25− assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl("host"))
25+ assertEquals("https://host/", HttpClientFactory.normalizeBaseUrl("host"))
26 }
26 }
27
27
28 @Test
28 @Test
@@ -37,6 +37,6 @@ class NormalizeBaseUrlTest {
37
37
38 @Test
38 @Test
39 fun keepsPathAfterHost() {
39 fun keepsPathAfterHost() {
40− assertEquals("http://host:4096/opencode/", HttpClientFactory.normalizeBaseUrl("host:4096/opencode"))
40+ assertEquals("https://host:4096/opencode/", HttpClientFactory.normalizeBaseUrl("host:4096/opencode"))
41 }
41 }
42}
42}