git.iko.soy/opencode-android
on master help sign in
Allow schemeless URLs in server form, defaulting to https
commitf98707ca5cea3776d32fa91d8dff3c950e320dfe authoriko <mail@iko.soy> dateSun 5 Jul 2026, 18:05 (3 months ago) parentefa04fb
5 files changed+114−115
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−26 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+43−61 app/src/main/res/values-es/strings.xml+2−4 app/src/main/res/values/strings.xml+2−4 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+66−20
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−26
@@ -53,9 +53,7 @@ import androidx.compose.ui.focus.focusRequester
5353import androidx.compose.ui.input.nestedscroll.nestedScroll
5454import androidx.compose.ui.platform.LocalSoftwareKeyboardController
5555import androidx.compose.ui.platform.testTag
56−import androidx.compose.ui.semantics.Role
5756import androidx.compose.ui.semantics.contentDescription
58−import androidx.compose.ui.semantics.role
5957import androidx.compose.ui.semantics.semantics
6058import androidx.compose.ui.semantics.stateDescription
6159import androidx.compose.ui.text.input.ImeAction
@@ -242,30 +240,7 @@ private fun ServerEditForm(
242240 isError = state.baseUrl.isNotBlank() && !urlValid,
243241 supportingText = {
244242 if (state.baseUrl.isNotBlank() && !urlValid) {
245− // Offer a one-tap scheme fix for bare host:port input instead of a
246− // generic error, so the user doesn't have to know the URL needs a scheme.
247− // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
248− // domains) so the quick-fix doesn't nudge users onto cleartext.
249− val suggestion = suggestUrlScheme(state.baseUrl)
250− if (suggestion != null) {
251− val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
252− // Mark the supporting-text fix as a Button so TalkBack announces it as an
253− // actionable control. Without an explicit role, a TextButton inside
254− // supportingText can be missed by screen-reader users who don't scan
255− // the error slot visually.
256− TextButton(
257− onClick = { vm.update { it.copy(baseUrl = suggestion) } },
258− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
259− modifier = Modifier.semantics {
260− role = Role.Button
261− contentDescription = state.baseUrl
262− },
263− ) {
264− Text(stringResource(labelRes))
265− }
266− } else {
267− Text(stringResource(R.string.invalid_url))
268− }
243+ Text(stringResource(R.string.invalid_url))
269244 }
270245 },
271246 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+43−61
@@ -95,62 +95,35 @@ fun isValidCertPin(raw: String): Boolean {
9595 return entries.all { it.matches(pinRegex) }
9696}
9797
98−fun 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
107−}
108−
10998/**
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.
99+ * Return [input] with an `https://` scheme prepended when it has no scheme, mirroring
100+ * [HttpClientFactory.normalizeBaseUrl]. Lets the user type a bare host ("example.com",
101+ * "192.168.1.10:4096") without manually prefixing a protocol; we always assume HTTPS.
102+ * Pass through unchanged when [input] already has a scheme.
118103 */
119−fun suggestUrlScheme(input: String): String? {
104+fun normalizeForSave(input: String): String {
120105 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
125−}
126−
127−private 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
106+ val lower = trimmed.lowercase()
107+ return if (lower.startsWith("http://") || lower.startsWith("https://")) {
108+ trimmed
109+ } else {
110+ "https://$trimmed"
111+ }
139112}
140113
141−private 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
114+fun isValidUrl(url: String): Boolean {
115+ val trimmed = url.trim()
116+ if (trimmed.isBlank()) return false
117+ // Accept any URL OkHttp can parse, *or* a schemeless bare host that we can prefix with
118+ // https:// ourselves. This lets the user type "example.com" or "192.168.1.10:4096"
119+ // without a protocol: canSave flips true and Save/Connect prepend https:// via
120+ // [normalizeForSave]. A non-http scheme like "ftp://…" is rejected explicitly so we
121+ // don't accidentally accept "ftp://example.com" by prepending https:// (which would
122+ // parse as host="ftp", path="//example.com"). Truly invalid input ("not a url") still
123+ // fails here so canSave stays false.
124+ if (trimmed.toHttpUrlOrNull() != null) return true
125+ if ("://" in trimmed) return false
126+ return "https://$trimmed".toHttpUrlOrNull() != null
154127}
155128
156129class ServerEditViewModel(
@@ -278,7 +251,9 @@ class ServerEditViewModel(
278251 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
279252 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
280253 // result is dropped if the user edited the base URL away while the probe was in flight.
281− val probedUrl = s.baseUrl.trim()
254+ // normalizeForSave applies the same scheme prefix save() would persist, so a bare host
255+ // typed without http:// is probed at the URL it'll actually be saved as.
256+ val probedUrl = normalizeForSave(s.baseUrl)
282257 val probedRequireHttps = s.requireHttps
283258 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
284259 viewModelScope.launch {
@@ -286,7 +261,7 @@ class ServerEditViewModel(
286261 result.onSuccess { pr ->
287262 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
288263 // `saving` latches true and blocks re-connecting.
289− if (_state.value.baseUrl.trim() != probedUrl) {
264+ if (normalizeForSave(_state.value.baseUrl) != probedUrl) {
290265 _state.update { it.copy(saving = false) }
291266 return@onSuccess
292267 }
@@ -304,7 +279,7 @@ class ServerEditViewModel(
304279 }
305280 }.onFailure { e ->
306281 _state.update {
307− if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
282+ if (normalizeForSave(it.baseUrl) != probedUrl) it.copy(saving = false)
308283 else it.copy(saving = false, error = container.friendlyError(e))
309284 }
310285 }
@@ -325,7 +300,7 @@ class ServerEditViewModel(
325300 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
326301 // FIX 11: remember the probed URL so a stale result is dropped if the user
327302 // edited the base URL away while the test was in flight.
328− val probedUrl = s.baseUrl.trim()
303+ val probedUrl = normalizeForSave(s.baseUrl)
329304 val probedUser = s.username.trim()
330305 val probedPass = s.password.trim()
331306 val probedRequireHttps = s.requireHttps
@@ -342,7 +317,7 @@ class ServerEditViewModel(
342317 result.onSuccess { ok ->
343318 _state.update {
344319 // Stale result: clear the spinner but drop the verdict (see probe()).
345− if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
320+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
346321 it.copy(
347322 testingCredentials = false,
348323 credentialsResult = ok,
@@ -351,7 +326,7 @@ class ServerEditViewModel(
351326 }
352327 }.onFailure { e ->
353328 _state.update {
354− if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
329+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
355330 it.copy(
356331 testingCredentials = false,
357332 credentialsResult = false,
@@ -377,7 +352,7 @@ class ServerEditViewModel(
377352 val s = _state.value
378353 if (!s.canSave || s.testingConnection) return
379354 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
380− val probedUrl = s.baseUrl.trim()
355+ val probedUrl = normalizeForSave(s.baseUrl)
381356 val probedRequireHttps = s.requireHttps
382357 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
383358 viewModelScope.launch {
@@ -389,7 +364,7 @@ class ServerEditViewModel(
389364 }
390365 val latency = System.currentTimeMillis() - start
391366 _state.update {
392− if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingConnection = false)
367+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingConnection = false)
393368 val verdict = result.fold(
394369 onSuccess = { probe ->
395370 when (probe) {
@@ -436,6 +411,10 @@ class ServerEditViewModel(
436411 val s = _state.value
437412 if (!s.canSave || s.saving) return
438413 _state.update { it.copy(saving = true) }
414+ // Prepend https:// to a bare host so the saved profile is immediately connectable
415+ // and re-opening the editor doesn't re-show an "add scheme" hint. The user can still
416+ // opt into cleartext by typing http:// explicitly.
417+ val savedBaseUrl = normalizeForSave(s.baseUrl)
439418 viewModelScope.launch {
440419 val result = runCatchingCancellable {
441420 // Duplicate detection: warn when a different profile with the same URL
@@ -445,7 +424,7 @@ class ServerEditViewModel(
445424 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
446425 container.profileStore.profiles.first()
447426 } ?: emptyList()
448− val duplicate = existing.any { it.id != s.id && it.baseUrl == s.baseUrl.trim() && it.label == s.label.trim() }
427+ val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == s.label.trim() }
449428 if (duplicate) {
450429 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
451430 }
@@ -462,7 +441,7 @@ class ServerEditViewModel(
462441 val saved = ServerProfile(
463442 id = s.id ?: UUID.randomUUID().toString(),
464443 label = s.label.trim(),
465− baseUrl = s.baseUrl.trim(),
444+ baseUrl = savedBaseUrl,
466445 username = s.username.trim().takeIf { it.isNotBlank() },
467446 password = s.password.trim().takeIf { it.isNotEmpty() },
468447 lastUsed = existingLastUsed,
@@ -476,9 +455,12 @@ class ServerEditViewModel(
476455 // would see id == null again, mint a new UUID and create a DUPLICATE
477456 // profile instead of upserting the same row. Also refresh the dirty
478457 // snapshot so the just-saved values no longer read as unsaved changes.
458+ // The base URL is updated to the schemed form too, so a freshly-saved
459+ // bare-host entry doesn't read as dirty against its own saved value.
479460 _state.update {
480461 it.copy(
481462 id = saved.id,
463+ baseUrl = saved.baseUrl,
482464 initial = InitialProfile(
483465 label = saved.label,
484466 baseUrl = saved.baseUrl,
app/src/main/res/values-es/strings.xml+2−4
@@ -86,8 +86,8 @@
8686 <!-- Server edit -->
8787 <string name="label_optional">Etiqueta (opcional)</string>
8888 <string name="base_url">URL base</string>
89− <string name="base_url_hint">http://192.168.1.10:4096</string>
90− <string name="invalid_url">Introduce una URL http:// o https:// válida.</string>
89+ <string name="base_url_hint">192.168.1.10:4096</string>
90+ <string name="invalid_url">Introduce una URL válida.</string>
9191 <string name="auth_required">Este servidor requiere autenticación.</string>
9292 <string name="username_optional">Usuario (opcional)</string>
9393 <string name="password_optional">Contraseña (opcional)</string>
@@ -331,8 +331,6 @@
331331 <string name="unknown_message">Tipo de mensaje desconocido</string>
332332 <string name="unknown_part">Contenido no compatible</string>
333333 <string name="tool_preview">🔧 %1$s</string>
334− <string name="suggest_scheme">Añadir prefijo http://</string>
335− <string name="suggest_scheme_https">Añadir prefijo https://</string>
336334 <string name="sort">Ordenar</string>
337335 <string name="sort_recent">Actividad reciente</string>
338336 <string name="sort_title">Título</string>
app/src/main/res/values/strings.xml+2−4
@@ -87,8 +87,8 @@
8787 <!-- Server edit -->
8888 <string name="label_optional">Label (optional)</string>
8989 <string name="base_url">Base URL</string>
90− <string name="base_url_hint">http://192.168.1.10:4096</string>
91− <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
90+ <string name="base_url_hint">192.168.1.10:4096</string>
91+ <string name="invalid_url">Enter a valid URL.</string>
9292 <string name="auth_required">This server requires authentication.</string>
9393 <string name="username_optional">Username (optional)</string>
9494 <string name="password_optional">Password (optional)</string>
@@ -332,8 +332,6 @@
332332 <string name="unknown_message">Unknown message type</string>
333333 <string name="unknown_part">Unsupported content</string>
334334 <string name="tool_preview">🔧 %1$s</string>
335− <string name="suggest_scheme">Add http:// prefix</string>
336− <string name="suggest_scheme_https">Add https:// prefix</string>
337335 <string name="sort">Sort</string>
338336 <string name="sort_recent">Recent activity</string>
339337 <string name="sort_title">Title</string>
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+66−20
@@ -139,6 +139,28 @@ class ServerEditViewModelTest {
139139 assertFalse(vm.state.value.canSave)
140140 }
141141
142+ @Test
143+ fun canSave_trueForBareHostWithoutScheme() = testScope.runTest {
144+ val container = FakeAppContainer()
145+ val vm = makeVm(container)
146+ // A bare host:port is schemed by normalizeForSave at save time, so canSave must
147+ // accept it — the user shouldn't have to type a protocol to enable Save/Connect.
148+ vm.update { it.copy(baseUrl = "localhost:3000") }
149+ assertTrue(vm.state.value.canSave)
150+ vm.update { it.copy(baseUrl = "192.168.1.10:4096") }
151+ assertTrue(vm.state.value.canSave)
152+ }
153+
154+ @Test
155+ fun canSave_trueForBarePublicDomain() = testScope.runTest {
156+ val container = FakeAppContainer()
157+ val vm = makeVm(container)
158+ // A public-looking domain with no port is schemed to https:// by normalizeForSave,
159+ // which OkHttp accepts, so canSave must be true.
160+ vm.update { it.copy(baseUrl = "example.com") }
161+ assertTrue(vm.state.value.canSave)
162+ }
163+
142164 // --- save() ---
143165
144166 @Test
@@ -159,7 +181,7 @@ class ServerEditViewModelTest {
159181 fun save_invalidUrlDoesNotSave() = testScope.runTest {
160182 val container = FakeAppContainer()
161183 val vm = makeVm(container)
162− vm.update { it.copy(baseUrl = "invalid") }
184+ vm.update { it.copy(baseUrl = "not a url") }
163185 var doneCalled = false
164186 vm.save { doneCalled = true }
165187 testScheduler.advanceUntilIdle()
@@ -217,6 +239,48 @@ class ServerEditViewModelTest {
217239 assertEquals("admin", saved.username)
218240 }
219241
242+ @Test
243+ fun save_appliesHttpsSchemeToBareLanHost() = testScope.runTest {
244+ val container = FakeAppContainer()
245+ val vm = makeVm(container)
246+ vm.update { it.copy(baseUrl = "localhost:3000", label = "LAN") }
247+ vm.save { }
248+ testScheduler.advanceUntilIdle()
249+ val saved = container.fakeProfileStore.savedProfile!!
250+ // A bare host is always schemed to https:// (the safe default). The user can still
251+ // opt into cleartext by typing http:// explicitly.
252+ assertEquals("https://localhost:3000", saved.baseUrl)
253+ assertEquals("LAN", saved.label)
254+ // The editor's own field is refreshed to the schemed form so a just-saved bare-host
255+ // entry doesn't read as dirty against its own stored value on re-open.
256+ assertEquals("https://localhost:3000", vm.state.value.baseUrl)
257+ assertFalse(vm.state.value.isDirty)
258+ }
259+
260+ @Test
261+ fun save_appliesHttpsSchemeToBarePublicDomain() = testScope.runTest {
262+ val container = FakeAppContainer()
263+ val vm = makeVm(container)
264+ vm.update { it.copy(baseUrl = "example.com") }
265+ vm.save { }
266+ testScheduler.advanceUntilIdle()
267+ val saved = container.fakeProfileStore.savedProfile!!
268+ assertEquals("https://example.com", saved.baseUrl)
269+ }
270+
271+ @Test
272+ fun save_preservesExplicitHttpSchemeForPublicDomain() = testScope.runTest {
273+ val container = FakeAppContainer()
274+ val vm = makeVm(container)
275+ // The user typed the scheme explicitly — don't upgrade a public domain to https://
276+ // behind their back; preserve their explicit http:// choice.
277+ vm.update { it.copy(baseUrl = "http://example.com") }
278+ vm.save { }
279+ testScheduler.advanceUntilIdle()
280+ val saved = container.fakeProfileStore.savedProfile!!
281+ assertEquals("http://example.com", saved.baseUrl)
282+ }
283+
220284 @Test
221285 fun save_emptyUsernameClearsCredentials() = testScope.runTest {
222286 val container = FakeAppContainer()
@@ -346,7 +410,7 @@ class ServerEditViewModelTest {
346410 val container = FakeAppContainer()
347411 container.probeResult = ProbeResult.Reachable
348412 val vm = makeVm(container)
349− vm.update { it.copy(baseUrl = "invalid") }
413+ vm.update { it.copy(baseUrl = "not a url") }
350414 var doneCalled = false
351415 vm.connect { doneCalled = true }
352416 testScheduler.advanceUntilIdle()
@@ -417,24 +481,6 @@ class ServerEditViewModelTest {
417481 assertFalse(vm.state.value.authFieldsVisible)
418482 }
419483
420− @Test
421− fun suggestUrlScheme_addsHttpForBareHost() {
422− assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))
423− assertEquals("http://localhost:3000", suggestUrlScheme("localhost:3000"))
424− }
425−
426− @Test
427− fun suggestUrlScheme_returnsNullForAlreadySchemed() {
428− assertNull(suggestUrlScheme("http://localhost:3000"))
429− assertNull(suggestUrlScheme("https://example.com"))
430− }
431−
432− @Test
433− fun suggestUrlScheme_returnsNullForUnparseable() {
434− assertNull(suggestUrlScheme("not a url"))
435− assertNull(suggestUrlScheme(""))
436− }
437−
438484 @Test
439485 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
440486 val container = FakeAppContainer()