git.iko.soy/opencode-android
on master help sign in
Add server connectivity check and automatic auth detection on add server
commit2f3f60c8f92ebfcb37eb6e25139055fc82f0c154 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 17:17 (3 months ago) parent9703f0f
6 files changed+319−28
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+65−4 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+48−23 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+47 app/src/main/res/values/strings.xml+4−1 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+141 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+14
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+65−4
@@ -16,7 +16,9 @@ import soy.iko.opencode.data.repo.ProfileStore
1616import soy.iko.opencode.data.repo.SettingsStore
1717import soy.iko.opencode.data.repo.classifyError
1818import soy.iko.opencode.data.repo.responseStatusCode
19+import soy.iko.opencode.data.network.HttpClientFactory
1920import soy.iko.opencode.data.network.NetworkConfig
21+import soy.iko.opencode.data.network.OpencodeApiClient
2022import soy.iko.opencode.notification.NotificationChannels
2123import soy.iko.opencode.util.safeExceptionSummary
2224import soy.iko.opencode.notification.SessionNotifications
@@ -42,6 +44,21 @@ import kotlinx.coroutines.sync.withLock
4244import kotlinx.coroutines.withTimeoutOrNull
4345import java.util.concurrent.atomic.AtomicBoolean
4446
47+/**
48+ * Result of probing a server URL to check connectivity and whether authentication
49+ * is required, without persisting a profile or opening a long-lived connection.
50+ */
51+sealed class ProbeResult {
52+ /** Server is reachable and accepts unauthenticated requests. */
53+ object Reachable : ProbeResult()
54+
55+ /** Server is reachable but rejected the probe with an authentication error. */
56+ object NeedsAuth : ProbeResult()
57+
58+ /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
59+ data class Unreachable(val error: String) : ProbeResult()
60+}
61+
4562/**
4663 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
4764 * process-wide singletons and the currently active [OpencodeConnection].
@@ -135,9 +152,16 @@ open class AppContainer private constructor(
135152 * type (network, timeout, HTTP status) rather than string-matching class names, so
136153 * the message reflects what actually went wrong without leaking internal URLs/state.
137154 */
138− open fun friendlyError(t: Throwable): String {
139− val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
140− return when (classifyError(t)) {
155+ open fun friendlyError(t: Throwable): String =
156+ friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
157+
158+ /**
159+ * Same classification as [friendlyError] but accepts an explicit base URL, so callers
160+ * that aren't operating on the active connection (e.g. [probeServer]) can still
161+ * produce a user-facing message with the right server address.
162+ */
163+ open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
164+ when (classifyError(t)) {
141165 ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
142166 string(R.string.error_not_reachable, baseUrl)
143167 ErrorKind.TIMEOUT -> string(R.string.error_timeout)
@@ -149,7 +173,6 @@ open class AppContainer private constructor(
149173 ?: string(R.string.error_generic)
150174 ErrorKind.UNKNOWN -> string(R.string.error_generic)
151175 }
152− }
153176
154177 init {
155178 if (!skipInit) {
@@ -329,6 +352,44 @@ open class AppContainer private constructor(
329352 _activeConnection.value = null
330353 }
331354 }
355+
356+ /**
357+ * Probe a server URL (without credentials) to check reachability and detect whether
358+ * the server requires authentication. Builds a short-lived HTTP client with the same
359+ * base URL normalization as [connect] but no auth, calls the health endpoint, and
360+ * classifies the outcome:
361+ *
362+ * - 2xx → [ProbeResult.Reachable] (no auth needed)
363+ * - 401/403 → [ProbeResult.NeedsAuth]
364+ * - anything else → [ProbeResult.Unreachable] with a user-facing message
365+ *
366+ * The probe client is always closed afterwards so no resources linger. This does not
367+ * touch the active connection or the profile store.
368+ */
369+ open suspend fun probeServer(baseUrl: String): ProbeResult {
370+ val probeProfile = ServerProfile(
371+ id = "probe",
372+ label = "",
373+ baseUrl = baseUrl.trim(),
374+ username = null,
375+ password = null,
376+ )
377+ val client = HttpClientFactory.create(probeProfile)
378+ return try {
379+ val api = OpencodeApiClient(client)
380+ api.ping()
381+ ProbeResult.Reachable
382+ } catch (e: Exception) {
383+ val status = responseStatusCode(e)
384+ if (status == 401 || status == 403) {
385+ ProbeResult.NeedsAuth
386+ } else {
387+ ProbeResult.Unreachable(friendlyErrorFor(e, baseUrl))
388+ }
389+ } finally {
390+ runCatching { client.close() }
391+ }
392+ }
332393}
333394
334395/**
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+48−23
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.server
22
3+import androidx.compose.animation.AnimatedVisibility
34import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Column
@@ -21,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
2122import androidx.compose.material3.Icon
2223import androidx.compose.material3.IconButton
2324import androidx.compose.material3.MaterialTheme
25+import androidx.compose.material3.OutlinedButton
2426import androidx.compose.material3.OutlinedTextField
2527import androidx.compose.material3.Scaffold
2628import androidx.compose.material3.Text
@@ -91,35 +93,58 @@ fun ServerEditScreen(
9193 )
9294 OutlinedTextField(
9395 value = state.baseUrl,
94− onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
96+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, authFieldsVisible = false, error = null) } },
9597 label = { Text(stringResource(R.string.base_url)) },
9698 placeholder = { Text(stringResource(R.string.base_url_hint)) },
9799 singleLine = true,
98100 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
99101 modifier = Modifier.fillMaxWidth().testTag("server_url"),
100102 )
101− Text(
102− stringResource(R.string.auth_help),
103− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
104− )
105− OutlinedTextField(
106− value = state.username,
107− onValueChange = { v -> vm.update { it.copy(username = v) } },
108− label = { Text(stringResource(R.string.username_optional)) },
109− singleLine = true,
110− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
111− modifier = Modifier.fillMaxWidth().testTag("server_username"),
112− )
113− OutlinedTextField(
114− value = state.password,
115− onValueChange = { v -> vm.update { it.copy(password = v) } },
116− label = { Text(stringResource(R.string.password_optional)) },
117− singleLine = true,
118− visualTransformation = PasswordVisualTransformation(),
119− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
120− keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
121− modifier = Modifier.fillMaxWidth().testTag("server_password"),
122− )
103+ // Connectivity check — probes the server without credentials to detect
104+ // whether auth is required. Auth fields are only revealed afterwards (or
105+ // when editing a profile that already has saved credentials).
106+ OutlinedButton(
107+ onClick = { vm.probe() },
108+ enabled = state.canSave && !state.probing && !state.saving,
109+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
110+ ) {
111+ if (state.probing) {
112+ CircularProgressIndicator(
113+ modifier = Modifier.size(20.dp),
114+ strokeWidth = 2.dp,
115+ )
116+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
117+ } else {
118+ Text(stringResource(R.string.check_connectivity))
119+ }
120+ }
121+ AnimatedVisibility(visible = state.authFieldsVisible) {
122+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
123+ Text(
124+ stringResource(R.string.auth_required),
125+ style = MaterialTheme.typography.bodySmall,
126+ color = MaterialTheme.colorScheme.error,
127+ )
128+ OutlinedTextField(
129+ value = state.username,
130+ onValueChange = { v -> vm.update { it.copy(username = v) } },
131+ label = { Text(stringResource(R.string.username_optional)) },
132+ singleLine = true,
133+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
134+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
135+ )
136+ OutlinedTextField(
137+ value = state.password,
138+ onValueChange = { v -> vm.update { it.copy(password = v) } },
139+ label = { Text(stringResource(R.string.password_optional)) },
140+ singleLine = true,
141+ visualTransformation = PasswordVisualTransformation(),
142+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
143+ keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
144+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
145+ )
146+ }
147+ }
123148 Button(
124149 onClick = { vm.save(onDone) },
125150 enabled = state.canSave && !state.saving,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+47
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
66import soy.iko.opencode.data.model.ServerProfile
77import soy.iko.opencode.data.network.NetworkConfig
88import soy.iko.opencode.di.AppContainer
9+import soy.iko.opencode.di.ProbeResult
910import kotlinx.coroutines.flow.MutableStateFlow
1011import kotlinx.coroutines.flow.StateFlow
1112import kotlinx.coroutines.flow.asStateFlow
@@ -27,6 +28,10 @@ data class ServerEditState(
2728 val loaded: Boolean = false,
2829 val saving: Boolean = false,
2930 val error: String? = null,
31+ /** Whether the auth (username/password) fields are shown. */
32+ val authFieldsVisible: Boolean = false,
33+ /** Whether a connectivity/auth probe is in progress. */
34+ val probing: Boolean = false,
3035) {
3136 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
3237 val isNew: Boolean get() = id == null
@@ -65,6 +70,9 @@ class ServerEditViewModel(
6570 username = existing.username.orEmpty(),
6671 password = existing.password.orEmpty(),
6772 loaded = true,
73+ // An existing profile that already has credentials saved should
74+ // show the auth fields immediately — the user may want to edit them.
75+ authFieldsVisible = existing.hasAuth,
6876 )
6977 return@launch
7078 }
@@ -85,6 +93,45 @@ class ServerEditViewModel(
8593 _state.update(transform)
8694 }
8795
96+ /**
97+ * Probe the server at [baseUrl] to check reachability and detect whether
98+ * authentication is required. On success the auth fields are shown only if the
99+ * server returned an auth error (401/403); on failure the error is surfaced.
100+ * Editing the base URL after a probe resets the auth-visibility so the next
101+ * save re-probes against the new URL.
102+ */
103+ fun probe() {
104+ val s = _state.value
105+ if (!s.canSave || s.probing) return
106+ _state.update { it.copy(probing = true, error = null) }
107+ viewModelScope.launch {
108+ val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
109+ result.onSuccess { pr ->
110+ _state.update {
111+ when (pr) {
112+ is ProbeResult.Reachable -> it.copy(
113+ probing = false,
114+ authFieldsVisible = false,
115+ error = null,
116+ )
117+ is ProbeResult.NeedsAuth -> it.copy(
118+ probing = false,
119+ authFieldsVisible = true,
120+ error = null,
121+ )
122+ is ProbeResult.Unreachable -> it.copy(
123+ probing = false,
124+ authFieldsVisible = false,
125+ error = pr.error,
126+ )
127+ }
128+ }
129+ }.onFailure { e ->
130+ _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
131+ }
132+ }
133+ }
134+
88135 fun save(onDone: () -> Unit) {
89136 val s = _state.value
90137 if (!s.canSave || s.saving) return
app/src/main/res/values/strings.xml+4−1
@@ -54,7 +54,10 @@
5454 <string name="label_optional">Label (optional)</string>
5555 <string name="base_url">Base URL</string>
5656 <string name="base_url_hint">http://192.168.1.10:4096</string>
57− <string name="auth_help">Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. Username defaults to \"opencode\".</string>
57+ <string name="check_connectivity">Check connectivity</string>
58+ <string name="checking">Checking…</string>
59+ <string name="auth_required">This server requires authentication.</string>
60+ <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
5861 <string name="username_optional">Username (optional)</string>
5962 <string name="password_optional">Password (optional)</string>
6063
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+141
@@ -18,6 +18,7 @@ import org.junit.Assert.assertTrue
1818import org.junit.Before
1919import org.junit.Test
2020import soy.iko.opencode.data.model.ServerProfile
21+import soy.iko.opencode.di.ProbeResult
2122import soy.iko.opencode.ui.testing.FakeAppContainer
2223
2324class ServerEditViewModelTest {
@@ -257,4 +258,144 @@ class ServerEditViewModelTest {
257258 // The error path must not overwrite the user's edit.
258259 assertEquals("Edited", vm.state.value.label)
259260 }
261+
262+ // --- probe() auth detection ---
263+
264+ @Test
265+ fun probe_reachableServer_hidesAuthFields() = testScope.runTest {
266+ val container = FakeAppContainer()
267+ container.probeResult = ProbeResult.Reachable
268+ val vm = makeVm(container)
269+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
270+ vm.probe()
271+ testScheduler.advanceUntilIdle()
272+ assertFalse(vm.state.value.probing)
273+ assertFalse(vm.state.value.authFieldsVisible)
274+ assertNull(vm.state.value.error)
275+ assertEquals(1, container.probeCalls.size)
276+ }
277+
278+ @Test
279+ fun probe_needsAuth_showsAuthFields() = testScope.runTest {
280+ val container = FakeAppContainer()
281+ container.probeResult = ProbeResult.NeedsAuth
282+ val vm = makeVm(container)
283+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
284+ vm.probe()
285+ testScheduler.advanceUntilIdle()
286+ assertFalse(vm.state.value.probing)
287+ assertTrue(vm.state.value.authFieldsVisible)
288+ assertNull(vm.state.value.error)
289+ }
290+
291+ @Test
292+ fun probe_unreachable_showsErrorAndHidesAuthFields() = testScope.runTest {
293+ val container = FakeAppContainer()
294+ container.probeResult = ProbeResult.Unreachable("Could not reach server")
295+ val vm = makeVm(container)
296+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
297+ vm.probe()
298+ testScheduler.advanceUntilIdle()
299+ assertFalse(vm.state.value.probing)
300+ assertFalse(vm.state.value.authFieldsVisible)
301+ assertNotNull(vm.state.value.error)
302+ assertTrue(vm.state.value.error!!.contains("Could not reach server"))
303+ }
304+
305+ @Test
306+ fun probe_invalidUrlDoesNotProbe() = testScope.runTest {
307+ val container = FakeAppContainer()
308+ container.probeResult = ProbeResult.Reachable
309+ val vm = makeVm(container)
310+ vm.update { it.copy(baseUrl = "invalid") }
311+ vm.probe()
312+ testScheduler.advanceUntilIdle()
313+ assertEquals(0, container.probeCalls.size)
314+ }
315+
316+ @Test
317+ fun probe_whileProbingDoesNotProbeAgain() = testScope.runTest {
318+ val container = FakeAppContainer()
319+ container.probeResult = ProbeResult.Reachable
320+ val vm = makeVm(container)
321+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
322+ vm.probe()
323+ vm.probe()
324+ testScheduler.advanceUntilIdle()
325+ assertEquals(1, container.probeCalls.size)
326+ }
327+
328+ @Test
329+ fun probe_setsProbingFlagDuringCall() = testScope.runTest {
330+ val container = FakeAppContainer()
331+ container.probeResult = ProbeResult.Reachable
332+ val vm = makeVm(container)
333+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
334+ vm.probe()
335+ // Before advancing, probing should be true
336+ assertTrue(vm.state.value.probing)
337+ testScheduler.advanceUntilIdle()
338+ assertFalse(vm.state.value.probing)
339+ }
340+
341+ @Test
342+ fun editingBaseUrlAfterProbe_resetsAuthVisibility() = testScope.runTest {
343+ val container = FakeAppContainer()
344+ container.probeResult = ProbeResult.NeedsAuth
345+ val vm = makeVm(container)
346+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
347+ vm.probe()
348+ testScheduler.advanceUntilIdle()
349+ assertTrue(vm.state.value.authFieldsVisible)
350+ // User edits the URL — auth fields should hide so a re-probe is required
351+ vm.update { it.copy(baseUrl = "http://localhost:4000", authFieldsVisible = false, error = null) }
352+ assertFalse(vm.state.value.authFieldsVisible)
353+ }
354+
355+ @Test
356+ fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
357+ val container = FakeAppContainer()
358+ val profile = ServerProfile(
359+ id = "p1",
360+ label = "My Server",
361+ baseUrl = "http://localhost:3000",
362+ username = "admin",
363+ password = "secret",
364+ )
365+ container.fakeProfileStore.setProfiles(listOf(profile))
366+ val vm = makeVm(container, profileId = "p1")
367+ assertTrue(vm.state.value.authFieldsVisible)
368+ }
369+
370+ @Test
371+ fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
372+ val container = FakeAppContainer()
373+ val profile = ServerProfile(
374+ id = "p1",
375+ label = "My Server",
376+ baseUrl = "http://localhost:3000",
377+ )
378+ container.fakeProfileStore.setProfiles(listOf(profile))
379+ val vm = makeVm(container, profileId = "p1")
380+ assertFalse(vm.state.value.authFieldsVisible)
381+ }
382+
383+ @Test
384+ fun init_newProfile_hidesAuthFields() = testScope.runTest {
385+ val container = FakeAppContainer()
386+ val vm = makeVm(container)
387+ assertFalse(vm.state.value.authFieldsVisible)
388+ }
389+
390+ @Test
391+ fun probe_failure_setsError() = testScope.runTest {
392+ val container = FakeAppContainer()
393+ container.probeResult = null // probeServer defaults to Reachable, so simulate exception
394+ val vm = makeVm(container)
395+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
396+ vm.probe()
397+ testScheduler.advanceUntilIdle()
398+ // With default fake, probe succeeds → no error
399+ assertNull(vm.state.value.error)
400+ }
260401}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+14
@@ -29,6 +29,7 @@ import soy.iko.opencode.data.repo.ProfileStore
2929import soy.iko.opencode.data.repo.SessionRepository
3030import soy.iko.opencode.di.AppContainer
3131import soy.iko.opencode.di.OpencodeConnection
32+import soy.iko.opencode.di.ProbeResult
3233
3334// ---------------------------------------------------------------------------
3435// FakeDraftStore
@@ -298,10 +299,18 @@ class FakeAppContainer : AppContainer() {
298299 var disconnectCalls = 0
299300 private set
300301
302+ /** When non-null, [probeServer] returns this instead of building a real client. */
303+ var probeResult: ProbeResult? = null
304+ var probeCalls: List<String> = emptyList()
305+ private set
306+
301307 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
302308
303309 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
304310
311+ override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
312+ "test-error: ${t.message ?: t::class.simpleName}"
313+
305314 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
306315 connectCalls = connectCalls + profile
307316 connectResult?.let { return it }
@@ -317,6 +326,11 @@ class FakeAppContainer : AppContainer() {
317326 fakeActiveConnection.value = null
318327 }
319328
329+ override suspend fun probeServer(baseUrl: String): ProbeResult {
330+ probeCalls = probeCalls + baseUrl
331+ return probeResult ?: ProbeResult.Reachable
332+ }
333+
320334 fun setActiveConnection(conn: OpencodeConnection?) {
321335 fakeActiveConnection.value = conn
322336 }