git.iko.soy/opencode-android
on a40d3a2 help sign in
168 lines 7.1 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) {
36 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
37 val isNew: Boolean get() = id == null
40private fun isValidUrl(url: String): Boolean = try {
41 val u = java.net.URI(url.trim())
42 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
43} catch (_: Exception) {
44 false
47class ServerEditViewModel(
48 private val container: AppContainer,
49 private val profileId: String?,
50) : ViewModel() {
52 private val _state = MutableStateFlow(ServerEditState(id = profileId))
53 val state: StateFlow<ServerEditState> = _state.asStateFlow()
55 init {
56 viewModelScope.launch {
57 if (profileId != null) {
58 // Wait for a non-empty profile list before searching, so DataStore
59 // emitting an empty list during loading doesn't cause a false timeout.
60 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
61 container.profileStore.profiles
62 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
63 .firstOrNull { it.id == profileId }
64 }
65 if (existing != null) {
66 _state.value = ServerEditState(
67 id = existing.id,
68 label = existing.label,
69 baseUrl = existing.baseUrl,
70 username = existing.username.orEmpty(),
71 password = existing.password.orEmpty(),
72 loaded = true,
73 // An existing profile that already has credentials saved should
74 // show the auth fields immediately — the user may want to edit them.
75 authFieldsVisible = existing.hasAuth,
76 )
77 return@launch
78 }
79 // Profile existed but timed out loading — show an error instead of
80 // silently presenting an empty form that looks like "new server".
81 _state.value = ServerEditState(
82 id = profileId,
83 loaded = true,
84 error = container.string(R.string.error_load_timeout),
85 )
86 return@launch
87 }
88 _state.update { it.copy(loaded = true) }
89 }
90 }
92 fun update(transform: (ServerEditState) -> ServerEditState) {
93 _state.update(transform)
94 }
96 /**
97 * Probe the server at [baseUrl] to check reachability and detect whether
98 * authentication is required. On success the auth fields are shown only if the
99 * server returned an auth error (401/403); on failure the error is surfaced.
100 * Editing the base URL after a probe resets the auth-visibility so the next
101 * save re-probes against the new URL.
102 */
103 fun probe() {
104 val s = _state.value
105 if (!s.canSave || s.probing) return
106 _state.update { it.copy(probing = true, error = null) }
107 viewModelScope.launch {
108 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
109 result.onSuccess { pr ->
110 _state.update {
111 when (pr) {
112 is ProbeResult.Reachable -> it.copy(
113 probing = false,
114 authFieldsVisible = false,
115 error = null,
116 )
117 is ProbeResult.NeedsAuth -> it.copy(
118 probing = false,
119 authFieldsVisible = true,
120 error = null,
121 )
122 is ProbeResult.Unreachable -> it.copy(
123 probing = false,
124 authFieldsVisible = false,
125 error = pr.error,
126 )
127 }
128 }
129 }.onFailure { e ->
130 _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
131 }
132 }
133 }
135 fun save(onDone: () -> Unit) {
136 val s = _state.value
137 if (!s.canSave || s.saving) return
138 _state.update { it.copy(saving = true) }
139 viewModelScope.launch {
140 val result = runCatchingCancellable {
141 // Preserve the existing lastUsed timestamp so saving an edit doesn't
142 // reset the profile's sort position (profiles are sorted descending
143 // by lastUsed). connect() is responsible for updating lastUsed on
144 // actual server use. If loading the existing profile times out,
145 // lastUsed=0 is passed — ProfileStore.save() preserves the existing
146 // nonzero lastUsed in that case so the sort position isn't lost.
147 val existingLastUsed = if (s.id != null) {
148 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
149 container.profileStore.profiles.first()
150 .firstOrNull { it.id == s.id }?.lastUsed
151 } ?: 0L
152 } else 0L
153 container.profileStore.save(
154 ServerProfile(
155 id = s.id ?: UUID.randomUUID().toString(),
156 label = s.label.trim(),
157 baseUrl = s.baseUrl.trim(),
158 username = s.username.trim().takeIf { it.isNotBlank() },
159 password = s.password.trim().takeIf { it.isNotEmpty() },
160 lastUsed = existingLastUsed,
161 ),
162 )
163 }
164 result.onSuccess { onDone() }
165 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
166 }
167 }