git.iko.soy/opencode-android
on 0a26f8d help sign in
472 lines 21 kB history raw
1package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.imePadding
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.widthIn
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.verticalScroll
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
22import androidx.compose.material.icons.filled.Visibility
23import androidx.compose.material.icons.filled.VisibilityOff
24import androidx.compose.material.icons.filled.Wifi
25import androidx.compose.material3.AlertDialog
26import androidx.compose.material3.Button
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.OutlinedButton
33import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Switch
36import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
38import androidx.compose.material3.TopAppBar
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.mutableStateOf
42import androidx.compose.runtime.remember
43import androidx.compose.runtime.saveable.rememberSaveable
44import androidx.compose.runtime.setValue
45import androidx.compose.ui.Alignment
46import androidx.compose.ui.Modifier
47import androidx.compose.ui.platform.LocalContext
48import androidx.compose.ui.platform.testTag
49import androidx.compose.ui.semantics.contentDescription
50import androidx.compose.ui.semantics.semantics
51import androidx.compose.ui.text.input.ImeAction
52import androidx.compose.ui.text.input.KeyboardType
53import androidx.compose.ui.text.input.PasswordVisualTransformation
54import androidx.compose.ui.text.input.VisualTransformation
55import androidx.compose.ui.res.stringResource
56import androidx.compose.ui.unit.dp
57import androidx.lifecycle.compose.collectAsStateWithLifecycle
58import androidx.lifecycle.viewmodel.compose.viewModel
59import soy.iko.opencode.data.network.NsdDiscovery
60import soy.iko.opencode.di.AppContainer
61import soy.iko.opencode.R
62import soy.iko.opencode.ui.vmFactory
64@OptIn(ExperimentalMaterial3Api::class)
65@Composable
66fun ServerEditScreen(
67 container: AppContainer,
68 profileId: String?,
69 onDone: () -> Unit,
70 sourceId: String? = null,
71) {
72 val vm: ServerEditViewModel =
73 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
74 val state by vm.state.collectAsStateWithLifecycle()
75 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
76 var passwordVisible by rememberSaveable { mutableStateOf(false) }
78 fun safeExit() {
79 when {
80 // A save is in flight: ignore the back gesture (matching the BackHandler guard
81 // below). Popping here would cancel the save's viewModelScope coroutine and
82 // silently abandon the write; the VM calls onDone itself once the save lands.
83 state.saving -> Unit
84 state.isDirty -> showDiscardConfirm = true
85 else -> onDone()
86 }
87 }
89 // Intercept the system back gesture when the form has unsaved changes so the user
90 // gets a chance to keep editing instead of losing their work silently. Stays enabled
91 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
92 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
93 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
94 if (!state.saving) showDiscardConfirm = true
95 }
97 Scaffold(
98 topBar = {
99 TopAppBar(
100 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
101 navigationIcon = {
102 IconButton(onClick = ::safeExit) {
103 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
104 }
105 },
106 )
107 },
108 ) { padding ->
109 if (!state.loaded) {
110 val loadingLabel = stringResource(R.string.loading)
111 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
112 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
113 }
114 } else {
115 ServerEditForm(
116 state = state,
117 passwordVisible = passwordVisible,
118 onTogglePassword = { passwordVisible = !passwordVisible },
119 padding = padding,
120 vm = vm,
121 onDone = onDone,
122 )
123 }
124 }
126 if (showDiscardConfirm) {
127 AlertDialog(
128 onDismissRequest = { showDiscardConfirm = false },
129 title = { Text(stringResource(R.string.discard_title)) },
130 text = { Text(stringResource(R.string.discard_text)) },
131 confirmButton = {
132 TextButton(onClick = {
133 showDiscardConfirm = false
134 onDone()
135 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
136 },
137 dismissButton = {
138 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
139 },
140 )
141 }
144@Composable
145private fun ServerEditForm(
146 state: ServerEditState,
147 passwordVisible: Boolean,
148 onTogglePassword: () -> Unit,
149 padding: androidx.compose.foundation.layout.PaddingValues,
150 vm: ServerEditViewModel,
151 onDone: () -> Unit,
152) {
153 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
154 // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
155 // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
156 // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
157 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
158 Column(
159 modifier = Modifier
160 .padding(padding)
161 .imePadding()
162 .widthIn(max = 600.dp)
163 .padding(16.dp)
164 .verticalScroll(rememberScrollState()),
165 verticalArrangement = Arrangement.spacedBy(12.dp),
166 ) {
167 OutlinedTextField(
168 value = state.label,
169 onValueChange = { v -> vm.update { it.copy(label = v) } },
170 label = { Text(stringResource(R.string.label_optional)) },
171 singleLine = true,
172 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
173 modifier = Modifier.fillMaxWidth().testTag("server_label"),
174 )
175 OutlinedTextField(
176 value = state.baseUrl,
177 onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
178 label = { Text(stringResource(R.string.base_url)) },
179 placeholder = { Text(stringResource(R.string.base_url_hint)) },
180 singleLine = true,
181 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
182 supportingText = {
183 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
184 // Offer a one-tap "http://" fix for bare host:port input instead of a
185 // generic error, so the user doesn't have to know the URL needs a scheme.
186 val suggestion = suggestUrlScheme(state.baseUrl)
187 if (suggestion != null) {
188 TextButton(
189 onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
190 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
191 ) {
192 Text(stringResource(R.string.suggest_scheme))
193 }
194 } else {
195 Text(stringResource(R.string.invalid_url))
196 }
197 }
198 },
199 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
200 modifier = Modifier.fillMaxWidth().testTag("server_url"),
201 )
202 OutlinedButton(
203 onClick = { vm.probe() },
204 enabled = canSave && !state.probing && !state.saving,
205 modifier = Modifier.fillMaxWidth().testTag("server_probe"),
206 ) {
207 if (state.probing) {
208 CircularProgressIndicator(
209 modifier = Modifier.size(20.dp),
210 strokeWidth = 2.dp,
211 )
212 Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
213 } else {
214 Text(stringResource(R.string.check_connectivity))
215 }
216 }
217 // Positive feedback when the probe found the server reachable without auth, so
218 // the user knows the connection works before saving (closes the loop that the
219 // prior "silently does nothing" Reachable path left open).
220 if (state.probeReachable == true && !state.authFieldsVisible) {
221 Text(
222 stringResource(R.string.server_reachable),
223 style = MaterialTheme.typography.bodySmall,
224 color = MaterialTheme.colorScheme.primary,
225 modifier = Modifier.fillMaxWidth(),
226 )
227 }
228 DiscoverySection(
229 onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
230 )
231 if (!state.authFieldsVisible) {
232 Text(
233 stringResource(R.string.auth_help),
234 style = MaterialTheme.typography.bodySmall,
235 color = MaterialTheme.colorScheme.onSurfaceVariant,
236 modifier = Modifier.fillMaxWidth(),
237 )
238 }
239 AnimatedVisibility(visible = state.authFieldsVisible) {
240 AuthFields(
241 state = state,
242 passwordVisible = passwordVisible,
243 onTogglePassword = onTogglePassword,
244 onUpdate = vm::update,
245 onTestCredentials = vm::testCredentials,
246 onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
247 )
248 }
249 SecurityFields(state = state, onUpdate = vm::update)
250 Row(
251 modifier = Modifier.fillMaxWidth(),
252 horizontalArrangement = Arrangement.spacedBy(12.dp),
253 ) {
254 OutlinedButton(
255 onClick = {
256 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
257 vm.save(onDone)
258 },
259 enabled = canSave && !state.saving,
260 modifier = Modifier.weight(1f),
261 ) {
262 if (state.saving) {
263 CircularProgressIndicator(
264 modifier = Modifier.size(20.dp),
265 color = MaterialTheme.colorScheme.primary,
266 strokeWidth = 2.dp,
267 )
268 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
269 } else {
270 Text(stringResource(R.string.save))
271 }
272 }
273 Button(
274 onClick = {
275 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
276 vm.saveAndConnect(onDone)
277 },
278 enabled = canSave && !state.saving,
279 modifier = Modifier.weight(1f),
280 ) {
281 Text(stringResource(R.string.save_and_connect))
282 }
283 }
284 state.error?.let { message ->
285 Text(
286 message,
287 color = MaterialTheme.colorScheme.error,
288 style = MaterialTheme.typography.bodySmall,
289 modifier = Modifier.fillMaxWidth(),
290 )
291 }
292 }
295@Composable
296private fun AuthFields(
297 state: ServerEditState,
298 passwordVisible: Boolean,
299 onTogglePassword: () -> Unit,
300 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
301 onTestCredentials: () -> Unit,
302 onImeDone: () -> Unit,
303) {
304 val showPasswordLabel = stringResource(R.string.show_password)
305 val hidePasswordLabel = stringResource(R.string.hide_password)
306 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
307 // every keystroke's recomposition (see ServerEditForm).
308 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
309 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
310 Text(
311 stringResource(R.string.auth_required),
312 style = MaterialTheme.typography.bodySmall,
313 color = MaterialTheme.colorScheme.error,
314 )
315 OutlinedTextField(
316 value = state.username,
317 onValueChange = { v -> onUpdate { it.copy(username = v) } },
318 label = { Text(stringResource(R.string.username_optional)) },
319 singleLine = true,
320 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
321 modifier = Modifier.fillMaxWidth().testTag("server_username"),
322 )
323 OutlinedTextField(
324 value = state.password,
325 onValueChange = { v -> onUpdate { it.copy(password = v) } },
326 label = { Text(stringResource(R.string.password_optional)) },
327 singleLine = true,
328 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
329 trailingIcon = {
330 IconButton(onClick = onTogglePassword) {
331 Icon(
332 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
333 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
334 )
335 }
336 },
337 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
338 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
339 modifier = Modifier.fillMaxWidth().testTag("server_password"),
340 )
341 OutlinedButton(
342 onClick = onTestCredentials,
343 // Require at least one credential field: testCredentials() no-ops when both are
344 // blank, so without this the button is tappable but silently does nothing.
345 enabled = canSave && !state.testingCredentials && !state.saving &&
346 (state.username.isNotBlank() || state.password.isNotBlank()),
347 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
348 ) {
349 if (state.testingCredentials) {
350 CircularProgressIndicator(
351 modifier = Modifier.size(20.dp),
352 strokeWidth = 2.dp,
353 )
354 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
355 } else {
356 Text(stringResource(R.string.test_credentials))
357 }
358 }
359 state.credentialsResult?.let { ok ->
360 Text(
361 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
362 style = MaterialTheme.typography.bodySmall,
363 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
364 )
365 }
366 }
369/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
370 * server certificate. Both are off by default so the common localhost/HTTP setup is
371 * unaffected. */
372@Composable
373private fun SecurityFields(
374 state: ServerEditState,
375 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
376) {
377 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
378 Text(
379 stringResource(R.string.server_security),
380 style = MaterialTheme.typography.titleSmall,
381 color = MaterialTheme.colorScheme.primary,
382 )
383 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
384 Column(modifier = Modifier.weight(1f)) {
385 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
386 Text(
387 stringResource(R.string.require_https_desc),
388 style = MaterialTheme.typography.bodySmall,
389 color = MaterialTheme.colorScheme.onSurfaceVariant,
390 )
391 }
392 Switch(
393 checked = state.requireHttps,
394 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
395 )
396 }
397 OutlinedTextField(
398 value = state.certPin,
399 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
400 label = { Text(stringResource(R.string.cert_pin)) },
401 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
402 singleLine = true,
403 isError = !state.certPinValid,
404 supportingText = {
405 Text(
406 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
407 style = MaterialTheme.typography.bodySmall,
408 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
409 else MaterialTheme.colorScheme.error,
410 )
411 },
412 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
413 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
414 )
415 }
418/**
419 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
420 * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
421 * its multicast traffic) only runs while the section is expanded — the collection stops when
422 * the section is collapsed or the screen leaves composition.
423 */
424@Composable
425private fun DiscoverySection(onPick: (String) -> Unit) {
426 val context = LocalContext.current
427 var expanded by rememberSaveable { mutableStateOf(false) }
428 Column(modifier = Modifier.fillMaxWidth()) {
429 TextButton(
430 onClick = { expanded = !expanded },
431 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
432 ) {
433 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
434 Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
435 }
436 if (expanded) {
437 val discovery = remember { NsdDiscovery(context) }
438 val servers by remember(discovery) { discovery.discover() }
439 .collectAsStateWithLifecycle(initialValue = emptyList())
440 if (servers.isEmpty()) {
441 Row(
442 verticalAlignment = Alignment.CenterVertically,
443 modifier = Modifier.padding(vertical = 8.dp),
444 ) {
445 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
446 Text(
447 stringResource(R.string.searching_network),
448 style = MaterialTheme.typography.bodySmall,
449 color = MaterialTheme.colorScheme.onSurfaceVariant,
450 modifier = Modifier.padding(start = 8.dp),
451 )
452 }
453 } else {
454 servers.forEach { server ->
455 Column(
456 modifier = Modifier
457 .fillMaxWidth()
458 .clickable { onPick(server.baseUrl) }
459 .padding(vertical = 8.dp),
460 ) {
461 Text(server.name, style = MaterialTheme.typography.bodyMedium)
462 Text(
463 server.baseUrl,
464 style = MaterialTheme.typography.bodySmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 )
467 }
468 }
469 }
470 }
471 }