mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-18 11:46:28 -04:00
feat(node): smoother remote-admin UX with per-node session tracking (#5217)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
@@ -16,27 +16,39 @@
|
||||
*/
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.navigation.SettingsRoute
|
||||
import org.meshtastic.core.model.SessionStatus
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.administration
|
||||
import org.meshtastic.core.resources.connect_radio_for_remote_admin
|
||||
import org.meshtastic.core.resources.establishing_session
|
||||
import org.meshtastic.core.resources.firmware
|
||||
import org.meshtastic.core.resources.firmware_edition
|
||||
import org.meshtastic.core.resources.installed_firmware_version
|
||||
import org.meshtastic.core.resources.latest_alpha_firmware
|
||||
import org.meshtastic.core.resources.latest_stable_firmware
|
||||
import org.meshtastic.core.resources.refresh_metadata
|
||||
import org.meshtastic.core.resources.remote_admin
|
||||
import org.meshtastic.core.resources.request_metadata
|
||||
import org.meshtastic.core.resources.session_active
|
||||
import org.meshtastic.core.resources.session_refresh_required
|
||||
import org.meshtastic.core.ui.component.BasicListItem
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.icon.ForkLeft
|
||||
import org.meshtastic.core.ui.icon.Icecream
|
||||
@@ -57,35 +69,113 @@ fun AdministrationSection(
|
||||
metricsState: MetricsState,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
sessionStatus: SessionStatus,
|
||||
isEnsuringSession: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SectionCard(title = Res.string.administration, modifier = modifier) {
|
||||
Column {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.request_metadata),
|
||||
leadingIcon = MeshtasticIcons.Memory,
|
||||
trailingIcon = null,
|
||||
onClick = {
|
||||
onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num)))
|
||||
},
|
||||
)
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
SectionCard(title = Res.string.administration) {
|
||||
Column {
|
||||
// Local nodes don't need a session — they short-circuit straight to the settings screen.
|
||||
if (metricsState.isLocal) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.remote_admin),
|
||||
leadingIcon = MeshtasticIcons.Settings,
|
||||
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(node.num)) },
|
||||
)
|
||||
} else {
|
||||
RemoteAdminListItem(
|
||||
nodeNum = node.num,
|
||||
sessionStatus = sessionStatus,
|
||||
isEnsuringSession = isEnsuringSession,
|
||||
onAction = onAction,
|
||||
)
|
||||
|
||||
SectionDivider()
|
||||
SectionDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.remote_admin),
|
||||
leadingIcon = MeshtasticIcons.Settings,
|
||||
enabled = metricsState.isLocal || node.metadata != null,
|
||||
) {
|
||||
onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num)))
|
||||
ListItem(
|
||||
text = stringResource(Res.string.refresh_metadata),
|
||||
leadingIcon = MeshtasticIcons.Memory,
|
||||
trailingIcon = null,
|
||||
enabled = !isEnsuringSession,
|
||||
onClick = { onAction(NodeDetailAction.RefreshMetadata(node.num)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val firmwareVersion = node.metadata?.firmware_version
|
||||
val firmwareEdition = metricsState.firmwareEdition
|
||||
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
|
||||
FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
|
||||
val firmwareVersion = node.metadata?.firmware_version
|
||||
val firmwareEdition = metricsState.firmwareEdition
|
||||
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
|
||||
FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single primary affordance for opening the remote-admin screen. Replaces the prior two-row, no-feedback flow that
|
||||
* required the user to know they had to tap "Metadata" first to populate `node.metadata` before "Remote Administration"
|
||||
* un-greyed out. The session passkey freshness — not the metadata insert — is the real gate (see
|
||||
* `firmware/src/modules/AdminModule.cpp:1460-1481`), and is now reflected via an [AssistChip] + inline progress.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemoteAdminListItem(
|
||||
nodeNum: Int,
|
||||
sessionStatus: SessionStatus,
|
||||
isEnsuringSession: Boolean,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
) {
|
||||
val supportingTextRes =
|
||||
when (sessionStatus) {
|
||||
SessionStatus.NoSession -> Res.string.connect_radio_for_remote_admin
|
||||
is SessionStatus.Active -> null
|
||||
is SessionStatus.Stale -> Res.string.session_refresh_required
|
||||
}
|
||||
val chipLabelRes =
|
||||
when (sessionStatus) {
|
||||
SessionStatus.NoSession -> null
|
||||
is SessionStatus.Active -> Res.string.session_active
|
||||
is SessionStatus.Stale -> Res.string.session_refresh_required
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicListItem(
|
||||
text = stringResource(Res.string.remote_admin),
|
||||
leadingIcon = MeshtasticIcons.Settings,
|
||||
supportingText = supportingTextRes?.let { stringResource(it) },
|
||||
enabled = !isEnsuringSession,
|
||||
trailingContent =
|
||||
chipLabelRes?.let { res ->
|
||||
{
|
||||
AssistChip(
|
||||
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
|
||||
label = { androidx.compose.material3.Text(stringResource(res)) },
|
||||
enabled = !isEnsuringSession,
|
||||
colors =
|
||||
if (sessionStatus is SessionStatus.Active) {
|
||||
AssistChipDefaults.assistChipColors(
|
||||
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
)
|
||||
} else {
|
||||
AssistChipDefaults.assistChipColors()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
|
||||
)
|
||||
AnimatedVisibility(visible = isEnsuringSession) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
androidx.compose.material3.Text(
|
||||
text = stringResource(Res.string.establishing_session),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ internal fun handleNodeAction(
|
||||
when (action) {
|
||||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
||||
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
|
||||
is NodeDetailAction.OpenRemoteAdmin -> viewModel.openRemoteAdmin(action.nodeNum)
|
||||
is NodeDetailAction.RefreshMetadata -> viewModel.refreshMetadata(action.nodeNum)
|
||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
||||
when (val menuAction = action.action) {
|
||||
is NodeMenuAction.DirectMessage -> {
|
||||
|
||||
@@ -119,7 +119,16 @@ fun NodeDetailList(
|
||||
}
|
||||
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
|
||||
if (!uiState.metricsState.isManaged) {
|
||||
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
|
||||
item {
|
||||
AdministrationSection(
|
||||
node = node,
|
||||
metricsState = uiState.metricsState,
|
||||
onAction = onAction,
|
||||
onFirmwareSelect = onFirmwareSelect,
|
||||
sessionStatus = uiState.sessionStatus,
|
||||
isEnsuringSession = uiState.isEnsuringSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ fun NodeDetailScreen(
|
||||
) {
|
||||
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(viewModel) { viewModel.navigationEvents.collect { onNavigate(it) } }
|
||||
NodeDetailScaffold(
|
||||
modifier = modifier,
|
||||
uiState = uiState,
|
||||
|
||||
@@ -20,19 +20,32 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
|
||||
import org.meshtastic.core.domain.usecase.session.EnsureSessionResult
|
||||
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.SessionStatus
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoute
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.connect_radio_for_remote_admin
|
||||
import org.meshtastic.core.resources.remote_admin_unreachable
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
@@ -51,6 +64,8 @@ data class NodeDetailUiState(
|
||||
val availableLogs: Set<LogsType> = emptySet(),
|
||||
val lastTracerouteTime: Long? = null,
|
||||
val lastRequestNeighborsTime: Long? = null,
|
||||
val sessionStatus: SessionStatus = SessionStatus.NoSession,
|
||||
val isEnsuringSession: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -58,12 +73,16 @@ data class NodeDetailUiState(
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@KoinViewModel
|
||||
@Suppress("LongParameterList")
|
||||
class NodeDetailViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeManagementActions: NodeManagementActions,
|
||||
private val nodeRequestActions: NodeRequestActions,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
|
||||
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase,
|
||||
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase,
|
||||
private val snackbarManager: SnackbarManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val nodeIdFromRoute: Int? = savedStateHandle.get<Int>("destNum")
|
||||
@@ -73,12 +92,32 @@ class NodeDetailViewModel(
|
||||
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
|
||||
.distinctUntilChanged()
|
||||
|
||||
private val isEnsuringSession = MutableStateFlow(false)
|
||||
|
||||
private val sessionStatusFlow =
|
||||
activeNodeId.flatMapLatest { nodeId ->
|
||||
if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId)
|
||||
}
|
||||
|
||||
/** One-shot navigation events from session-bearing actions (e.g. successful remote-admin opens). */
|
||||
private val _navigationEvents = Channel<Route>(capacity = Channel.BUFFERED)
|
||||
val navigationEvents: Flow<Route> = _navigationEvents.receiveAsFlow()
|
||||
|
||||
/** Primary UI state stream, combining identity, metrics, and global device metadata. */
|
||||
val uiState: StateFlow<NodeDetailUiState> =
|
||||
activeNodeId
|
||||
.flatMapLatest { nodeId ->
|
||||
if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState())
|
||||
getNodeDetailsUseCase(nodeId)
|
||||
if (nodeId == null) {
|
||||
flowOf(NodeDetailUiState())
|
||||
} else {
|
||||
combine(getNodeDetailsUseCase(nodeId), sessionStatusFlow, isEnsuringSession) {
|
||||
base,
|
||||
sessionStatus,
|
||||
ensuring,
|
||||
->
|
||||
base.copy(sessionStatus = sessionStatus, isEnsuringSession = ensuring)
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = NodeDetailUiState())
|
||||
|
||||
@@ -117,6 +156,37 @@ class NodeDetailViewModel(
|
||||
|
||||
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
|
||||
|
||||
/**
|
||||
* Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a
|
||||
* snackbar with the appropriate guidance on [EnsureSessionResult.Disconnected] or [EnsureSessionResult.Timeout].
|
||||
*/
|
||||
fun openRemoteAdmin(destNum: Int) {
|
||||
if (isEnsuringSession.value) return
|
||||
viewModelScope.launch {
|
||||
isEnsuringSession.value = true
|
||||
try {
|
||||
when (ensureRemoteAdminSession(destNum)) {
|
||||
EnsureSessionResult.AlreadyActive,
|
||||
EnsureSessionResult.Refreshed,
|
||||
-> _navigationEvents.trySend(SettingsRoute.Settings(destNum))
|
||||
EnsureSessionResult.Disconnected ->
|
||||
snackbarManager.showSnackbar(
|
||||
UiText.Resource(Res.string.connect_radio_for_remote_admin).resolve(),
|
||||
)
|
||||
EnsureSessionResult.Timeout ->
|
||||
snackbarManager.showSnackbar(UiText.Resource(Res.string.remote_admin_unreachable).resolve())
|
||||
}
|
||||
} finally {
|
||||
isEnsuringSession.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect.
|
||||
*/
|
||||
fun refreshMetadata(destNum: Int) = onServiceAction(ServiceAction.GetDeviceMetadata(destNum))
|
||||
|
||||
fun setNodeNotes(nodeNum: Int, notes: String) {
|
||||
nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ sealed interface NodeDetailAction {
|
||||
|
||||
data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction
|
||||
|
||||
/** Open the remote-administration screen, ensuring a fresh session passkey first. */
|
||||
data class OpenRemoteAdmin(val nodeNum: Int) : NodeDetailAction
|
||||
|
||||
/** Force-refresh device metadata (firmware version, edition, role) for the given node. */
|
||||
data class RefreshMetadata(val nodeNum: Int) : NodeDetailAction
|
||||
|
||||
data object ShareContact : NodeDetailAction
|
||||
|
||||
// Opens the compass sheet scoped to a target node and the user’s preferred units.
|
||||
|
||||
@@ -25,12 +25,17 @@ import dev.mokkery.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
|
||||
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.SessionStatus
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
@@ -48,11 +53,15 @@ class HandleNodeActionTest {
|
||||
private val nodeRequestActions: NodeRequestActions = mock()
|
||||
private val serviceRepository: ServiceRepository = mock()
|
||||
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
|
||||
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock()
|
||||
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
|
||||
private val snackbarManager: SnackbarManager = mock()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
|
||||
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
@@ -86,5 +95,8 @@ class HandleNodeActionTest {
|
||||
nodeRequestActions = nodeRequestActions,
|
||||
serviceRepository = serviceRepository,
|
||||
getNodeDetailsUseCase = getNodeDetailsUseCase,
|
||||
ensureRemoteAdminSession = ensureRemoteAdminSession,
|
||||
observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus,
|
||||
snackbarManager = snackbarManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,12 +27,17 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
|
||||
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.SessionStatus
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
import org.meshtastic.proto.User
|
||||
@@ -51,12 +56,16 @@ class NodeDetailViewModelTest {
|
||||
private val nodeRequestActions: NodeRequestActions = mock()
|
||||
private val serviceRepository: ServiceRepository = mock()
|
||||
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
|
||||
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock()
|
||||
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
|
||||
private val snackbarManager: SnackbarManager = mock()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
|
||||
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
|
||||
|
||||
viewModel = createViewModel(1234)
|
||||
}
|
||||
@@ -67,6 +76,9 @@ class NodeDetailViewModelTest {
|
||||
nodeRequestActions = nodeRequestActions,
|
||||
serviceRepository = serviceRepository,
|
||||
getNodeDetailsUseCase = getNodeDetailsUseCase,
|
||||
ensureRemoteAdminSession = ensureRemoteAdminSession,
|
||||
observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus,
|
||||
snackbarManager = snackbarManager,
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
|
||||
Reference in New Issue
Block a user