git.iko.soy/opencode-android
on c0621ff help sign in
286 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 // Snapshot the seeded (copy) values so an untouched duplicate isn't
148 // immediately dirty — an empty initial would make backing out of a
149 // freshly opened duplicate always trigger the discard-changes dialog.
150 initial = InitialProfile(
151 label = dupLabel.trim(),
152 baseUrl = source.baseUrl.trim(),
153 username = source.username.orEmpty().trim(),
154 password = source.password.orEmpty().trim(),
155 ),
156 )
157 return@launch
158 }
159 // Source not found — fall through to a blank new-profile form.
160 }
161 // New-profile form: seed an initial snapshot of empty values so isDirty
162 // becomes true the moment the user types anything.
163 _state.update {
164 it.copy(
165 loaded = true,
166 initial = InitialProfile("", "", "", ""),
167 )
168 }
169 }
170 }
172 fun update(transform: (ServerEditState) -> ServerEditState) {
173 // Clear both the inline credential note and the bottom-of-form error when any field
174 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
175 // "Credentials rejected" / test-failure message no longer describes the current input.
176 _state.update { transform(it).copy(credentialsResult = null, error = null) }
177 }
179 fun probe() {
180 val s = _state.value
181 if (!s.canSave || s.probing) return
182 _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
183 viewModelScope.launch {
184 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
185 result.onSuccess { pr ->
186 _state.update {
187 when (pr) {
188 is ProbeResult.Reachable -> it.copy(
189 probing = false,
190 authFieldsVisible = false,
191 error = null,
192 probeReachable = true,
193 )
194 is ProbeResult.NeedsAuth -> it.copy(
195 probing = false,
196 authFieldsVisible = true,
197 error = null,
198 probeReachable = false,
199 )
200 is ProbeResult.Unreachable -> it.copy(
201 probing = false,
202 authFieldsVisible = false,
203 error = pr.error,
204 probeReachable = false,
205 )
206 }
207 }
208 }.onFailure { e ->
209 _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
210 }
211 }
212 }
214 /**
215 * Probe the server *with* the entered credentials to validate them before saving.
216 * Only meaningful once auth fields are visible (the server requires auth); calling
217 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
218 * so the UI can show success/failure without dismissing the form.
219 */
220 fun testCredentials() {
221 val s = _state.value
222 if (!s.canSave || s.testingCredentials) return
223 if (s.username.isBlank() && s.password.isBlank()) return
224 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
225 viewModelScope.launch {
226 val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
227 result.onSuccess { ok ->
228 _state.update {
229 it.copy(
230 testingCredentials = false,
231 credentialsResult = ok,
232 error = if (ok) null else container.string(R.string.credentials_rejected),
233 )
234 }
235 }.onFailure { e ->
236 _state.update {
237 it.copy(
238 testingCredentials = false,
239 credentialsResult = false,
240 error = container.friendlyError(e),
241 )
242 }
243 }
244 }
245 }
247 fun save(onDone: () -> Unit) {
248 saveInternal(connectAfter = false, onDone = onDone)
249 }
251 /** Save and then connect to the saved profile in one step. */
252 fun saveAndConnect(onDone: () -> Unit) {
253 saveInternal(connectAfter = true, onDone = onDone)
254 }
256 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
257 val s = _state.value
258 if (!s.canSave || s.saving) return
259 _state.update { it.copy(saving = true) }
260 viewModelScope.launch {
261 val result = runCatchingCancellable {
262 val existingLastUsed = if (s.id != null) {
263 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
264 container.profileStore.profiles.first()
265 .firstOrNull { it.id == s.id }?.lastUsed
266 } ?: 0L
267 } else 0L
268 val saved = ServerProfile(
269 id = s.id ?: UUID.randomUUID().toString(),
270 label = s.label.trim(),
271 baseUrl = s.baseUrl.trim(),
272 username = s.username.trim().takeIf { it.isNotBlank() },
273 password = s.password.trim().takeIf { it.isNotEmpty() },
274 lastUsed = existingLastUsed,
275 )
276 container.profileStore.save(saved)
277 if (connectAfter) {
278 container.connect(saved)
279 container.activeConnection.value?.api?.ping()
280 }
281 }
282 result.onSuccess { onDone() }
283 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
284 }
285 }