git.iko.soy/opencode-android
on bc6b45e help sign in
275 lines 12 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 /** True when the last connectivity probe found the server reachable without auth.
42 * Null until a probe runs, and cleared when the base URL is edited. */
43 val probeReachable: Boolean? = null,
44) {
45 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
46 val isNew: Boolean get() = id == null
47 /** True if any field differs from its loaded value. */
48 val isDirty: Boolean
49 get() {
50 val init = initial ?: return false
51 return label.trim() != init.label ||
52 baseUrl.trim() != init.baseUrl ||
53 username.trim() != init.username ||
54 password.trim() != init.password
55 }
58/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
59data class InitialProfile(
60 val label: String,
61 val baseUrl: String,
62 val username: String,
63 val password: String,
66fun isValidUrl(url: String): Boolean = try {
67 val u = java.net.URI(url.trim())
68 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
69} catch (_: Exception) {
70 false
73/**
74 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
75 * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
76 * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
77 */
78fun suggestUrlScheme(input: String): String? {
79 val trimmed = input.trim()
80 if (trimmed.isEmpty() || "://" in trimmed) return null
81 val candidate = "http://$trimmed"
82 return if (isValidUrl(candidate)) candidate else null
85class ServerEditViewModel(
86 private val container: AppContainer,
87 private val profileId: String?,
88 private val sourceId: String? = null,
89) : ViewModel() {
91 private val _state = MutableStateFlow(ServerEditState(id = profileId))
92 val state: StateFlow<ServerEditState> = _state.asStateFlow()
94 init {
95 viewModelScope.launch {
96 if (profileId != null) {
97 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
98 container.profileStore.profiles
99 .first { list -> list.any { p -> p.id == profileId } }
100 .firstOrNull { it.id == profileId }
101 }
102 if (existing != null) {
103 val init = InitialProfile(
104 label = existing.label,
105 baseUrl = existing.baseUrl,
106 username = existing.username.orEmpty(),
107 password = existing.password.orEmpty(),
108 )
109 _state.value = ServerEditState(
110 id = existing.id,
111 label = existing.label,
112 baseUrl = existing.baseUrl,
113 username = existing.username.orEmpty(),
114 password = existing.password.orEmpty(),
115 loaded = true,
116 authFieldsVisible = existing.hasAuth,
117 initial = init,
118 )
119 return@launch
120 }
121 _state.value = ServerEditState(
122 id = profileId,
123 loaded = true,
124 error = container.string(R.string.error_load_timeout),
125 )
126 return@launch
127 }
128 // Duplicate: seed the form from an existing profile but as a NEW profile
129 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
130 // label so the user can tell the duplicate from the original at a glance.
131 if (sourceId != null) {
132 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133 container.profileStore.profiles
134 .first { list -> list.any { p -> p.id == sourceId } }
135 .firstOrNull { it.id == sourceId }
136 }
137 if (source != null) {
138 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
139 _state.value = ServerEditState(
140 id = null,
141 label = dupLabel,
142 baseUrl = source.baseUrl,
143 username = source.username.orEmpty(),
144 password = source.password.orEmpty(),
145 loaded = true,
146 authFieldsVisible = source.hasAuth,
147 initial = InitialProfile("", "", "", ""),
148 )
149 return@launch
150 }
151 // Source not found — fall through to a blank new-profile form.
152 }
153 // New-profile form: seed an initial snapshot of empty values so isDirty
154 // becomes true the moment the user types anything.
155 _state.update {
156 it.copy(
157 loaded = true,
158 initial = InitialProfile("", "", "", ""),
159 )
160 }
161 }
162 }
164 fun update(transform: (ServerEditState) -> ServerEditState) {
165 _state.update { transform(it).copy(credentialsResult = null) }
166 }
168 fun probe() {
169 val s = _state.value
170 if (!s.canSave || s.probing) return
171 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
172 viewModelScope.launch {
173 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
174 result.onSuccess { pr ->
175 _state.update {
176 when (pr) {
177 is ProbeResult.Reachable -> it.copy(
178 probing = false,
179 authFieldsVisible = false,
180 error = null,
181 probeReachable = true,
182 )
183 is ProbeResult.NeedsAuth -> it.copy(
184 probing = false,
185 authFieldsVisible = true,
186 error = null,
187 probeReachable = false,
188 )
189 is ProbeResult.Unreachable -> it.copy(
190 probing = false,
191 authFieldsVisible = false,
192 error = pr.error,
193 probeReachable = false,
194 )
195 }
196 }
197 }.onFailure { e ->
198 _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
199 }
200 }
201 }
203 /**
204 * Probe the server *with* the entered credentials to validate them before saving.
205 * Only meaningful once auth fields are visible (the server requires auth); calling
206 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
207 * so the UI can show success/failure without dismissing the form.
208 */
209 fun testCredentials() {
210 val s = _state.value
211 if (!s.canSave || s.testingCredentials) return
212 if (s.username.isBlank() && s.password.isBlank()) return
213 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
214 viewModelScope.launch {
215 val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
216 result.onSuccess { ok ->
217 _state.update {
218 it.copy(
219 testingCredentials = false,
220 credentialsResult = ok,
221 error = if (ok) null else container.string(R.string.credentials_rejected),
222 )
223 }
224 }.onFailure { e ->
225 _state.update {
226 it.copy(
227 testingCredentials = false,
228 credentialsResult = false,
229 error = container.friendlyError(e),
230 )
231 }
232 }
233 }
234 }
236 fun save(onDone: () -> Unit) {
237 saveInternal(connectAfter = false, onDone = onDone)
238 }
240 /** Save and then connect to the saved profile in one step. */
241 fun saveAndConnect(onDone: () -> Unit) {
242 saveInternal(connectAfter = true, onDone = onDone)
243 }
245 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
246 val s = _state.value
247 if (!s.canSave || s.saving) return
248 _state.update { it.copy(saving = true) }
249 viewModelScope.launch {
250 val result = runCatchingCancellable {
251 val existingLastUsed = if (s.id != null) {
252 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
253 container.profileStore.profiles.first()
254 .firstOrNull { it.id == s.id }?.lastUsed
255 } ?: 0L
256 } else 0L
257 val saved = ServerProfile(
258 id = s.id ?: UUID.randomUUID().toString(),
259 label = s.label.trim(),
260 baseUrl = s.baseUrl.trim(),
261 username = s.username.trim().takeIf { it.isNotBlank() },
262 password = s.password.trim().takeIf { it.isNotEmpty() },
263 lastUsed = existingLastUsed,
264 )
265 container.profileStore.save(saved)
266 if (connectAfter) {
267 container.connect(saved)
268 container.activeConnection.value?.api?.ping()
269 }
270 }
271 result.onSuccess { onDone() }
272 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
273 }
274 }