git.iko.soy/opencode-android
on 0a26f8d help sign in
374 lines 18 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 /** Whether a connectivity/auth probe is in progress. */
39 val probing: Boolean = false,
40 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
41 val initial: InitialProfile? = null,
42 /** Whether a credential test is in progress. */
43 val testingCredentials: Boolean = false,
44 /** Non-null when the last credential test succeeded (true) or failed (false). */
45 val credentialsResult: Boolean? = null,
46 /** True when the last connectivity probe found the server reachable without auth.
47 * Null until a probe runs, and cleared when the base URL is edited. */
48 val probeReachable: Boolean? = null,
49) {
50 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
51 val isNew: Boolean get() = id == null
52 /** A blank pin is fine (feature off); otherwise every entry must be a valid OkHttp pin. */
53 val certPinValid: Boolean get() = isValidCertPin(certPin)
54 /** True if any field differs from its loaded value. */
55 val isDirty: Boolean
56 get() {
57 val init = initial ?: return false
58 return label.trim() != init.label ||
59 baseUrl.trim() != init.baseUrl ||
60 username.trim() != init.username ||
61 password.trim() != init.password ||
62 requireHttps != init.requireHttps ||
63 certPin.trim() != init.certPin
64 }
67/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
68data class InitialProfile(
69 val label: String,
70 val baseUrl: String,
71 val username: String,
72 val password: String,
73 val requireHttps: Boolean = false,
74 val certPin: String = "",
77/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
78 * separated entry is a well-formed OkHttp "sha256/<base64>" (or legacy "sha1/<base64>") pin. */
79fun isValidCertPin(raw: String): Boolean {
80 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
81 if (entries.isEmpty()) return true
82 // Require a well-formed sha256/ or sha1/ <base64>= pin (at least one base64 char before the
83 // padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===" and
84 // unsupported algorithms like "md5/…". sha1 is accepted because OkHttp's CertificatePinner
85 // accepts it too — the validator must match what the runtime accepts, or a well-formed
86 // sha1 pin would be un-saveable. A real pin is 43 base64 chars + '=', but we accept any
87 // length for demo/test pins.
88 val pinRegex = Regex("(sha256|sha1)/[A-Za-z0-9+/]+=")
89 return entries.all { it.matches(pinRegex) }
92fun isValidUrl(url: String): Boolean {
93 val trimmed = url.trim()
94 if (trimmed.isBlank()) return false
95 // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
96 // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
97 // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
98 // already implies a valid scheme; a bare host without a scheme returns null, keeping the
99 // "suggest http:// prefix" flow in suggestUrlScheme() intact.
100 return trimmed.toHttpUrlOrNull() != null
103/**
104 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
105 * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
106 * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
107 */
108fun suggestUrlScheme(input: String): String? {
109 val trimmed = input.trim()
110 if (trimmed.isEmpty() || "://" in trimmed) return null
111 val candidate = "http://$trimmed"
112 return if (isValidUrl(candidate)) candidate else null
115class ServerEditViewModel(
116 private val container: AppContainer,
117 private val profileId: String?,
118 private val sourceId: String? = null,
119) : ViewModel() {
121 private val _state = MutableStateFlow(ServerEditState(id = profileId))
122 val state: StateFlow<ServerEditState> = _state.asStateFlow()
124 init {
125 viewModelScope.launch {
126 if (profileId != null) {
127 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
128 container.profileStore.profiles
129 .first { list -> list.any { p -> p.id == profileId } }
130 .firstOrNull { it.id == profileId }
131 }
132 if (existing != null) {
133 // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
134 // save() stores them, so a stored value with surrounding whitespace
135 // doesn't make the freshly-opened editor spuriously dirty (isDirty
136 // compares field.trim() against these).
137 val init = InitialProfile(
138 label = existing.label.trim(),
139 baseUrl = existing.baseUrl.trim(),
140 username = existing.username.orEmpty().trim(),
141 password = existing.password.orEmpty().trim(),
142 requireHttps = existing.requireHttps,
143 certPin = existing.certPin.orEmpty().trim(),
144 )
145 _state.value = ServerEditState(
146 id = existing.id,
147 label = existing.label,
148 baseUrl = existing.baseUrl,
149 username = existing.username.orEmpty(),
150 password = existing.password.orEmpty(),
151 requireHttps = existing.requireHttps,
152 certPin = existing.certPin.orEmpty(),
153 loaded = true,
154 authFieldsVisible = existing.hasAuth,
155 initial = init,
156 )
157 return@launch
158 }
159 _state.value = ServerEditState(
160 id = profileId,
161 loaded = true,
162 error = container.string(R.string.error_load_timeout),
163 )
164 return@launch
165 }
166 // Duplicate: seed the form from an existing profile but as a NEW profile
167 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
168 // label so the user can tell the duplicate from the original at a glance.
169 if (sourceId != null) {
170 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
171 container.profileStore.profiles
172 .first { list -> list.any { p -> p.id == sourceId } }
173 .firstOrNull { it.id == sourceId }
174 }
175 if (source != null) {
176 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
177 _state.value = ServerEditState(
178 id = null,
179 label = dupLabel,
180 baseUrl = source.baseUrl,
181 username = source.username.orEmpty(),
182 password = source.password.orEmpty(),
183 requireHttps = source.requireHttps,
184 certPin = source.certPin.orEmpty(),
185 loaded = true,
186 authFieldsVisible = source.hasAuth,
187 // Snapshot the seeded (copy) values so an untouched duplicate isn't
188 // immediately dirty — an empty initial would make backing out of a
189 // freshly opened duplicate always trigger the discard-changes dialog.
190 initial = InitialProfile(
191 label = dupLabel.trim(),
192 baseUrl = source.baseUrl.trim(),
193 username = source.username.orEmpty().trim(),
194 password = source.password.orEmpty().trim(),
195 requireHttps = source.requireHttps,
196 certPin = source.certPin.orEmpty().trim(),
197 ),
198 )
199 return@launch
200 }
201 // Source not found — fall through to a blank new-profile form.
202 }
203 // New-profile form: seed an initial snapshot of empty values so isDirty
204 // becomes true the moment the user types anything.
205 _state.update {
206 it.copy(
207 loaded = true,
208 initial = InitialProfile("", "", "", ""),
209 )
210 }
211 }
212 }
214 fun update(transform: (ServerEditState) -> ServerEditState) {
215 // Clear both the inline credential note and the bottom-of-form error when any field
216 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
217 // "Credentials rejected" / test-failure message no longer describes the current input.
218 _state.update { transform(it).copy(credentialsResult = null, error = null) }
219 }
221 fun probe() {
222 val s = _state.value
223 if (!s.canSave || s.probing) return
224 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
225 // FIX 21(b): probe the trimmed URL that save() would actually store.
226 // FIX 11: remember which URL we probed so a stale result is dropped if the
227 // user edited the base URL away while the probe was in flight.
228 val probedUrl = s.baseUrl.trim()
229 val probedRequireHttps = s.requireHttps
230 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
231 viewModelScope.launch {
232 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
233 result.onSuccess { pr ->
234 _state.update {
235 // Stale result (URL edited away mid-probe): drop the reachability verdict but
236 // still clear the spinner, else `probing` latches true and blocks re-probing.
237 if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
238 when (pr) {
239 is ProbeResult.Reachable -> it.copy(
240 probing = false,
241 authFieldsVisible = false,
242 error = null,
243 probeReachable = true,
244 )
245 is ProbeResult.NeedsAuth -> it.copy(
246 probing = false,
247 authFieldsVisible = true,
248 error = null,
249 probeReachable = false,
250 )
251 is ProbeResult.Unreachable -> it.copy(
252 probing = false,
253 authFieldsVisible = false,
254 error = pr.error,
255 probeReachable = false,
256 )
257 }
258 }
259 }.onFailure { e ->
260 _state.update {
261 if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
262 it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
263 }
264 }
265 }
266 }
268 /**
269 * Probe the server *with* the entered credentials to validate them before saving.
270 * Only meaningful once auth fields are visible (the server requires auth); calling
271 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
272 * so the UI can show success/failure without dismissing the form.
273 */
274 fun testCredentials() {
275 val s = _state.value
276 if (!s.canSave || s.testingCredentials) return
277 if (s.username.isBlank() && s.password.isBlank()) return
278 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
279 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
280 // FIX 11: remember the probed URL so a stale result is dropped if the user
281 // edited the base URL away while the test was in flight.
282 val probedUrl = s.baseUrl.trim()
283 val probedUser = s.username.trim()
284 val probedPass = s.password.trim()
285 val probedRequireHttps = s.requireHttps
286 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
287 viewModelScope.launch {
288 val result = runCatchingCancellable {
289 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
290 }
291 result.onSuccess { ok ->
292 _state.update {
293 // Stale result: clear the spinner but drop the verdict (see probe()).
294 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
295 it.copy(
296 testingCredentials = false,
297 credentialsResult = ok,
298 error = if (ok) null else container.string(R.string.credentials_rejected),
299 )
300 }
301 }.onFailure { e ->
302 _state.update {
303 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
304 it.copy(
305 testingCredentials = false,
306 credentialsResult = false,
307 error = container.friendlyError(e),
308 )
309 }
310 }
311 }
312 }
314 fun save(onDone: () -> Unit) {
315 saveInternal(connectAfter = false, onDone = onDone)
316 }
318 /** Save and then connect to the saved profile in one step. */
319 fun saveAndConnect(onDone: () -> Unit) {
320 saveInternal(connectAfter = true, onDone = onDone)
321 }
323 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
324 val s = _state.value
325 if (!s.canSave || s.saving) return
326 _state.update { it.copy(saving = true) }
327 viewModelScope.launch {
328 val result = runCatchingCancellable {
329 val existingLastUsed = if (s.id != null) {
330 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
331 container.profileStore.profiles.first()
332 .firstOrNull { it.id == s.id }?.lastUsed
333 } ?: 0L
334 } else 0L
335 val saved = ServerProfile(
336 id = s.id ?: UUID.randomUUID().toString(),
337 label = s.label.trim(),
338 baseUrl = s.baseUrl.trim(),
339 username = s.username.trim().takeIf { it.isNotBlank() },
340 password = s.password.trim().takeIf { it.isNotEmpty() },
341 lastUsed = existingLastUsed,
342 requireHttps = s.requireHttps,
343 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
344 )
345 container.profileStore.save(saved)
346 // FIX 3: persist the (possibly freshly-generated) id and the normalized
347 // values back into state right after the save succeeds — before the
348 // connect/ping that may throw. Otherwise a retry after a connect failure
349 // would see id == null again, mint a new UUID and create a DUPLICATE
350 // profile instead of upserting the same row. Also refresh the dirty
351 // snapshot so the just-saved values no longer read as unsaved changes.
352 _state.update {
353 it.copy(
354 id = saved.id,
355 initial = InitialProfile(
356 label = saved.label,
357 baseUrl = saved.baseUrl,
358 username = saved.username.orEmpty(),
359 password = saved.password.orEmpty(),
360 requireHttps = saved.requireHttps,
361 certPin = saved.certPin.orEmpty(),
362 ),
363 )
364 }
365 if (connectAfter) {
366 container.connect(saved)
367 container.activeConnection.value?.api?.ping()
368 }
369 }
370 result.onSuccess { onDone() }
371 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
372 }
373 }