git.iko.soy/opencode-android
on 06fd41b help sign in
554 lines 27 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.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)
78@Composable
79fun ServerEditScreen(
80 container: AppContainer,
81 profileId: String?,
82 onDone: () -> Unit,
83 sourceId: String? = null,
84) {
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) }
91 fun safeExit() {
92 when {
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.
96 state.saving -> Unit
97 state.isDirty -> showDiscardConfirm = true
98 else -> onDone()
99 }
100 }
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
108 }
110 Scaffold(
111 topBar = {
112 TopAppBar(
113 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
114 navigationIcon = {
115 IconButton(onClick = ::safeExit) {
116 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
117 }
118 },
119 )
120 },
121 ) { padding ->
122 if (!state.loaded) {
123 val loadingLabel = stringResource(R.string.loading)
124 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
125 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
126 }
127 } else {
128 ServerEditForm(
129 state = state,
130 passwordVisible = passwordVisible,
131 onTogglePassword = { passwordVisible = !passwordVisible },
132 padding = padding,
133 vm = vm,
134 onDone = onDone,
135 )
136 }
137 }
139 if (showDiscardConfirm) {
140 AlertDialog(
141 onDismissRequest = { showDiscardConfirm = false },
142 title = { Text(stringResource(R.string.discard_title)) },
143 text = { Text(stringResource(R.string.discard_text)) },
144 confirmButton = {
145 TextButton(onClick = {
146 showDiscardConfirm = false
147 onDone()
148 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
149 },
150 dismissButton = {
151 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
152 },
153 )
154 }
157@Composable
158private fun ServerEditForm(
159 state: ServerEditState,
160 passwordVisible: Boolean,
161 onTogglePassword: () -> Unit,
162 padding: androidx.compose.foundation.layout.PaddingValues,
163 vm: ServerEditViewModel,
164 onDone: () -> Unit,
165) {
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()
181 keyboard?.show()
182 }
183 }
184 Column(
185 modifier = Modifier
186 .padding(padding)
187 .imePadding()
188 .fillMaxWidth()
189 .wrapContentWidth(Alignment.CenterHorizontally)
190 .widthIn(max = 600.dp)
191 .padding(16.dp)
192 .verticalScroll(rememberScrollState()),
193 verticalArrangement = Arrangement.spacedBy(12.dp),
194 ) {
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).
197 OutlinedTextField(
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) }
204 },
205 label = { Text(stringResource(R.string.base_url)) },
206 placeholder = { Text(stringResource(R.string.base_url_hint)) },
207 singleLine = true,
208 isError = state.baseUrl.isNotBlank() && !urlValid,
209 supportingText = {
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
218 TextButton(
219 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
220 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
221 ) {
222 Text(stringResource(labelRes))
223 }
224 } else {
225 Text(stringResource(R.string.invalid_url))
226 }
227 }
228 },
229 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
230 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
231 modifier = Modifier
232 .fillMaxWidth()
233 .focusRequester(baseUrlFocus)
234 .testTag("server_url"),
235 )
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)
238 // collapsed.
239 DiscoverySection(
240 initiallyExpanded = state.baseUrl.isBlank(),
241 onPick = { url -> vm.update { it.copy(baseUrl = url) } },
242 )
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) {
246 AuthFields(
247 state = state,
248 passwordVisible = passwordVisible,
249 onTogglePassword = onTogglePassword,
250 onUpdate = vm::update,
251 onTestCredentials = vm::testCredentials,
252 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
253 )
254 }
255 // Single primary action: probe-then-save-and-connect. Replaces the former
256 // Check-connectivity / Save / Save-&-connect trio.
257 Button(
258 onClick = {
259 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
260 vm.connect(onDone)
261 },
262 enabled = canSave && !state.saving,
263 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
264 ) {
265 if (state.saving) {
266 CircularProgressIndicator(
267 modifier = Modifier.size(20.dp),
268 color = MaterialTheme.colorScheme.onPrimary,
269 strokeWidth = 2.dp,
270 )
271 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
272 } else {
273 Text(stringResource(R.string.connect))
274 }
275 }
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 ->
279 Text(
280 message,
281 color = MaterialTheme.colorScheme.error,
282 style = MaterialTheme.typography.bodySmall,
283 modifier = Modifier.fillMaxWidth(),
284 )
285 }
286 // Secondary, low-emphasis: persist without connecting (offline setup).
287 TextButton(
288 onClick = {
289 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
290 vm.save(onDone)
291 },
292 enabled = canSave && !state.saving,
293 modifier = Modifier.fillMaxWidth().testTag("server_save"),
294 ) {
295 Text(stringResource(R.string.save_without_connecting))
296 }
297 // De-emphasized optional label at the bottom: displayLabel already falls back to the
298 // URL, so most servers don't need one.
299 OutlinedTextField(
300 value = state.label,
301 onValueChange = { v -> vm.update { it.copy(label = v) } },
302 label = { Text(stringResource(R.string.label_optional)) },
303 singleLine = true,
304 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
305 modifier = Modifier.fillMaxWidth().testTag("server_label"),
306 )
307 AdvancedSection(state = state, onUpdate = vm::update)
308 }
311@Composable
312private fun AuthFields(
313 state: ServerEditState,
314 passwordVisible: Boolean,
315 onTogglePassword: () -> Unit,
316 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
317 onTestCredentials: () -> Unit,
318 onImeDone: () -> Unit,
319) {
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() }
333 }
334 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
335 Text(
336 stringResource(R.string.auth_required),
337 style = MaterialTheme.typography.bodySmall,
338 color = MaterialTheme.colorScheme.error,
339 )
340 OutlinedTextField(
341 value = state.username,
342 onValueChange = { v -> onUpdate { it.copy(username = v) } },
343 label = { Text(stringResource(R.string.username_optional)) },
344 singleLine = true,
345 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
346 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
347 modifier = Modifier
348 .fillMaxWidth()
349 .focusRequester(usernameFocus)
350 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
351 .testTag("server_username"),
352 )
353 OutlinedTextField(
354 value = state.password,
355 onValueChange = { v -> onUpdate { it.copy(password = v) } },
356 label = { Text(stringResource(R.string.password_optional)) },
357 singleLine = true,
358 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
359 trailingIcon = {
360 IconButton(onClick = onTogglePassword) {
361 Icon(
362 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
363 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
364 )
365 }
366 },
367 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
368 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
369 modifier = Modifier
370 .fillMaxWidth()
371 .focusRequester(passwordFocus)
372 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
373 .testTag("server_password"),
374 )
375 OutlinedButton(
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"),
382 ) {
383 if (state.testingCredentials) {
384 CircularProgressIndicator(
385 modifier = Modifier.size(20.dp),
386 strokeWidth = 2.dp,
387 )
388 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
389 } else {
390 Text(stringResource(R.string.test_credentials))
391 }
392 }
393 state.credentialsResult?.let { ok ->
394 Text(
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,
398 )
399 }
400 }
403/**
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
408 * off by default.
409 */
410@Composable
411private fun AdvancedSection(
412 state: ServerEditState,
413 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
414) {
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()) {
418 TextButton(
419 onClick = { expanded = !expanded },
420 modifier = Modifier.semantics { stateDescription = stateLabel },
421 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
422 ) {
423 Icon(
424 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
425 contentDescription = null,
426 modifier = Modifier.size(18.dp),
427 )
428 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
429 }
430 AnimatedVisibility(visible = expanded) {
431 SecurityFields(state = state, onUpdate = onUpdate)
432 }
433 }
436/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
437@Composable
438private fun SecurityFields(
439 state: ServerEditState,
440 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
441) {
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)
446 Text(
447 stringResource(R.string.require_https_desc),
448 style = MaterialTheme.typography.bodySmall,
449 color = MaterialTheme.colorScheme.onSurfaceVariant,
450 )
451 }
452 Switch(
453 checked = state.requireHttps,
454 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
455 )
456 }
457 OutlinedTextField(
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)) },
462 singleLine = true,
463 isError = !state.certPinValid,
464 supportingText = {
465 Text(
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,
470 )
471 },
472 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
473 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
474 )
475 }
478/**
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.
484 */
485@Composable
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()) {
491 TextButton(
492 onClick = { expanded = !expanded },
493 modifier = Modifier.semantics { stateDescription = stateLabel },
494 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
495 ) {
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))
498 }
499 if (expanded) {
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
512 }
513 if (servers.isNotEmpty()) {
514 servers.forEach { server ->
515 Column(
516 modifier = Modifier
517 .fillMaxWidth()
518 .heightIn(min = 48.dp)
519 .clickable(role = Role.Button) { onPick(server.baseUrl) }
520 .padding(vertical = 8.dp),
521 verticalArrangement = Arrangement.Center,
522 ) {
523 Text(server.name, style = MaterialTheme.typography.bodyMedium)
524 Text(
525 server.baseUrl,
526 style = MaterialTheme.typography.bodySmall,
527 color = MaterialTheme.colorScheme.onSurfaceVariant,
528 )
529 }
530 }
531 } else if (searchTimedOut) {
532 Text(
533 stringResource(R.string.no_servers_found),
534 style = MaterialTheme.typography.bodySmall,
535 color = MaterialTheme.colorScheme.onSurfaceVariant,
536 modifier = Modifier.padding(vertical = 8.dp),
537 )
538 } else {
539 Row(
540 verticalAlignment = Alignment.CenterVertically,
541 modifier = Modifier.padding(vertical = 8.dp),
542 ) {
543 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
544 Text(
545 stringResource(R.string.searching_network),
546 style = MaterialTheme.typography.bodySmall,
547 color = MaterialTheme.colorScheme.onSurfaceVariant,
548 modifier = Modifier.padding(start = 8.dp),
549 )
550 }
551 }
552 }
553 }