feat: optimize network stack (#404)
* feat: optimize network stack and connection management Refactor OkHttp connection pools, consolidate Gson serialization, add network-aware WebSocket reconnects, and deduplicate concurrent requests. * test: update mapping assertions for AuthExpired * refactor: address PR review feedback on network stack Simplify health check, improve authenticator cookie matching robustness, resolve nit imports, and add unit tests.
This commit is contained in:
19 files changed
+455
-206
No files matched your search
@@ -48,7 +48,9 @@ data class AuthLoginUiState(
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
class AuthLoginViewModel(
|
||||
private val app: Application,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AuthLoginUiState())
|
||||
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
@@ -57,11 +59,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
}
|
||||
|
||||
private val probeClient: OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
|
||||
|
||||
fun onHostChange(value: String) {
|
||||
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) }
|
||||
@@ -150,7 +148,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
// Step 1: Check if dashboard is reachable via /api/status (always public)
|
||||
val statusOk =
|
||||
try {
|
||||
val req = Request.Builder().url("$baseUrl/api/status").get().build()
|
||||
val req =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/api/status")
|
||||
.get()
|
||||
.build()
|
||||
val resp = probeClient.newCall(req).execute()
|
||||
resp.isSuccessful
|
||||
} catch (e: Exception) {
|
||||
@@ -163,7 +166,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
// Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA
|
||||
val needsBasicAuth =
|
||||
try {
|
||||
val req = Request.Builder().url(baseUrl).get().build()
|
||||
val req =
|
||||
Request
|
||||
.Builder()
|
||||
.url(baseUrl)
|
||||
.get()
|
||||
.build()
|
||||
val resp = probeClient.newCall(req).execute()
|
||||
val code = resp.code
|
||||
val location = resp.header("location", "")
|
||||
@@ -178,7 +186,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
var extractedToken: String? = null
|
||||
if (!needsBasicAuth) {
|
||||
try {
|
||||
val req = Request.Builder().url(baseUrl).get().build()
|
||||
val req =
|
||||
Request
|
||||
.Builder()
|
||||
.url(baseUrl)
|
||||
.get()
|
||||
.build()
|
||||
val resp = probeClient.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
// Extract __HERMES_SESSION_TOKEN__ from the SPA HTML
|
||||
@@ -231,12 +244,18 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
val token = connectTokenOnly(state.host, port, state.token)
|
||||
if (token != null) ConnectResult(wsCredential = token) else null
|
||||
}
|
||||
DashboardAuthMode.BASIC_AUTH ->
|
||||
|
||||
DashboardAuthMode.BASIC_AUTH -> {
|
||||
connectBasicAuth(state.host, port, state.username, state.password)
|
||||
}
|
||||
|
||||
DashboardAuthMode.ALL -> {
|
||||
connectBasicAuth(state.host, port, state.username, state.password)
|
||||
}
|
||||
null -> null
|
||||
|
||||
null -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +298,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
|
||||
token
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
val msg =
|
||||
when (val err = result.error) {
|
||||
@@ -289,9 +309,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
else -> app.getString(R.string.connect_error_http_code, err.code)
|
||||
}
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkError.AuthExpired -> {
|
||||
app.getString(R.string.connect_error_401)
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkError.Connection -> {
|
||||
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkError.Unknown -> {
|
||||
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
|
||||
}
|
||||
@@ -331,14 +357,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
try {
|
||||
// Step 1: Authenticate via the password login endpoint to get a session cookie
|
||||
val loginClient =
|
||||
OkHttpClient.Builder()
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
|
||||
.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
|
||||
val loginReq =
|
||||
Request.Builder()
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/auth/password-login")
|
||||
.header("Content-Type", "application/json")
|
||||
.post(jsonBody.toRequestBody())
|
||||
@@ -380,13 +407,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
|
||||
// Step 3: Mint a WebSocket ticket using the session cookie
|
||||
val ticketClient =
|
||||
OkHttpClient.Builder()
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val ticketReq =
|
||||
Request.Builder()
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/api/auth/ws-ticket")
|
||||
.header("Cookie", cookieHeader)
|
||||
.post("{}".toRequestBody())
|
||||
@@ -434,7 +463,5 @@ class AuthLoginViewModelFactory(
|
||||
private val app: Application,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return AuthLoginViewModel(app) as T
|
||||
}
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = AuthLoginViewModel(app) as T
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.gson.Gson
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
@@ -775,16 +775,14 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? {
|
||||
return try {
|
||||
val gson = com.google.gson.Gson()
|
||||
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
|
||||
try {
|
||||
val json = gson.toJson(map)
|
||||
gson.fromJson(json, CommandCatalog::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse command catalog", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
@@ -1186,6 +1184,6 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val gson = Gson()
|
||||
private val gson = OkHttpProvider.gson
|
||||
}
|
||||
}
|
||||
@@ -216,8 +216,8 @@ object ChatWsEventReducer {
|
||||
): ReducerResult {
|
||||
val contentJson =
|
||||
event.data?.let {
|
||||
com.google.gson
|
||||
.Gson()
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
.gson
|
||||
.toJson(it)
|
||||
} ?: ""
|
||||
val toolMessage =
|
||||
@@ -264,8 +264,8 @@ object ChatWsEventReducer {
|
||||
): ReducerResult {
|
||||
val contentJson =
|
||||
event.data?.let {
|
||||
com.google.gson
|
||||
.Gson()
|
||||
com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
.gson
|
||||
.toJson(it)
|
||||
} ?: ""
|
||||
val messages = state.messages.toMutableList()
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -12,17 +13,19 @@ interface ToastHost {
|
||||
}
|
||||
|
||||
inline fun <T> ViewModel.safeLaunchLoad(
|
||||
currentJob: Job? = null,
|
||||
crossinline apiCall: suspend () -> NetworkResult<T>,
|
||||
crossinline onStart: () -> Unit,
|
||||
crossinline onSuccess: (T) -> Unit,
|
||||
crossinline onError: (String) -> Unit,
|
||||
) {
|
||||
): Job {
|
||||
if (currentJob?.isActive == true) return currentJob
|
||||
onStart()
|
||||
viewModelScope.launch {
|
||||
return viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { apiCall() }
|
||||
when (result) {
|
||||
is NetworkResult.Success -> onSuccess(result.data)
|
||||
is NetworkResult.Failure -> onError(result.error.message ?: "Unknown error")
|
||||
is NetworkResult.Failure -> onError(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ data class ConnectUiState(
|
||||
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
|
||||
)
|
||||
|
||||
class ConnectViewModel(private val app: Application) : ViewModel() {
|
||||
class ConnectViewModel(
|
||||
private val app: Application,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ConnectUiState())
|
||||
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
||||
|
||||
@@ -206,26 +208,44 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkError.AuthExpired -> {
|
||||
AuthManager.setToken(null)
|
||||
if (AuthManager.getSelectedProfileId() != null) {
|
||||
AuthManager.setProfileToken(AuthManager.getSelectedProfileId()!!, null)
|
||||
}
|
||||
app.getString(R.string.connect_error_401)
|
||||
}
|
||||
|
||||
is NetworkError.Connection -> {
|
||||
val causeMessage = err.cause.message ?: ""
|
||||
when {
|
||||
causeMessage.contains(
|
||||
"timeout",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_timeout)
|
||||
) -> {
|
||||
app.getString(R.string.connect_error_timeout)
|
||||
}
|
||||
|
||||
causeMessage.contains(
|
||||
"refused",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_refused)
|
||||
) -> {
|
||||
app.getString(R.string.connect_error_refused)
|
||||
}
|
||||
|
||||
causeMessage.contains(
|
||||
"resolve",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_resolve)
|
||||
else ->
|
||||
) -> {
|
||||
app.getString(R.string.connect_error_resolve)
|
||||
}
|
||||
|
||||
else -> {
|
||||
String.format(
|
||||
app.getString(R.string.connect_error_connection_failed),
|
||||
err.cause.message ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,20 +255,30 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
|
||||
causeMessage.contains(
|
||||
"timeout",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_timeout)
|
||||
) -> {
|
||||
app.getString(R.string.connect_error_timeout)
|
||||
}
|
||||
|
||||
causeMessage.contains(
|
||||
"refused",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_refused)
|
||||
) -> {
|
||||
app.getString(R.string.connect_error_refused)
|
||||
}
|
||||
|
||||
causeMessage.contains(
|
||||
"resolve",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_resolve)
|
||||
else ->
|
||||
) -> {
|
||||
app.getString(R.string.connect_error_resolve)
|
||||
}
|
||||
|
||||
else -> {
|
||||
String.format(
|
||||
app.getString(R.string.connect_error_connection_failed),
|
||||
err.cause.message ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,7 +389,5 @@ class ConnectViewModelFactory(
|
||||
private val app: Application,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ConnectViewModel(app) as T
|
||||
}
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = ConnectViewModel(app) as T
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -23,26 +24,32 @@ data class CronJobsUiState(
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class CronJobsViewModel : ViewModel(), ToastHost {
|
||||
class CronJobsViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(CronJobsUiState())
|
||||
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
|
||||
fun loadCronJobs() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load cron jobs: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
currentJob = loadJob,
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load cron jobs: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun pauseCronJob(id: String) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -26,6 +27,8 @@ class SessionsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
|
||||
/** Page size sent to the server — matches the default the gateway uses. */
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 20
|
||||
@@ -33,37 +36,39 @@ class SessionsViewModel : ViewModel() {
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions() {
|
||||
safeLaunchLoad(
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
sessions = data?.sessions.orEmpty(),
|
||||
total = data?.total ?: 0,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load sessions: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
currentJob = loadJob,
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
sessions = data?.sessions.orEmpty(),
|
||||
total = data?.total ?: 0,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load sessions: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Load the next page and append to the existing session list. */
|
||||
@@ -92,6 +97,7 @@ class SessionsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -29,26 +30,32 @@ data class SkillsUiState(
|
||||
val saveContentSuccess: Boolean = false,
|
||||
)
|
||||
|
||||
class SkillsViewModel : ViewModel(), ToastHost {
|
||||
class SkillsViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(SkillsUiState())
|
||||
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
|
||||
fun loadSkills() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load skills: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
currentJob = loadJob,
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load skills: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleSkill(skill: Skill) {
|
||||
|
||||
Reference in new issue
Block a user