git.iko.soy/opencode-android
on e66c7ed help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsViewModel.kt
226 lines 11 kB history raw
1package soy.iko.opencode.ui.server
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.di.AppContainer
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.first
12import kotlinx.coroutines.flow.update
13import kotlinx.coroutines.launch
14import kotlinx.coroutines.withTimeoutOrNull
15import soy.iko.opencode.R
16import soy.iko.opencode.util.runCatchingCancellable
17import java.util.UUID
19/**
20 * ViewModel for the Server Settings screen, which edits an *existing* saved server profile.
21 * Holds the fields that don't appear on the add-server form: the display label, the advanced
22 * security options (require-HTTPS + certificate pin), plus the URL and credentials. Saving
23 * persists the profile and, when it's the active connection, reconnects to it so the edit
24 * takes effect immediately. Deleting disconnects first when the profile is active.
25 */
26class ServerSettingsViewModel(
27 private val container: AppContainer,
28 private val profileId: String,
29) : ViewModel() {
31 private val _state = MutableStateFlow(ServerEditState(id = profileId))
32 val state: StateFlow<ServerEditState> = _state.asStateFlow()
34 init {
35 viewModelScope.launch {
36 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
37 container.profileStore.profiles
38 .first { list -> list.any { p -> p.id == profileId } }
39 .firstOrNull { it.id == profileId }
40 }
41 if (existing != null) {
42 // Normalize (trim) the dirty-snapshot fields the same way save() stores them,
43 // so a stored value with surrounding whitespace doesn't make the freshly-opened
44 // settings screen spuriously dirty (isDirty compares field.trim() against these).
45 val init = InitialProfile(
46 label = existing.label.trim(),
47 baseUrl = existing.baseUrl.trim(),
48 username = existing.username.orEmpty().trim(),
49 password = existing.password.orEmpty().trim(),
50 requireHttps = existing.requireHttps,
51 certPin = existing.certPin.orEmpty().trim(),
52 )
53 _state.value = ServerEditState(
54 id = existing.id,
55 label = existing.label,
56 baseUrl = existing.baseUrl,
57 username = existing.username.orEmpty(),
58 password = existing.password.orEmpty(),
59 requireHttps = existing.requireHttps,
60 certPin = existing.certPin.orEmpty(),
61 loaded = true,
62 authFieldsVisible = existing.hasAuth,
63 initial = init,
64 )
65 return@launch
66 }
67 _state.value = ServerEditState(
68 id = profileId,
69 loaded = true,
70 error = container.string(R.string.error_load_timeout),
71 )
72 }
73 }
75 fun update(transform: (ServerEditState) -> ServerEditState) {
76 // Clear both the inline credential note and the bottom-of-form error when any field
77 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
78 // "Credentials rejected" message no longer describes the current input.
79 _state.update { transform(it).copy(credentialsResult = null, error = null) }
80 }
82 /**
83 * Probe the server *with* the entered credentials to validate them before saving. Only
84 * meaningful once auth fields are visible (the server requires auth). Surfaces
85 * [ServerEditState.credentialsResult] so the UI can show success/failure without
86 * dismissing the form.
87 */
88 fun testCredentials() {
89 val s = _state.value
90 if (!s.canSave || s.testingCredentials) return
91 if (s.username.isBlank() && s.password.isBlank()) return
92 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
93 // Test the trimmed URL/credentials that save() would actually store, and remember the
94 // probed URL so a stale result is dropped if the user edited the base URL away while
95 // the test was in flight.
96 val probedUrl = normalizeForSave(s.baseUrl)
97 val probedUser = s.username.trim()
98 val probedPass = s.password.trim()
99 val probedRequireHttps = s.requireHttps
100 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
101 viewModelScope.launch {
102 val result = runCatchingCancellable {
103 withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
104 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
105 }
106 }
107 result.onSuccess { ok ->
108 _state.update {
109 if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
110 it.copy(
111 testingCredentials = false,
112 credentialsResult = ok,
113 error = if (ok == true) null else container.string(R.string.credentials_rejected),
114 )
115 }
116 }.onFailure { e ->
117 _state.update {
118 if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
119 it.copy(
120 testingCredentials = false,
121 credentialsResult = false,
122 error = container.friendlyError(e),
123 )
124 }
125 }
126 }
127 }
129 /**
130 * Persist the edited profile. When it's the active connection, reconnect so the change
131 * (URL, credentials, security) takes effect immediately. Fires [onDone] on success so the
132 * screen pops back.
133 */
134 fun save(onDone: () -> Unit) {
135 val s = _state.value
136 if (!s.canSave || s.saving || s.testingCredentials) return
137 _state.update { it.copy(saving = true, error = null) }
138 val savedBaseUrl = normalizeForSave(s.baseUrl)
139 viewModelScope.launch {
140 val result = runCatchingCancellable {
141 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
142 container.profileStore.profiles.first()
143 } ?: emptyList()
144 // Duplicate detection: warn when a *different* profile with the same URL and
145 // label already exists. Excludes the profile being edited (by id) so re-saving
146 // the same profile doesn't trigger.
147 val savedLabel = s.label.trim().ifBlank { hostFromBaseUrl(savedBaseUrl) }
148 val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == savedLabel }
149 if (duplicate) {
150 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
151 }
152 // Preserve lastUsed so re-saving doesn't reset the profile's sort order. When
153 // the load timed out (existing is empty) fall back to now rather than 0, which
154 // would silently drop the user's most-recently-used server to the list bottom.
155 val existingLastUsed = existing.firstOrNull { it.id == s.id }?.lastUsed ?: System.currentTimeMillis()
156 val saved = ServerProfile(
157 id = s.id ?: UUID.randomUUID().toString(),
158 label = savedLabel,
159 baseUrl = savedBaseUrl,
160 username = s.username.trim().takeIf { it.isNotBlank() },
161 password = s.password.trim().takeIf { it.isNotEmpty() },
162 lastUsed = existingLastUsed,
163 requireHttps = s.requireHttps,
164 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
165 )
166 container.profileStore.save(saved)
167 // Persist the normalized values back into state and refresh the dirty snapshot
168 // so the just-saved values no longer read as unsaved changes.
169 _state.update {
170 it.copy(
171 id = saved.id,
172 label = saved.label,
173 baseUrl = saved.baseUrl,
174 initial = InitialProfile(
175 label = saved.label,
176 baseUrl = saved.baseUrl,
177 username = saved.username.orEmpty(),
178 password = saved.password.orEmpty(),
179 requireHttps = saved.requireHttps,
180 certPin = saved.certPin.orEmpty(),
181 ),
182 )
183 }
184 // If this is the active profile, reconnect so the edit (URL/creds/security)
185 // takes effect immediately. A non-active profile just persists.
186 if (container.activeConnection.value?.profile?.id == saved.id) {
187 container.connect(saved)
188 // A null activeConnection must NOT count as a successful ping — see
189 // ServerEditViewModel for the reasoning (null?.api?.ping() yields null, which
190 // runCatchingCancellable wraps as success and masks a dropped connection).
191 val pingOk = container.activeConnection.value?.let {
192 runCatchingCancellable { it.api.ping() }.isSuccess
193 } ?: false
194 if (!pingOk) {
195 throw java.io.IOException(container.string(R.string.error_ping_failed))
196 }
197 }
198 }
199 result.onSuccess { onDone() }
200 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
201 }
202 }
204 /**
205 * Delete the profile being edited. Fires [onDone] on success so the screen pops back. The
206 * active profile is disconnected first so the app doesn't hold a stale connection to a
207 * deleted profile.
208 */
209 fun delete(onDone: () -> Unit) {
210 val s = _state.value
211 val id = s.id ?: return
212 if (s.saving || s.testingCredentials) return
213 _state.update { it.copy(saving = true, error = null) }
214 viewModelScope.launch {
215 val result = runCatchingCancellable { container.profileStore.delete(id) }
216 result.onSuccess {
217 if (container.activeConnection.value?.profile?.id == id) {
218 runCatchingCancellable { container.disconnect() }
219 }
220 onDone()
221 }.onFailure { e ->
222 _state.update { it.copy(saving = false, error = container.friendlyError(e)) }
223 }
224 }
225 }