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