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.heightIn
13import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.layout.wrapContentWidth
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.filled.ExpandLess
25import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.Visibility
27import androidx.compose.material.icons.filled.VisibilityOff
28import androidx.compose.material.icons.filled.Wifi
29import androidx.compose.material3.AlertDialog
30import androidx.compose.material3.Button
31import androidx.compose.material3.CircularProgressIndicator
32import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.OutlinedButton
37import androidx.compose.material3.OutlinedTextField
38import androidx.compose.material3.Scaffold
39import androidx.compose.material3.Switch
40import androidx.compose.material3.Text
41import androidx.compose.material3.TextButton
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
46import androidx.compose.runtime.mutableStateOf
47import androidx.compose.runtime.remember
48import androidx.compose.runtime.saveable.rememberSaveable
49import androidx.compose.runtime.setValue
50import androidx.compose.ui.Alignment
51import androidx.compose.ui.Modifier
52import androidx.compose.ui.focus.FocusRequester
53import androidx.compose.ui.focus.focusRequester
54import androidx.compose.ui.platform.LocalContext
55import androidx.compose.ui.platform.LocalSoftwareKeyboardController
56import androidx.compose.ui.platform.testTag
57import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.semantics.stateDescription
61import androidx.compose.ui.text.input.ImeAction
62import androidx.compose.ui.text.input.KeyboardType
63import androidx.compose.ui.text.input.PasswordVisualTransformation
64import androidx.compose.ui.text.input.VisualTransformation
65import androidx.compose.ui.res.stringResource
66import androidx.compose.ui.unit.dp
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.viewmodel.compose.viewModel
69import soy.iko.opencode.data.network.NsdDiscovery
70import soy.iko.opencode.data.network.NetworkConfig
71import soy.iko.opencode.di.AppContainer
72import soy.iko.opencode.R
73import soy.iko.opencode.ui.components.autofillModifier
74import soy.iko.opencode.ui.components.AutofillHint
75import soy.iko.opencode.ui.vmFactory
77@OptIn(ExperimentalMaterial3Api::class)
80 container: AppContainer,
83 sourceId: String? = null,
85 val vm: ServerEditViewModel =
86 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
87 val state by vm.state.collectAsStateWithLifecycle()
88 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
89 var passwordVisible by rememberSaveable { mutableStateOf(false) }
93 // A save is in flight: ignore the back gesture (matching the BackHandler guard
94 // below). Popping here would cancel the save's viewModelScope coroutine and
95 // silently abandon the write; the VM calls onDone itself once the save lands.
97 state.isDirty -> showDiscardConfirm = true
102 // Intercept the system back gesture when the form has unsaved changes so the user
103 // gets a chance to keep editing instead of losing their work silently. Stays enabled
104 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
105 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
106 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
107 if (!state.saving) showDiscardConfirm = true
113 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
115 IconButton(onClick = ::safeExit) {
116 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
123 val loadingLabel = stringResource(R.string.loading)
124 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
125 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
130 passwordVisible = passwordVisible,
131 onTogglePassword = { passwordVisible = !passwordVisible },
139 if (showDiscardConfirm) {
141 onDismissRequest = { showDiscardConfirm = false },
142 title = { Text(stringResource(R.string.discard_title)) },
143 text = { Text(stringResource(R.string.discard_text)) },
145 TextButton(onClick = {
146 showDiscardConfirm = false
148 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
151 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
158private fun ServerEditForm(
159 state: ServerEditState,
160 passwordVisible: Boolean,
161 onTogglePassword: () -> Unit,
162 padding: androidx.compose.foundation.layout.PaddingValues,
163 vm: ServerEditViewModel,
166 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
167 // canSave parses the base URL with OkHttp's parser (which allocates on the common
168 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
169 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
170 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
171 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
172 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
173 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
174 // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
175 // user can start typing the required field immediately.
176 val baseUrlFocus = remember { FocusRequester() }
177 val keyboard = LocalSoftwareKeyboardController.current
178 LaunchedEffect(Unit) {
179 if (state.isNew && state.baseUrl.isBlank()) {
180 baseUrlFocus.requestFocus()
189 .wrapContentWidth(Alignment.CenterHorizontally)
190 .widthIn(max = 600.dp)
192 .verticalScroll(rememberScrollState()),
193 verticalArrangement = Arrangement.spacedBy(12.dp),
195 // Base URL is the only required field, so it leads the form and takes focus on a
196 // fresh add form (via baseUrlFocus above).
198 value = state.baseUrl,
199 onValueChange = { v ->
200 // A paste of a full QR/JSON config into the URL field populates the whole form
201 // (so a user can paste a shared server config instead of scanning an image).
202 // Falls through to a normal URL edit when it isn't a recognized payload.
203 if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
205 label = { Text(stringResource(R.string.base_url)) },
206 placeholder = { Text(stringResource(R.string.base_url_hint)) },
208 isError = state.baseUrl.isNotBlank() && !urlValid,
210 if (state.baseUrl.isNotBlank() && !urlValid) {
211 // Offer a one-tap scheme fix for bare host:port input instead of a
212 // generic error, so the user doesn't have to know the URL needs a scheme.
213 // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
214 // domains) so the quick-fix doesn't nudge users onto cleartext.
215 val suggestion = suggestUrlScheme(state.baseUrl)
216 if (suggestion != null) {
217 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
219 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
220 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
222 Text(stringResource(labelRes))
225 Text(stringResource(R.string.invalid_url))
229 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
230 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
233 .focusRequester(baseUrlFocus)
234 .testTag("server_url"),
236 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
237 // server is the primary path; on edit the populated URL keeps it (and its multicast)
240 initiallyExpanded = state.baseUrl.isBlank(),
241 onPick = { url -> vm.update { it.copy(baseUrl = url) } },
243 // Auth fields appear only once a connect attempt learns the server requires them
244 // (or an authed profile is loaded) — the common no-auth server never sees them.
245 AnimatedVisibility(visible = state.authFieldsVisible) {
248 passwordVisible = passwordVisible,
249 onTogglePassword = onTogglePassword,
250 onUpdate = vm::update,
251 onTestCredentials = vm::testCredentials,
252 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
255 // Single primary action: probe-then-save-and-connect. Replaces the former
256 // Check-connectivity / Save / Save-&-connect trio.
259 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
262 enabled = canSave && !state.saving,
263 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
266 CircularProgressIndicator(
267 modifier = Modifier.size(20.dp),
268 color = MaterialTheme.colorScheme.onPrimary,
271 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
273 Text(stringResource(R.string.connect))
276 // Keep the error directly under the primary action so it's in view where the user is
277 // looking when a Connect/Save fails — not buried further down the form.
278 state.error?.let { message ->
281 color = MaterialTheme.colorScheme.error,
282 style = MaterialTheme.typography.bodySmall,
283 modifier = Modifier.fillMaxWidth(),
286 // Secondary, low-emphasis: persist without connecting (offline setup).
289 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
292 enabled = canSave && !state.saving,
293 modifier = Modifier.fillMaxWidth().testTag("server_save"),
295 Text(stringResource(R.string.save_without_connecting))
297 // De-emphasized optional label at the bottom: displayLabel already falls back to the
298 // URL, so most servers don't need one.
301 onValueChange = { v -> vm.update { it.copy(label = v) } },
302 label = { Text(stringResource(R.string.label_optional)) },
304 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
305 modifier = Modifier.fillMaxWidth().testTag("server_label"),
307 AdvancedSection(state = state, onUpdate = vm::update)
312private fun AuthFields(
313 state: ServerEditState,
314 passwordVisible: Boolean,
315 onTogglePassword: () -> Unit,
316 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
317 onTestCredentials: () -> Unit,
318 onImeDone: () -> Unit,
320 val showPasswordLabel = stringResource(R.string.show_password)
321 val hidePasswordLabel = stringResource(R.string.hide_password)
322 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
323 // every keystroke's recomposition (see ServerEditForm).
324 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
325 val usernameFocus = remember { FocusRequester() }
326 val passwordFocus = remember { FocusRequester() }
327 // AuthFields only enters composition once the server is known to require credentials
328 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
329 // and focuses the username — otherwise the user has to tap into it after the probe flips
330 // the auth fields open.
331 LaunchedEffect(Unit) {
332 runCatching { usernameFocus.requestFocus() }
334 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
336 stringResource(R.string.auth_required),
337 style = MaterialTheme.typography.bodySmall,
338 color = MaterialTheme.colorScheme.error,
341 value = state.username,
342 onValueChange = { v -> onUpdate { it.copy(username = v) } },
343 label = { Text(stringResource(R.string.username_optional)) },
345 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
346 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
349 .focusRequester(usernameFocus)
350 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
351 .testTag("server_username"),
354 value = state.password,
355 onValueChange = { v -> onUpdate { it.copy(password = v) } },
356 label = { Text(stringResource(R.string.password_optional)) },
358 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
360 IconButton(onClick = onTogglePassword) {
362 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
363 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
367 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
368 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
371 .focusRequester(passwordFocus)
372 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
373 .testTag("server_password"),
376 onClick = onTestCredentials,
377 // Require at least one credential field: testCredentials() no-ops when both are
378 // blank, so without this the button is tappable but silently does nothing.
379 enabled = canSave && !state.testingCredentials && !state.saving &&
380 (state.username.isNotBlank() || state.password.isNotBlank()),
381 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
383 if (state.testingCredentials) {
384 CircularProgressIndicator(
385 modifier = Modifier.size(20.dp),
388 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
390 Text(stringResource(R.string.test_credentials))
393 state.credentialsResult?.let { ok ->
395 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
396 style = MaterialTheme.typography.bodySmall,
397 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
404 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
405 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
406 * these settings (so an invalid pin can't silently disable Connect from inside a closed
407 * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
411private fun AdvancedSection(
412 state: ServerEditState,
413 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
415 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
416 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
417 Column(modifier = Modifier.fillMaxWidth()) {
419 onClick = { expanded = !expanded },
420 modifier = Modifier.semantics { stateDescription = stateLabel },
421 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
424 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
425 contentDescription = null,
426 modifier = Modifier.size(18.dp),
428 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
430 AnimatedVisibility(visible = expanded) {
431 SecurityFields(state = state, onUpdate = onUpdate)
436/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
438private fun SecurityFields(
439 state: ServerEditState,
440 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
442 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
443 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
444 Column(modifier = Modifier.weight(1f)) {
445 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
447 stringResource(R.string.require_https_desc),
448 style = MaterialTheme.typography.bodySmall,
449 color = MaterialTheme.colorScheme.onSurfaceVariant,
453 checked = state.requireHttps,
454 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
458 value = state.certPin,
459 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
460 label = { Text(stringResource(R.string.cert_pin)) },
461 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
463 isError = !state.certPinValid,
466 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
467 style = MaterialTheme.typography.bodySmall,
468 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
469 else MaterialTheme.colorScheme.error,
472 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
473 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
479 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
480 * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
481 * runs while the section is expanded — the collection stops when the section is collapsed or
482 * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
483 * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
486private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
487 val context = LocalContext.current
488 var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
489 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
490 Column(modifier = Modifier.fillMaxWidth()) {
492 onClick = { expanded = !expanded },
493 modifier = Modifier.semantics { stateDescription = stateLabel },
494 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
496 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
497 Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
500 val discovery = remember { NsdDiscovery(context) }
501 val servers by remember(discovery) { discovery.discover() }
502 .collectAsStateWithLifecycle(initialValue = emptyList())
503 // mDNS discovery is a continuous flow that never emits a terminal "no results"
504 // state — on a network with no opencode servers the spinner would spin forever.
505 // After the timeout, swap the spinner for a "no servers found" message. Discovery
506 // keeps running, so a late server can still replace the message when it arrives.
507 var searchTimedOut by remember { mutableStateOf(false) }
508 LaunchedEffect(Unit) {
509 searchTimedOut = false
510 kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
511 searchTimedOut = true
513 if (servers.isNotEmpty()) {
514 servers.forEach { server ->
518 .heightIn(min = 48.dp)
519 .clickable(role = Role.Button) { onPick(server.baseUrl) }
520 .padding(vertical = 8.dp),
521 verticalArrangement = Arrangement.Center,
523 Text(server.name, style = MaterialTheme.typography.bodyMedium)
526 style = MaterialTheme.typography.bodySmall,
527 color = MaterialTheme.colorScheme.onSurfaceVariant,
531 } else if (searchTimedOut) {
533 stringResource(R.string.no_servers_found),
534 style = MaterialTheme.typography.bodySmall,
535 color = MaterialTheme.colorScheme.onSurfaceVariant,
536 modifier = Modifier.padding(vertical = 8.dp),
540 verticalAlignment = Alignment.CenterVertically,
541 modifier = Modifier.padding(vertical = 8.dp),
543 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
545 stringResource(R.string.searching_network),
546 style = MaterialTheme.typography.bodySmall,
547 color = MaterialTheme.colorScheme.onSurfaceVariant,
548 modifier = Modifier.padding(start = 8.dp),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list