fix(#298): internationalize ConnectViewModel strings, fix null-assert crash on profile selector (#307)
* fix(#298): internationalize ConnectViewModel strings, fix null-assert crash on profile selector E1: Add all connect error resources to strings.xml for i18n readiness E2: Migrate ConnectViewModel to AndroidViewModel, replace all hardcoded user-facing strings with getString(R.string.*) calls F1: Replace state.selectedProfile!! with .?.let { } pattern in ConnectScreen.kt to eliminate NPE risk on mutable state copy Closes #298 * fix(#298): update test files for AndroidViewModel constructor Update ConnectViewModelTest.kt and E2eIntegrationTest.kt to pass mock Application to the new ConnectViewModel(app) constructor. Add getString() MockK stubs matching the string resource keys. * fix(#298): add missing LocalContext import and restore connect_pairing_string Two compilation errors: - ConnectScreen.kt was missing import for LocalContext - strings.xml was missing connect_pairing_string resource (accidentally removed in earlier patch) * fix(#298): extract ViewModelFactory to avoid composable-in-non-composable error LocalContext.current cannot be called inside a viewModel { } factory lambda because the lambda is not @Composable. Fix by extracting a ConnectViewModelFactory class and using run { } to capture the Application value at the composable call site before passing to the factory constructor. * fix(#298): fix MockK varargs matching in test stubs getString(int, Object...) takes varargs. MockK's any<Int>() and any<String>() didn't match the vararg Object... slot. Changed to any() (untyped) which matches correctly. * fix(#298): fix MockK answers — arg indexing was wrong In getString(int, Object...), firstArg is the resource ID, secondArg is the format argument. Using firstArg<Int>() in the answers block was returning the res ID, not 500/etc. Changed to secondArg<Any>(). * fix(#298): replace getString(int,Object...) with String.format(getString(int),...) Avoid MockK varargs matching issues by calling app.getString(int) for the format template and String.format() to apply args. Simplifies test stubs to pure getString(int) returns. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
8e040543de
commit
e8025efcde
5 files changed
+156
-57
No files matched your search
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.connect
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -45,6 +46,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -60,7 +62,14 @@ import com.m57.hermescontrol.R
|
||||
fun ConnectScreen(
|
||||
onConnected: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ConnectViewModel = viewModel { ConnectViewModel() },
|
||||
viewModel: ConnectViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
run {
|
||||
val app = LocalContext.current.applicationContext as Application
|
||||
ConnectViewModelFactory(app)
|
||||
},
|
||||
),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -135,11 +144,9 @@ fun ConnectScreen(
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (state.selectedProfile != null) {
|
||||
stringResource(R.string.connect_selected_profile, state.selectedProfile!!.name)
|
||||
} else {
|
||||
stringResource(R.string.connect_action_select_profile)
|
||||
},
|
||||
state.selectedProfile?.let { p ->
|
||||
stringResource(R.string.connect_selected_profile, p.name)
|
||||
} ?: stringResource(R.string.connect_action_select_profile),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.connect
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
@@ -28,7 +31,7 @@ data class ConnectUiState(
|
||||
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
|
||||
)
|
||||
|
||||
class ConnectViewModel : ViewModel() {
|
||||
class ConnectViewModel(private val app: Application) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ConnectUiState())
|
||||
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
||||
|
||||
@@ -110,16 +113,16 @@ class ConnectViewModel : ViewModel() {
|
||||
fun connect() {
|
||||
val state = _uiState.value
|
||||
if (state.token.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Token is required") }
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) }
|
||||
return
|
||||
}
|
||||
if (state.host.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Host is required") }
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_host_required)) }
|
||||
return
|
||||
}
|
||||
val port = state.port.toIntOrNull()
|
||||
if (port == null || port !in 1..65535) {
|
||||
_uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") }
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_port_invalid)) }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,15 +188,15 @@ class ConnectViewModel : ViewModel() {
|
||||
when (err.code) {
|
||||
401 -> {
|
||||
AuthManager.setToken(null)
|
||||
"Invalid token (401 Unauthorized)"
|
||||
app.getString(R.string.connect_error_401)
|
||||
}
|
||||
|
||||
403 -> {
|
||||
"Access denied (403 Forbidden)"
|
||||
app.getString(R.string.connect_error_403)
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Server returned HTTP ${err.code}"
|
||||
String.format(app.getString(R.string.connect_error_http_code), err.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,20 +204,46 @@ class ConnectViewModel : ViewModel() {
|
||||
is NetworkError.Connection -> {
|
||||
val causeMessage = err.cause.message ?: ""
|
||||
when {
|
||||
causeMessage.contains("timeout", true) -> "Connection timed out"
|
||||
causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?"
|
||||
causeMessage.contains("resolve", true) -> "Could not resolve host"
|
||||
else -> "Connection failed: ${err.cause.message}"
|
||||
causeMessage.contains(
|
||||
"timeout",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_timeout)
|
||||
causeMessage.contains(
|
||||
"refused",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_refused)
|
||||
causeMessage.contains(
|
||||
"resolve",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_resolve)
|
||||
else ->
|
||||
String.format(
|
||||
app.getString(R.string.connect_error_connection_failed),
|
||||
err.cause.message ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkError.Unknown -> {
|
||||
val causeMessage = err.cause.message ?: ""
|
||||
when {
|
||||
causeMessage.contains("timeout", true) -> "Connection timed out"
|
||||
causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?"
|
||||
causeMessage.contains("resolve", true) -> "Could not resolve host"
|
||||
else -> "Connection failed: ${err.cause.message}"
|
||||
causeMessage.contains(
|
||||
"timeout",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_timeout)
|
||||
causeMessage.contains(
|
||||
"refused",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_refused)
|
||||
causeMessage.contains(
|
||||
"resolve",
|
||||
true,
|
||||
) -> app.getString(R.string.connect_error_resolve)
|
||||
else ->
|
||||
String.format(
|
||||
app.getString(R.string.connect_error_connection_failed),
|
||||
err.cause.message ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +304,7 @@ class ConnectViewModel : ViewModel() {
|
||||
// Decoded valid JSON but missing required fields
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Malformed pairing string — missing host, port, or token",
|
||||
errorMessage = app.getString(R.string.connect_error_missing_fields),
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -283,7 +312,7 @@ class ConnectViewModel : ViewModel() {
|
||||
// Decoded to valid string but not JSON shape — not a pairing string
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Malformed pairing string — expected URL or Base64-encoded JSON",
|
||||
errorMessage = app.getString(R.string.connect_error_malformed),
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -294,17 +323,38 @@ class ConnectViewModel : ViewModel() {
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Malformed pairing string — expected URL or Base64-encoded JSON",
|
||||
errorMessage = app.getString(R.string.connect_error_malformed),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage =
|
||||
String.format(
|
||||
app.getString(R.string.connect_error_parse_failed),
|
||||
e.message ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = String.format(app.getString(R.string.connect_error_parse_failed), e.message ?: ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user