git.iko.soy/opencode-android
on master help sign in
Allow schemeless URLs in server form, defaulting to https
f98707ciko
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+1−26
@@ -53,9 +53,7 @@ import androidx.compose.ui.focus.focusRequester
53import androidx.compose.ui.input.nestedscroll.nestedScroll
53import androidx.compose.ui.input.nestedscroll.nestedScroll
54import androidx.compose.ui.platform.LocalSoftwareKeyboardController
54import androidx.compose.ui.platform.LocalSoftwareKeyboardController
55import androidx.compose.ui.platform.testTag
55import androidx.compose.ui.platform.testTag
56−import androidx.compose.ui.semantics.Role
57import androidx.compose.ui.semantics.contentDescription
56import androidx.compose.ui.semantics.contentDescription
58−import androidx.compose.ui.semantics.role
59import androidx.compose.ui.semantics.semantics
57import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.semantics.stateDescription
58import androidx.compose.ui.semantics.stateDescription
61import androidx.compose.ui.text.input.ImeAction
59import androidx.compose.ui.text.input.ImeAction
@@ -242,30 +240,7 @@ private fun ServerEditForm(
242 isError = state.baseUrl.isNotBlank() && !urlValid,
240 isError = state.baseUrl.isNotBlank() && !urlValid,
243 supportingText = {
241 supportingText = {
244 if (state.baseUrl.isNotBlank() && !urlValid) {
242 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))
243+ Text(stringResource(R.string.invalid_url))
268− }
269 }
244 }
270 },
245 },
271 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
246 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 {
95 return entries.all { it.matches(pinRegex) }
95 return entries.all { it.matches(pinRegex) }
96}
96}
97
97
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−
109/**
98/**
110− * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
99+ * Return [input] with an `https://` scheme prepended when it has no scheme, mirroring
111− * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
100+ * [HttpClientFactory.normalizeBaseUrl]. Lets the user type a bare host ("example.com",
112− * one-tap fix instead of a generic "invalid URL" error.
101+ * "192.168.1.10:4096") without manually prefixing a protocol; we always assume HTTPS.
113− *
102+ * Pass through unchanged when [input] already has a scheme.
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 */
103 */
119−fun suggestUrlScheme(input: String): String? {
104+fun normalizeForSave(input: String): String {
120 val trimmed = input.trim()
105 val trimmed = input.trim()
121− if (trimmed.isEmpty() || "://" in trimmed) return null
106+ val lower = trimmed.lowercase()
122− val scheme = if (looksLikeTls(trimmed)) "https" else "http"
107+ return if (lower.startsWith("http://") || lower.startsWith("https://")) {
123− val candidate = "$scheme://$trimmed"
108+ trimmed
124− return if (isValidUrl(candidate)) candidate else null
109+ } else {
125−}
110+ "https://$trimmed"
126−
111+ }
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
139}
112}
140
113
141−private fun isPrivateHost(host: String): Boolean {
114+fun isValidUrl(url: String): Boolean {
142− if (host.endsWith(".local")) return true
115+ val trimmed = url.trim()
143− val octets = host.split('.')
116+ if (trimmed.isBlank()) return false
144− if (octets.size == 4 && octets.all { it.toIntOrNull() in 0..255 }) {
117+ // Accept any URL OkHttp can parse, *or* a schemeless bare host that we can prefix with
145− val a = octets[0].toInt()
118+ // https:// ourselves. This lets the user type "example.com" or "192.168.1.10:4096"
146− val b = octets[1].toInt()
119+ // without a protocol: canSave flips true and Save/Connect prepend https:// via
147− if (a == 10) return true
120+ // [normalizeForSave]. A non-http scheme like "ftp://…" is rejected explicitly so we
148− if (a == 192 && b == 168) return true
121+ // don't accidentally accept "ftp://example.com" by prepending https:// (which would
149− if (a == 172 && b in 16..31) return true
122+ // parse as host="ftp", path="//example.com"). Truly invalid input ("not a url") still
123+ // fails here so canSave stays false.
150− if (a == 127) return true
124+ if (trimmed.toHttpUrlOrNull() != null) return true
151− if (a == 169 && b == 254) return true
125+ if ("://" in trimmed) return false
152− }
126+ return "https://$trimmed".toHttpUrlOrNull() != null
153− return false
154}
127}
155
128
156class ServerEditViewModel(
129class ServerEditViewModel(
@@ -278,7 +251,9 @@ class ServerEditViewModel(
278 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
251 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
279 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
252 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
280 // result is dropped if the user edited the base URL away while the probe was in flight.
253 // result is dropped if the user edited the base URL away while the probe was in flight.
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.
281− val probedUrl = s.baseUrl.trim()
256+ val probedUrl = normalizeForSave(s.baseUrl)
282 val probedRequireHttps = s.requireHttps
257 val probedRequireHttps = s.requireHttps
283 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
258 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
284 viewModelScope.launch {
259 viewModelScope.launch {
@@ -286,7 +261,7 @@ class ServerEditViewModel(
286 result.onSuccess { pr ->
261 result.onSuccess { pr ->
287 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
262 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
288 // `saving` latches true and blocks re-connecting.
263 // `saving` latches true and blocks re-connecting.
289− if (_state.value.baseUrl.trim() != probedUrl) {
264+ if (normalizeForSave(_state.value.baseUrl) != probedUrl) {
290 _state.update { it.copy(saving = false) }
265 _state.update { it.copy(saving = false) }
291 return@onSuccess
266 return@onSuccess
292 }
267 }
@@ -304,7 +279,7 @@ class ServerEditViewModel(
304 }
279 }
305 }.onFailure { e ->
280 }.onFailure { e ->
306 _state.update {
281 _state.update {
307− if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
282+ if (normalizeForSave(it.baseUrl) != probedUrl) it.copy(saving = false)
308 else it.copy(saving = false, error = container.friendlyError(e))
283 else it.copy(saving = false, error = container.friendlyError(e))
309 }
284 }
310 }
285 }
@@ -325,7 +300,7 @@ class ServerEditViewModel(
325 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
300 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
326 // FIX 11: remember the probed URL so a stale result is dropped if the user
301 // FIX 11: remember the probed URL so a stale result is dropped if the user
327 // edited the base URL away while the test was in flight.
302 // edited the base URL away while the test was in flight.
328− val probedUrl = s.baseUrl.trim()
303+ val probedUrl = normalizeForSave(s.baseUrl)
329 val probedUser = s.username.trim()
304 val probedUser = s.username.trim()
330 val probedPass = s.password.trim()
305 val probedPass = s.password.trim()
331 val probedRequireHttps = s.requireHttps
306 val probedRequireHttps = s.requireHttps
@@ -342,7 +317,7 @@ class ServerEditViewModel(
342 result.onSuccess { ok ->
317 result.onSuccess { ok ->
343 _state.update {
318 _state.update {
344 // Stale result: clear the spinner but drop the verdict (see probe()).
319 // 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)
346 it.copy(
321 it.copy(
347 testingCredentials = false,
322 testingCredentials = false,
348 credentialsResult = ok,
323 credentialsResult = ok,
@@ -351,7 +326,7 @@ class ServerEditViewModel(
351 }
326 }
352 }.onFailure { e ->
327 }.onFailure { e ->
353 _state.update {
328 _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)
355 it.copy(
330 it.copy(
356 testingCredentials = false,
331 testingCredentials = false,
357 credentialsResult = false,
332 credentialsResult = false,
@@ -377,7 +352,7 @@ class ServerEditViewModel(
377 val s = _state.value
352 val s = _state.value
378 if (!s.canSave || s.testingConnection) return
353 if (!s.canSave || s.testingConnection) return
379 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
354 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
380− val probedUrl = s.baseUrl.trim()
355+ val probedUrl = normalizeForSave(s.baseUrl)
381 val probedRequireHttps = s.requireHttps
356 val probedRequireHttps = s.requireHttps
382 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
357 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
383 viewModelScope.launch {
358 viewModelScope.launch {
@@ -389,7 +364,7 @@ class ServerEditViewModel(
389 }
364 }
390 val latency = System.currentTimeMillis() - start
365 val latency = System.currentTimeMillis() - start
391 _state.update {
366 _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)
393 val verdict = result.fold(
368 val verdict = result.fold(
394 onSuccess = { probe ->
369 onSuccess = { probe ->
395 when (probe) {
370 when (probe) {
@@ -436,6 +411,10 @@ class ServerEditViewModel(
436 val s = _state.value
411 val s = _state.value
437 if (!s.canSave || s.saving) return
412 if (!s.canSave || s.saving) return
438 _state.update { it.copy(saving = true) }
413 _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)
439 viewModelScope.launch {
418 viewModelScope.launch {
440 val result = runCatchingCancellable {
419 val result = runCatchingCancellable {
441 // Duplicate detection: warn when a different profile with the same URL
420 // Duplicate detection: warn when a different profile with the same URL
@@ -445,7 +424,7 @@ class ServerEditViewModel(
445 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
424 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
446 container.profileStore.profiles.first()
425 container.profileStore.profiles.first()
447 } ?: emptyList()
426 } ?: 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() }
449 if (duplicate) {
428 if (duplicate) {
450 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
429 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
451 }
430 }
@@ -462,7 +441,7 @@ class ServerEditViewModel(
462 val saved = ServerProfile(
441 val saved = ServerProfile(
463 id = s.id ?: UUID.randomUUID().toString(),
442 id = s.id ?: UUID.randomUUID().toString(),
464 label = s.label.trim(),
443 label = s.label.trim(),
465− baseUrl = s.baseUrl.trim(),
444+ baseUrl = savedBaseUrl,
466 username = s.username.trim().takeIf { it.isNotBlank() },
445 username = s.username.trim().takeIf { it.isNotBlank() },
467 password = s.password.trim().takeIf { it.isNotEmpty() },
446 password = s.password.trim().takeIf { it.isNotEmpty() },
468 lastUsed = existingLastUsed,
447 lastUsed = existingLastUsed,
@@ -476,9 +455,12 @@ class ServerEditViewModel(
476 // would see id == null again, mint a new UUID and create a DUPLICATE
455 // would see id == null again, mint a new UUID and create a DUPLICATE
477 // profile instead of upserting the same row. Also refresh the dirty
456 // profile instead of upserting the same row. Also refresh the dirty
478 // snapshot so the just-saved values no longer read as unsaved changes.
457 // 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.
479 _state.update {
460 _state.update {
480 it.copy(
461 it.copy(
481 id = saved.id,
462 id = saved.id,
463+ baseUrl = saved.baseUrl,
482 initial = InitialProfile(
464 initial = InitialProfile(
483 label = saved.label,
465 label = saved.label,
484 baseUrl = saved.baseUrl,
466 baseUrl = saved.baseUrl,
app/src/main/res/values-es/strings.xml+2−4
@@ -86,8 +86,8 @@
86 <!-- Server edit -->
86 <!-- Server edit -->
87 <string name="label_optional">Etiqueta (opcional)</string>
87 <string name="label_optional">Etiqueta (opcional)</string>
88 <string name="base_url">URL base</string>
88 <string name="base_url">URL base</string>
89− <string name="base_url_hint">http://192.168.1.10:4096</string>
89+ <string name="base_url_hint">192.168.1.10:4096</string>
90− <string name="invalid_url">Introduce una URL http:// o https:// válida.</string>
90+ <string name="invalid_url">Introduce una URL válida.</string>
91 <string name="auth_required">Este servidor requiere autenticación.</string>
91 <string name="auth_required">Este servidor requiere autenticación.</string>
92 <string name="username_optional">Usuario (opcional)</string>
92 <string name="username_optional">Usuario (opcional)</string>
93 <string name="password_optional">Contraseña (opcional)</string>
93 <string name="password_optional">Contraseña (opcional)</string>
@@ -331,8 +331,6 @@
331 <string name="unknown_message">Tipo de mensaje desconocido</string>
331 <string name="unknown_message">Tipo de mensaje desconocido</string>
332 <string name="unknown_part">Contenido no compatible</string>
332 <string name="unknown_part">Contenido no compatible</string>
333 <string name="tool_preview">🔧 %1$s</string>
333 <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>
336 <string name="sort">Ordenar</string>
334 <string name="sort">Ordenar</string>
337 <string name="sort_recent">Actividad reciente</string>
335 <string name="sort_recent">Actividad reciente</string>
338 <string name="sort_title">Título</string>
336 <string name="sort_title">Título</string>
app/src/main/res/values/strings.xml+2−4
@@ -87,8 +87,8 @@
87 <!-- Server edit -->
87 <!-- Server edit -->
88 <string name="label_optional">Label (optional)</string>
88 <string name="label_optional">Label (optional)</string>
89 <string name="base_url">Base URL</string>
89 <string name="base_url">Base URL</string>
90− <string name="base_url_hint">http://192.168.1.10:4096</string>
90+ <string name="base_url_hint">192.168.1.10:4096</string>
91− <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
91+ <string name="invalid_url">Enter a valid URL.</string>
92 <string name="auth_required">This server requires authentication.</string>
92 <string name="auth_required">This server requires authentication.</string>
93 <string name="username_optional">Username (optional)</string>
93 <string name="username_optional">Username (optional)</string>
94 <string name="password_optional">Password (optional)</string>
94 <string name="password_optional">Password (optional)</string>
@@ -332,8 +332,6 @@
332 <string name="unknown_message">Unknown message type</string>
332 <string name="unknown_message">Unknown message type</string>
333 <string name="unknown_part">Unsupported content</string>
333 <string name="unknown_part">Unsupported content</string>
334 <string name="tool_preview">🔧 %1$s</string>
334 <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>
337 <string name="sort">Sort</string>
335 <string name="sort">Sort</string>
338 <string name="sort_recent">Recent activity</string>
336 <string name="sort_recent">Recent activity</string>
339 <string name="sort_title">Title</string>
337 <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 {
139 assertFalse(vm.state.value.canSave)
139 assertFalse(vm.state.value.canSave)
140 }
140 }
141
141
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+
142 // --- save() ---
164 // --- save() ---
143
165
144 @Test
166 @Test
@@ -159,7 +181,7 @@ class ServerEditViewModelTest {
159 fun save_invalidUrlDoesNotSave() = testScope.runTest {
181 fun save_invalidUrlDoesNotSave() = testScope.runTest {
160 val container = FakeAppContainer()
182 val container = FakeAppContainer()
161 val vm = makeVm(container)
183 val vm = makeVm(container)
162− vm.update { it.copy(baseUrl = "invalid") }
184+ vm.update { it.copy(baseUrl = "not a url") }
163 var doneCalled = false
185 var doneCalled = false
164 vm.save { doneCalled = true }
186 vm.save { doneCalled = true }
165 testScheduler.advanceUntilIdle()
187 testScheduler.advanceUntilIdle()
@@ -217,6 +239,48 @@ class ServerEditViewModelTest {
217 assertEquals("admin", saved.username)
239 assertEquals("admin", saved.username)
218 }
240 }
219
241
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+
220 @Test
284 @Test
221 fun save_emptyUsernameClearsCredentials() = testScope.runTest {
285 fun save_emptyUsernameClearsCredentials() = testScope.runTest {
222 val container = FakeAppContainer()
286 val container = FakeAppContainer()
@@ -346,7 +410,7 @@ class ServerEditViewModelTest {
346 val container = FakeAppContainer()
410 val container = FakeAppContainer()
347 container.probeResult = ProbeResult.Reachable
411 container.probeResult = ProbeResult.Reachable
348 val vm = makeVm(container)
412 val vm = makeVm(container)
349− vm.update { it.copy(baseUrl = "invalid") }
413+ vm.update { it.copy(baseUrl = "not a url") }
350 var doneCalled = false
414 var doneCalled = false
351 vm.connect { doneCalled = true }
415 vm.connect { doneCalled = true }
352 testScheduler.advanceUntilIdle()
416 testScheduler.advanceUntilIdle()
@@ -417,24 +481,6 @@ class ServerEditViewModelTest {
417 assertFalse(vm.state.value.authFieldsVisible)
481 assertFalse(vm.state.value.authFieldsVisible)
418 }
482 }
419
483
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−
438 @Test
484 @Test
439 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
485 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
440 val container = FakeAppContainer()
486 val container = FakeAppContainer()