git.iko.soy/opencode-android
on d29e57b help sign in
231 lines 9.2 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 soy.iko.opencode.R
18import soy.iko.opencode.util.runCatchingCancellable
19import java.util.UUID
21@Immutable
22data class ServerEditState(
23 val id: String? = null,
24 val label: String = "",
25 val baseUrl: String = "",
26 val username: String = "",
27 val password: String = "",
28 val loaded: Boolean = false,
29 val saving: Boolean = false,
30 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,
35 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
36 val initial: InitialProfile? = null,
37 /** Whether a credential test is in progress. */
38 val testingCredentials: Boolean = false,
39 /** Non-null when the last credential test succeeded (true) or failed (false). */
40 val credentialsResult: Boolean? = null,
41) {
42 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
43 val isNew: Boolean get() = id == null
44 /** True if any field differs from its loaded value. */
45 val isDirty: Boolean
46 get() {
47 val init = initial ?: return false
48 return label.trim() != init.label ||
49 baseUrl.trim() != init.baseUrl ||
50 username.trim() != init.username ||
51 password.trim() != init.password
52 }
55/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
56data class InitialProfile(
57 val label: String,
58 val baseUrl: String,
59 val username: String,
60 val password: String,
63fun isValidUrl(url: String): Boolean = try {
64 val u = java.net.URI(url.trim())
65 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
66} catch (_: Exception) {
67 false
70class ServerEditViewModel(
71 private val container: AppContainer,
72 private val profileId: String?,
73) : ViewModel() {
75 private val _state = MutableStateFlow(ServerEditState(id = profileId))
76 val state: StateFlow<ServerEditState> = _state.asStateFlow()
78 init {
79 viewModelScope.launch {
80 if (profileId != null) {
81 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
82 container.profileStore.profiles
83 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
84 .firstOrNull { it.id == profileId }
85 }
86 if (existing != null) {
87 val init = InitialProfile(
88 label = existing.label,
89 baseUrl = existing.baseUrl,
90 username = existing.username.orEmpty(),
91 password = existing.password.orEmpty(),
92 )
93 _state.value = ServerEditState(
94 id = existing.id,
95 label = existing.label,
96 baseUrl = existing.baseUrl,
97 username = existing.username.orEmpty(),
98 password = existing.password.orEmpty(),
99 loaded = true,
100 authFieldsVisible = existing.hasAuth,
101 initial = init,
102 )
103 return@launch
104 }
105 _state.value = ServerEditState(
106 id = profileId,
107 loaded = true,
108 error = container.string(R.string.error_load_timeout),
109 )
110 return@launch
111 }
112 // New-profile form: seed an initial snapshot of empty values so isDirty
113 // becomes true the moment the user types anything.
114 _state.update {
115 it.copy(
116 loaded = true,
117 initial = InitialProfile("", "", "", ""),
118 )
119 }
120 }
121 }
123 fun update(transform: (ServerEditState) -> ServerEditState) {
124 _state.update { transform(it).copy(credentialsResult = null) }
125 }
127 fun probe() {
128 val s = _state.value
129 if (!s.canSave || s.probing) return
130 _state.update { it.copy(probing = true, error = null, credentialsResult = null) }
131 viewModelScope.launch {
132 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
133 result.onSuccess { pr ->
134 _state.update {
135 when (pr) {
136 is ProbeResult.Reachable -> it.copy(
137 probing = false,
138 authFieldsVisible = false,
139 error = null,
140 )
141 is ProbeResult.NeedsAuth -> it.copy(
142 probing = false,
143 authFieldsVisible = true,
144 error = null,
145 )
146 is ProbeResult.Unreachable -> it.copy(
147 probing = false,
148 authFieldsVisible = false,
149 error = pr.error,
150 )
151 }
152 }
153 }.onFailure { e ->
154 _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
155 }
156 }
157 }
159 /**
160 * Probe the server *with* the entered credentials to validate them before saving.
161 * Only meaningful once auth fields are visible (the server requires auth); calling
162 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
163 * so the UI can show success/failure without dismissing the form.
164 */
165 fun testCredentials() {
166 val s = _state.value
167 if (!s.canSave || s.testingCredentials) return
168 if (s.username.isBlank() && s.password.isBlank()) return
169 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
170 viewModelScope.launch {
171 val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
172 result.onSuccess { ok ->
173 _state.update {
174 it.copy(
175 testingCredentials = false,
176 credentialsResult = ok,
177 error = if (ok) null else container.string(R.string.credentials_rejected),
178 )
179 }
180 }.onFailure { e ->
181 _state.update {
182 it.copy(
183 testingCredentials = false,
184 credentialsResult = false,
185 error = container.friendlyError(e),
186 )
187 }
188 }
189 }
190 }
192 fun save(onDone: () -> Unit) {
193 saveInternal(connectAfter = false, onDone = onDone)
194 }
196 /** Save and then connect to the saved profile in one step. */
197 fun saveAndConnect(onDone: () -> Unit) {
198 saveInternal(connectAfter = true, onDone = onDone)
199 }
201 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
202 val s = _state.value
203 if (!s.canSave || s.saving) return
204 _state.update { it.copy(saving = true) }
205 viewModelScope.launch {
206 val result = runCatchingCancellable {
207 val existingLastUsed = if (s.id != null) {
208 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
209 container.profileStore.profiles.first()
210 .firstOrNull { it.id == s.id }?.lastUsed
211 } ?: 0L
212 } else 0L
213 val saved = ServerProfile(
214 id = s.id ?: UUID.randomUUID().toString(),
215 label = s.label.trim(),
216 baseUrl = s.baseUrl.trim(),
217 username = s.username.trim().takeIf { it.isNotBlank() },
218 password = s.password.trim().takeIf { it.isNotEmpty() },
219 lastUsed = existingLastUsed,
220 )
221 container.profileStore.save(saved)
222 if (connectAfter) {
223 container.connect(saved)
224 container.activeConnection.value?.api?.ping()
225 }
226 }
227 result.onSuccess { onDone() }
228 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
229 }
230 }