git.iko.soy/opencode-android
on 2794fda help sign in
523 lines 26 kB history raw
1package soy.iko.opencode.ui.server
3import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.ServerProfile
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.di.ProbeResult
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.first
14import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
16import kotlinx.coroutines.withTimeoutOrNull
17import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
18import soy.iko.opencode.R
19import soy.iko.opencode.util.runCatchingCancellable
20import java.util.UUID
22@Immutable
23data class ServerEditState(
24 val id: String? = null,
25 val label: String = "",
26 val baseUrl: String = "",
27 val username: String = "",
28 val password: String = "",
29 /** Upgrade cleartext http:// to https:// for this server. */
30 val requireHttps: Boolean = false,
31 /** Optional OkHttp certificate pin(s), "sha256/<base64>" (whitespace/comma separated). */
32 val certPin: String = "",
33 val loaded: Boolean = false,
34 val saving: Boolean = false,
35 val error: String? = null,
36 /** Whether the auth (username/password) fields are shown. */
37 val authFieldsVisible: Boolean = false,
38 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
39 val initial: InitialProfile? = null,
40 /** Whether a credential test is in progress. */
41 val testingCredentials: Boolean = false,
42 /** Non-null when the last credential test succeeded (true) or failed (false). */
43 val credentialsResult: Boolean? = null,
44 /** Whether a reachability probe (Test connection) is in progress. */
45 val testingConnection: Boolean = false,
46 /** Latency (ms) of the last successful reachability probe, or an error message. */
47 val connectionResult: ConnectionProbeResult? = null,
48) {
49 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
50 val isNew: Boolean get() = id == null
51 /** A blank pin is fine (feature off); otherwise every entry must be a valid OkHttp pin. */
52 val certPinValid: Boolean get() = isValidCertPin(certPin)
53 /** True if any field differs from its loaded value. */
54 val isDirty: Boolean
55 get() {
56 val init = initial ?: return false
57 return label.trim() != init.label ||
58 baseUrl.trim() != init.baseUrl ||
59 username.trim() != init.username ||
60 password.trim() != init.password ||
61 requireHttps != init.requireHttps ||
62 certPin.trim() != init.certPin
63 }
66/** Result of a "Test connection" reachability probe from the edit screen. */
67@Immutable
68sealed interface ConnectionProbeResult {
69 data class Success(val latencyMs: Long) : ConnectionProbeResult
70 data class Failed(val message: String) : ConnectionProbeResult
73/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
74data class InitialProfile(
75 val label: String,
76 val baseUrl: String,
77 val username: String,
78 val password: String,
79 val requireHttps: Boolean = false,
80 val certPin: String = "",
83/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
84 * separated entry is a well-formed OkHttp "sha256/<base64>" (or legacy "sha1/<base64>") pin. */
85fun isValidCertPin(raw: String): Boolean {
86 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
87 if (entries.isEmpty()) return true
88 // Require a well-formed sha256/ or sha1/ <base64>= pin (at least one base64 char before the
89 // padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===" and
90 // unsupported algorithms like "md5/…". sha1 is accepted because OkHttp's CertificatePinner
91 // accepts it too — the validator must match what the runtime accepts, or a well-formed
92 // sha1 pin would be un-saveable. A real pin is 43 base64 chars + '=', but we accept any
93 // length for demo/test pins.
94 val pinRegex = Regex("(sha256|sha1)/[A-Za-z0-9+/]+=")
95 return entries.all { it.matches(pinRegex) }
98fun isValidUrl(url: String): Boolean {
99 val trimmed = url.trim()
100 if (trimmed.isBlank()) return false
101 // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
102 // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
103 // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
104 // already implies a valid scheme; a bare host without a scheme returns null, keeping the
105 // "suggest http:// prefix" flow in suggestUrlScheme() intact.
106 return trimmed.toHttpUrlOrNull() != null
109/**
110 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
111 * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
112 * one-tap fix instead of a generic "invalid URL" error.
113 *
114 * Picks `https://` for shapes that imply TLS by convention (explicit :443 port, or a
115 * public-looking domain with no port) so the quick-fix doesn't nudge users onto cleartext.
116 * Local/LAN hosts (localhost, private IP ranges, `*.local`) stay `http://` — the common
117 * `opencode serve` case over the LAN.
118 */
119fun suggestUrlScheme(input: String): String? {
120 val trimmed = input.trim()
121 if (trimmed.isEmpty() || "://" in trimmed) return null
122 val scheme = if (looksLikeTls(trimmed)) "https" else "http"
123 val candidate = "$scheme://$trimmed"
124 return if (isValidUrl(candidate)) candidate else null
127private fun looksLikeTls(input: String): Boolean {
128 val host = input.substringBefore('/').lowercase()
129 val hostNoPort = host.substringBefore(':')
130 val port = host.substringAfter(':', missingDelimiterValue = "")
131 if (port == "443") return true
132 if (port.isNotEmpty()) return false
133 if (hostNoPort == "localhost") return false
134 if (isPrivateHost(hostNoPort)) return false
135 // A dotless single-label host (e.g. an mDNS/Bonjour name on some setups) is ambiguous;
136 // keep cleartext as the LAN-friendly default rather than suggesting TLS.
137 if ('.' !in hostNoPort) return false
138 return true
141private fun isPrivateHost(host: String): Boolean {
142 if (host.endsWith(".local")) return true
143 val octets = host.split('.')
144 if (octets.size == 4 && octets.all { it.toIntOrNull() in 0..255 }) {
145 val a = octets[0].toInt()
146 val b = octets[1].toInt()
147 if (a == 10) return true
148 if (a == 192 && b == 168) return true
149 if (a == 172 && b in 16..31) return true
150 if (a == 127) return true
151 if (a == 169 && b == 254) return true
152 }
153 return false
156class ServerEditViewModel(
157 private val container: AppContainer,
158 private val profileId: String?,
159 private val sourceId: String? = null,
160) : ViewModel() {
162 private val _state = MutableStateFlow(ServerEditState(id = profileId))
163 val state: StateFlow<ServerEditState> = _state.asStateFlow()
165 init {
166 viewModelScope.launch {
167 if (profileId != null) {
168 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
169 container.profileStore.profiles
170 .first { list -> list.any { p -> p.id == profileId } }
171 .firstOrNull { it.id == profileId }
172 }
173 if (existing != null) {
174 // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
175 // save() stores them, so a stored value with surrounding whitespace
176 // doesn't make the freshly-opened editor spuriously dirty (isDirty
177 // compares field.trim() against these).
178 val init = InitialProfile(
179 label = existing.label.trim(),
180 baseUrl = existing.baseUrl.trim(),
181 username = existing.username.orEmpty().trim(),
182 password = existing.password.orEmpty().trim(),
183 requireHttps = existing.requireHttps,
184 certPin = existing.certPin.orEmpty().trim(),
185 )
186 _state.value = ServerEditState(
187 id = existing.id,
188 label = existing.label,
189 baseUrl = existing.baseUrl,
190 username = existing.username.orEmpty(),
191 password = existing.password.orEmpty(),
192 requireHttps = existing.requireHttps,
193 certPin = existing.certPin.orEmpty(),
194 loaded = true,
195 authFieldsVisible = existing.hasAuth,
196 initial = init,
197 )
198 return@launch
199 }
200 _state.value = ServerEditState(
201 id = profileId,
202 loaded = true,
203 error = container.string(R.string.error_load_timeout),
204 )
205 return@launch
206 }
207 // Duplicate: seed the form from an existing profile but as a NEW profile
208 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
209 // label so the user can tell the duplicate from the original at a glance.
210 if (sourceId != null) {
211 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
212 container.profileStore.profiles
213 .first { list -> list.any { p -> p.id == sourceId } }
214 .firstOrNull { it.id == sourceId }
215 }
216 if (source != null) {
217 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
218 _state.value = ServerEditState(
219 id = null,
220 label = dupLabel,
221 baseUrl = source.baseUrl,
222 username = source.username.orEmpty(),
223 password = source.password.orEmpty(),
224 requireHttps = source.requireHttps,
225 certPin = source.certPin.orEmpty(),
226 loaded = true,
227 authFieldsVisible = source.hasAuth,
228 // Snapshot the seeded (copy) values so an untouched duplicate isn't
229 // immediately dirty — an empty initial would make backing out of a
230 // freshly opened duplicate always trigger the discard-changes dialog.
231 initial = InitialProfile(
232 label = dupLabel.trim(),
233 baseUrl = source.baseUrl.trim(),
234 username = source.username.orEmpty().trim(),
235 password = source.password.orEmpty().trim(),
236 requireHttps = source.requireHttps,
237 certPin = source.certPin.orEmpty().trim(),
238 ),
239 )
240 return@launch
241 }
242 // Source not found — fall through to a blank new-profile form.
243 }
244 // New-profile form: seed an initial snapshot of empty values so isDirty
245 // becomes true the moment the user types anything.
246 _state.update {
247 it.copy(
248 loaded = true,
249 initial = InitialProfile("", "", "", ""),
250 )
251 }
252 }
253 }
255 fun update(transform: (ServerEditState) -> ServerEditState) {
256 // Clear both the inline credential note and the bottom-of-form error when any field
257 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
258 // "Credentials rejected" / test-failure message no longer describes the current input.
259 _state.update { transform(it).copy(credentialsResult = null, error = null) }
260 }
262 /**
263 * Populate the form from a pasted/decoded QR payload (a JSON blob the Share-as-QR flow
264 * produces) or an `opencode://server/...` link. Returns true when [text] was recognized and
265 * applied so the caller can surface a "fields filled" confirmation. Lets a user transfer a
266 * server config by pasting instead of scanning an image.
267 */
268 fun applyQrPayload(text: String): Boolean {
269 val qr = soy.iko.opencode.ui.components.parseQrPayload(text.trim()) ?: return false
270 _state.update {
271 it.copy(
272 baseUrl = qr.baseUrl,
273 username = qr.username.orEmpty(),
274 password = qr.password.orEmpty(),
275 requireHttps = qr.requireHttps,
276 certPin = qr.certPin.orEmpty(),
277 credentialsResult = null,
278 error = null,
279 )
280 }
281 return true
282 }
284 /**
285 * Primary action: save the profile and connect to it. When the server needs auth and the
286 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
287 * fields instead of connecting with none — folding the former separate "Check connectivity"
288 * step into this single action so the common case is one tap.
289 */
290 fun connect(onDone: () -> Unit) {
291 val s = _state.value
292 if (!s.canSave || s.saving) return
293 // Credentials already known (an authed profile is loaded, or the user typed some): skip
294 // the probe and go straight to save + connect. A wrong password then surfaces as a
295 // connect error rather than silently re-opening the auth fields.
296 if (s.authFieldsVisible || s.username.isNotBlank() || s.password.isNotBlank()) {
297 saveInternal(connectAfter = true, onDone = onDone)
298 return
299 }
300 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
301 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
302 // result is dropped if the user edited the base URL away while the probe was in flight.
303 val probedUrl = s.baseUrl.trim()
304 val probedRequireHttps = s.requireHttps
305 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
306 viewModelScope.launch {
307 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
308 result.onSuccess { pr ->
309 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
310 // `saving` latches true and blocks re-connecting.
311 if (_state.value.baseUrl.trim() != probedUrl) {
312 _state.update { it.copy(saving = false) }
313 return@onSuccess
314 }
315 when (pr) {
316 is ProbeResult.NeedsAuth ->
317 _state.update { it.copy(saving = false, authFieldsVisible = true, error = null) }
318 is ProbeResult.Unreachable ->
319 _state.update { it.copy(saving = false, error = pr.error) }
320 is ProbeResult.Reachable -> {
321 // Reachable without auth: clear the probe-owned saving flag and hand off
322 // to the normal save+connect path (saveInternal re-sets saving itself).
323 _state.update { it.copy(saving = false) }
324 saveInternal(connectAfter = true, onDone = onDone)
325 }
326 }
327 }.onFailure { e ->
328 _state.update {
329 if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
330 else it.copy(saving = false, error = container.friendlyError(e))
331 }
332 }
333 }
334 }
336 /**
337 * Probe the server *with* the entered credentials to validate them before saving.
338 * Only meaningful once auth fields are visible (the server requires auth); calling
339 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
340 * so the UI can show success/failure without dismissing the form.
341 */
342 fun testCredentials() {
343 val s = _state.value
344 if (!s.canSave || s.testingCredentials) return
345 if (s.username.isBlank() && s.password.isBlank()) return
346 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
347 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
348 // FIX 11: remember the probed URL so a stale result is dropped if the user
349 // edited the base URL away while the test was in flight.
350 val probedUrl = s.baseUrl.trim()
351 val probedUser = s.username.trim()
352 val probedPass = s.password.trim()
353 val probedRequireHttps = s.requireHttps
354 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
355 viewModelScope.launch {
356 // Bound the probe so a hung/unresponsive server doesn't leave the "Testing…"
357 // spinner spinning indefinitely. A timeout surfaces as a failure (the probe
358 // didn't confirm credentials), not a silent hang.
359 val result = runCatchingCancellable {
360 withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
361 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
362 }
363 }
364 result.onSuccess { ok ->
365 _state.update {
366 // Stale result: clear the spinner but drop the verdict (see probe()).
367 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
368 it.copy(
369 testingCredentials = false,
370 credentialsResult = ok,
371 error = if (ok == true) null else container.string(R.string.credentials_rejected),
372 )
373 }
374 }.onFailure { e ->
375 _state.update {
376 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
377 it.copy(
378 testingCredentials = false,
379 credentialsResult = false,
380 error = container.friendlyError(e),
381 )
382 }
383 }
384 }
385 }
387 /** Secondary action: persist the profile without connecting (e.g. setting up offline). */
388 fun save(onDone: () -> Unit) {
389 saveInternal(connectAfter = false, onDone = onDone)
390 }
392 /**
393 * Probe the server's reachability *without* requiring credentials (a pre-save "is this URL
394 * reachable?" check). Surfaces [ServerEditState.connectionResult] so the UI can show
395 * success/latency or a failure without dismissing the form. Complements [testCredentials],
396 * which validates auth once the URL is known to be reachable.
397 */
398 fun testConnection() {
399 val s = _state.value
400 if (!s.canSave || s.testingConnection) return
401 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
402 val probedUrl = s.baseUrl.trim()
403 val probedRequireHttps = s.requireHttps
404 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
405 viewModelScope.launch {
406 val start = System.currentTimeMillis()
407 val result = runCatchingCancellable {
408 withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
409 container.probeServer(probedUrl, probedRequireHttps, probedCertPin)
410 } ?: throw java.net.SocketTimeoutException("Probe timed out")
411 }
412 val latency = System.currentTimeMillis() - start
413 _state.update {
414 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingConnection = false)
415 val verdict = result.fold(
416 onSuccess = { probe ->
417 when (probe) {
418 ProbeResult.Reachable, is ProbeResult.NeedsAuth ->
419 ConnectionProbeResult.Success(latency)
420 is ProbeResult.Unreachable ->
421 ConnectionProbeResult.Failed(probe.error)
422 }
423 },
424 onFailure = { e -> ConnectionProbeResult.Failed(container.friendlyError(e)) },
425 )
426 it.copy(testingConnection = false, connectionResult = verdict)
427 }
428 }
429 }
431 /**
432 * Delete the profile being edited (only valid for an existing profile, not a new one).
433 * Fires [onDone] on success so the screen pops back to the list. The active profile can't
434 * be deleted here (the list enforces the same guard); surface an error instead.
435 */
436 fun delete(onDone: () -> Unit) {
437 val s = _state.value
438 val id = s.id ?: return
439 if (s.saving || s.testingConnection || s.testingCredentials) return
440 _state.update { it.copy(saving = true, error = null) }
441 viewModelScope.launch {
442 val result = runCatchingCancellable { container.profileStore.delete(id) }
443 result.onSuccess {
444 // If this was the active profile, disconnect so the app doesn't hold a stale
445 // connection to a deleted profile. The list screen also guards against deleting
446 // the active profile, but the edit screen reaches here via its own overflow.
447 if (container.activeConnection.value?.profile?.id == id) {
448 runCatchingCancellable { container.disconnect() }
449 }
450 onDone()
451 }.onFailure { e ->
452 _state.update { it.copy(saving = false, error = container.friendlyError(e)) }
453 }
454 }
455 }
457 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
458 val s = _state.value
459 if (!s.canSave || s.saving) return
460 _state.update { it.copy(saving = true) }
461 viewModelScope.launch {
462 val result = runCatchingCancellable {
463 // Duplicate detection: warn when a different profile with the same URL
464 // and label already exists, so the user doesn't silently create a second
465 // entry identical to an existing one. The check excludes the profile
466 // being edited (by id) so re-saving an existing profile doesn't trigger.
467 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
468 container.profileStore.profiles.first()
469 } ?: emptyList()
470 val duplicate = existing.any { it.id != s.id && it.baseUrl == s.baseUrl.trim() && it.label == s.label.trim() }
471 if (duplicate) {
472 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
473 }
474 val existingLastUsed = if (s.id != null) {
475 existing.firstOrNull { it.id == s.id }?.lastUsed ?: 0L
476 } else 0L
477 val saved = ServerProfile(
478 id = s.id ?: UUID.randomUUID().toString(),
479 label = s.label.trim(),
480 baseUrl = s.baseUrl.trim(),
481 username = s.username.trim().takeIf { it.isNotBlank() },
482 password = s.password.trim().takeIf { it.isNotEmpty() },
483 lastUsed = existingLastUsed,
484 requireHttps = s.requireHttps,
485 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
486 )
487 container.profileStore.save(saved)
488 // FIX 3: persist the (possibly freshly-generated) id and the normalized
489 // values back into state right after the save succeeds — before the
490 // connect/ping that may throw. Otherwise a retry after a connect failure
491 // would see id == null again, mint a new UUID and create a DUPLICATE
492 // profile instead of upserting the same row. Also refresh the dirty
493 // snapshot so the just-saved values no longer read as unsaved changes.
494 _state.update {
495 it.copy(
496 id = saved.id,
497 initial = InitialProfile(
498 label = saved.label,
499 baseUrl = saved.baseUrl,
500 username = saved.username.orEmpty(),
501 password = saved.password.orEmpty(),
502 requireHttps = saved.requireHttps,
503 certPin = saved.certPin.orEmpty(),
504 ),
505 )
506 }
507 if (connectAfter) {
508 container.connect(saved)
509 // Await the ping and treat a failure as a failed save: without this, a server
510 // that accepts the connect but is half-up (wrong path, unresponsive /info) would
511 // navigate to the session list as if everything succeeded, leaving the user
512 // stranded on an empty screen with no error.
513 val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
514 if (!pingOk) {
515 throw java.io.IOException(container.string(R.string.error_ping_failed))
516 }
517 }
518 }
519 result.onSuccess { onDone() }
520 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
521 }
522 }