fix: address 8 critical issues from PR review with TDD

Critical fixes:
- Fix memory leak: RSSI polling now stopped in onCleared()
- Fix silent BLE scan errors: user-friendly error messages added
- Fix double-check locking bug in Python RNode initialization
- Fix interface registration order: start() before Transport register
- Fix race condition: use threading.Event for read loop control
- Fix write retry: implement exponential backoff (0.3s, 1s, 3s)
- Fix BLE write latch race: null check prevents stale callbacks
- Fix MTU request hang: 2-second timeout falls back to discoverServices

Tests added:
- RSSI polling cancellation test
- BLE scan error handling test
- Thread safety tests for read loop
- Write retry exponential backoff tests

Also includes ktlint format auto-fixes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
torlando-tech
2025-12-09 13:07:10 -05:00
parent d8300627a9
commit 166bb16300
31 changed files with 1818 additions and 1197 deletions

View File

@@ -204,7 +204,6 @@ abstract class InterfaceDatabase : RoomDatabase() {
2,
),
)
}
/**

View File

@@ -159,27 +159,29 @@ class InterfaceRepository
when (entity.type) {
"AutoInterface" -> {
// Ports are optional - null means use RNS defaults
val discoveryPort = if (json.has("discovery_port")) {
val port = json.getInt("discovery_port")
if (port !in 1..65535) {
Log.e(TAG, "Invalid discovery port in database: $port")
error("Invalid discovery port: $port")
val discoveryPort =
if (json.has("discovery_port")) {
val port = json.getInt("discovery_port")
if (port !in 1..65535) {
Log.e(TAG, "Invalid discovery port in database: $port")
error("Invalid discovery port: $port")
}
port
} else {
null
}
port
} else {
null
}
val dataPort = if (json.has("data_port")) {
val port = json.getInt("data_port")
if (port !in 1..65535) {
Log.e(TAG, "Invalid data port in database: $port")
error("Invalid data port: $port")
val dataPort =
if (json.has("data_port")) {
val port = json.getInt("data_port")
if (port !in 1..65535) {
Log.e(TAG, "Invalid data port in database: $port")
error("Invalid data port: $port")
}
port
} else {
null
}
port
} else {
null
}
InterfaceConfig.AutoInterface(
name = entity.name,

View File

@@ -25,6 +25,7 @@ import com.lxmf.messenger.reticulum.model.PacketType
import com.lxmf.messenger.reticulum.model.ReceivedPacket
import com.lxmf.messenger.reticulum.model.ReticulumConfig
import com.lxmf.messenger.service.ReticulumService
import com.lxmf.messenger.service.manager.parseIdentityResultJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -42,7 +43,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import com.lxmf.messenger.service.manager.parseIdentityResultJson
import org.json.JSONObject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

View File

@@ -38,9 +38,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.lxmf.messenger.data.repository.Announce
import com.lxmf.messenger.ui.theme.MeshConnected
import com.lxmf.messenger.util.formatTimeSince
import com.lxmf.messenger.ui.theme.MeshLimited
import com.lxmf.messenger.ui.theme.MeshOffline
import com.lxmf.messenger.util.formatTimeSince
/**
* Shared peer card component used by both AnnounceStreamScreen and SavedPeersScreen.
@@ -168,11 +168,12 @@ fun PeerCard(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (announce.isFavorite || !showFavoriteToggle) {
Icons.Default.Star
} else {
Icons.Default.StarBorder
},
imageVector =
if (announce.isFavorite || !showFavoriteToggle) {
Icons.Default.Star
} else {
Icons.Default.StarBorder
},
contentDescription = if (announce.isFavorite) "Remove from saved" else "Save peer",
tint =
if (announce.isFavorite || !showFavoriteToggle) {
@@ -297,4 +298,3 @@ fun OtherBadge() {
fun formatHashString(hashString: String): String {
return hashString.take(16)
}

View File

@@ -25,8 +25,8 @@ import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.AlertDialog

View File

@@ -1,10 +1,6 @@
package com.lxmf.messenger.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -18,16 +14,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -35,8 +26,6 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
@@ -47,14 +36,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -67,32 +51,19 @@ import androidx.compose.runtime.rememberCoroutineScope
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.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.lxmf.messenger.data.repository.Announce
import com.lxmf.messenger.reticulum.model.NodeType
import com.lxmf.messenger.ui.components.AudioBadge
import com.lxmf.messenger.ui.components.Identicon
import com.lxmf.messenger.ui.components.NodeTypeBadge
import com.lxmf.messenger.ui.components.OtherBadge
import com.lxmf.messenger.ui.components.PeerCard
import com.lxmf.messenger.ui.components.SearchableTopAppBar
import com.lxmf.messenger.ui.components.SignalStrengthIndicator
import com.lxmf.messenger.ui.components.formatHashString
import com.lxmf.messenger.ui.theme.MeshConnected
import com.lxmf.messenger.util.formatTimeSince
import com.lxmf.messenger.ui.theme.MeshLimited
import com.lxmf.messenger.ui.theme.MeshOffline
import com.lxmf.messenger.viewmodel.AnnounceStreamViewModel
import kotlinx.coroutines.launch
@@ -527,7 +498,6 @@ fun PeerContextMenu(
}
}
@Composable
fun EmptyAnnounceState(modifier: Modifier = Modifier) {
Column(

View File

@@ -23,12 +23,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MarkEmailUnread
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.AlertDialog
@@ -41,18 +38,13 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

View File

@@ -702,30 +702,204 @@ fun BleConnectionsCard(
if (isSharedInstance) {
Text(
text = "BLE connections are not available while using a shared Reticulum instance. " +
"Only Columba's own instance can initiate Bluetooth LE connections.",
text =
"BLE connections are not available while using a shared Reticulum instance. " +
"Only Columba's own instance can initiate Bluetooth LE connections.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else when (uiState) {
is BleConnectionsUiState.Loading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
} else {
when (uiState) {
is BleConnectionsUiState.Loading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Loading connections...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is BleConnectionsUiState.Success -> {
if (uiState.totalConnections == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Bluetooth is turned on",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
text = "No active BLE connections",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = onOpenBluetoothSettings,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("Bluetooth Settings")
}
}
} else {
// Summary stats
Row(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(8.dp),
)
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = uiState.totalConnections.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Total",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = uiState.centralConnections.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Central",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = uiState.peripheralConnections.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Peripheral",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Signal quality indicator
val avgSignalQuality =
if (uiState.connections.isNotEmpty()) {
val avgRssi = uiState.connections.map { it.rssi }.average().toInt()
when {
avgRssi > -50 -> SignalQuality.EXCELLENT
avgRssi > -70 -> SignalQuality.GOOD
avgRssi > -85 -> SignalQuality.FAIR
else -> SignalQuality.POOR
}
} else {
SignalQuality.GOOD
}
val (signalText, signalColor) =
when (avgSignalQuality) {
SignalQuality.EXCELLENT -> "Excellent Signal" to MaterialTheme.colorScheme.primary
SignalQuality.GOOD -> "Good Signal" to MaterialTheme.colorScheme.primary
SignalQuality.FAIR -> "Fair Signal" to MaterialTheme.colorScheme.tertiary
SignalQuality.POOR -> "Poor Signal" to MaterialTheme.colorScheme.error
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = signalColor,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = signalText,
style = MaterialTheme.typography.bodySmall,
color = signalColor,
)
}
TextButton(onClick = onViewDetails) {
Text("View Details")
Icon(
imageVector = Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
}
}
}
is BleConnectionsUiState.Error -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Error: ${uiState.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
is BleConnectionsUiState.PermissionsRequired -> {
Text(
text = "Loading connections...",
text = "Bluetooth permissions required",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is BleConnectionsUiState.Success -> {
if (uiState.totalConnections == 0) {
is BleConnectionsUiState.BluetoothDisabled -> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -733,202 +907,31 @@ fun BleConnectionsCard(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Bluetooth,
imageVector = Icons.Default.BluetoothDisabled,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Bluetooth is turned on",
text = "Bluetooth is turned off",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "No active BLE connections",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = onOpenBluetoothSettings,
Button(
onClick = onEnableBluetooth,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Settings,
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("Bluetooth Settings")
Text("Turn ON")
}
}
} else {
// Summary stats
Row(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(8.dp),
)
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = uiState.totalConnections.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Total",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = uiState.centralConnections.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Central",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = uiState.peripheralConnections.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Peripheral",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Signal quality indicator
val avgSignalQuality =
if (uiState.connections.isNotEmpty()) {
val avgRssi = uiState.connections.map { it.rssi }.average().toInt()
when {
avgRssi > -50 -> SignalQuality.EXCELLENT
avgRssi > -70 -> SignalQuality.GOOD
avgRssi > -85 -> SignalQuality.FAIR
else -> SignalQuality.POOR
}
} else {
SignalQuality.GOOD
}
val (signalText, signalColor) =
when (avgSignalQuality) {
SignalQuality.EXCELLENT -> "Excellent Signal" to MaterialTheme.colorScheme.primary
SignalQuality.GOOD -> "Good Signal" to MaterialTheme.colorScheme.primary
SignalQuality.FAIR -> "Fair Signal" to MaterialTheme.colorScheme.tertiary
SignalQuality.POOR -> "Poor Signal" to MaterialTheme.colorScheme.error
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = signalColor,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = signalText,
style = MaterialTheme.typography.bodySmall,
color = signalColor,
)
}
TextButton(onClick = onViewDetails) {
Text("View Details")
Icon(
imageVector = Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
}
}
}
is BleConnectionsUiState.Error -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Error: ${uiState.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
is BleConnectionsUiState.PermissionsRequired -> {
Text(
text = "Bluetooth permissions required",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
is BleConnectionsUiState.BluetoothDisabled -> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.BluetoothDisabled,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Bluetooth is turned off",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(
onClick = onEnableBluetooth,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("Turn ON")
}
}
}
}
@@ -1187,8 +1190,9 @@ private fun ServiceControlCard(
if (isSharedInstance) {
Text(
text = "Service control is disabled while using a shared Reticulum instance. " +
"The network service is managed by another app (e.g., Sideband).",
text =
"Service control is disabled while using a shared Reticulum instance. " +
"The network service is managed by another app (e.g., Sideband).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View File

@@ -540,10 +540,11 @@ fun InterfaceCard(
}
// Reconnect button for offline RNode interfaces
val showReconnect = interfaceEntity.type == "RNode" &&
interfaceEntity.enabled &&
isOnline == false &&
onReconnect != null
val showReconnect =
interfaceEntity.type == "RNode" &&
interfaceEntity.enabled &&
isOnline == false &&
onReconnect != null
if (showReconnect) {
TextButton(
onClick = onReconnect,

View File

@@ -1,6 +1,5 @@
package com.lxmf.messenger.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -8,27 +7,18 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -37,7 +27,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.lxmf.messenger.data.repository.Announce

View File

@@ -67,11 +67,12 @@ fun SettingsScreen(
// Show Snackbar when shared instance becomes available (ephemeral notification)
LaunchedEffect(state.sharedInstanceAvailable) {
if (state.sharedInstanceAvailable && !state.preferOwnInstance) {
val result = snackbarHostState.showSnackbar(
message = "Shared instance available",
actionLabel = "Switch",
duration = SnackbarDuration.Indefinite,
)
val result =
snackbarHostState.showSnackbar(
message = "Shared instance available",
actionLabel = "Switch",
duration = SnackbarDuration.Indefinite,
)
when (result) {
SnackbarResult.ActionPerformed -> viewModel.switchToSharedInstance()
SnackbarResult.Dismissed -> viewModel.dismissSharedInstanceAvailable()
@@ -114,10 +115,11 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Show shared instance banner when relevant to the user
val showSharedInstanceBanner = state.isSharedInstance ||
state.sharedInstanceAvailable ||
state.sharedInstanceLost ||
state.isRestarting
val showSharedInstanceBanner =
state.isSharedInstance ||
state.sharedInstanceAvailable ||
state.sharedInstanceLost ||
state.isRestarting
if (showSharedInstanceBanner) {
SharedInstanceBannerCard(
isExpanded = state.isSharedInstanceBannerExpanded,

View File

@@ -63,16 +63,18 @@ fun SharedInstanceBannerCard(
onDismissLostWarning: () -> Unit = {},
) {
// Use error color when shared instance is lost, primary otherwise
val containerColor = if (sharedInstanceLost) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primaryContainer
}
val contentColor = if (sharedInstanceLost) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onPrimaryContainer
}
val containerColor =
if (sharedInstanceLost) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primaryContainer
}
val contentColor =
if (sharedInstanceLost) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onPrimaryContainer
}
Card(
modifier =
@@ -104,20 +106,22 @@ fun SharedInstanceBannerCard(
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = if (sharedInstanceLost) {
Icons.Default.LinkOff
} else {
Icons.Default.Link
},
imageVector =
if (sharedInstanceLost) {
Icons.Default.LinkOff
} else {
Icons.Default.Link
},
contentDescription = "Instance Mode",
tint = contentColor,
)
Text(
text = when {
sharedInstanceLost -> "Shared Instance Disconnected"
isUsingSharedInstance -> "Connected to Shared Instance"
else -> "Using Columba's Own Instance"
},
text =
when {
sharedInstanceLost -> "Shared Instance Disconnected"
isUsingSharedInstance -> "Connected to Shared Instance"
else -> "Using Columba's Own Instance"
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
@@ -157,16 +161,18 @@ fun SharedInstanceBannerCard(
tint = contentColor,
)
Text(
text = "The shared Reticulum instance (e.g., Sideband) appears " +
"to be offline. Columba cannot send or receive messages.",
text =
"The shared Reticulum instance (e.g., Sideband) appears " +
"to be offline. Columba cannot send or receive messages.",
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
)
}
Text(
text = "You can switch to Columba's own instance to restore " +
"connectivity, or wait for the shared instance to come back online.",
text =
"You can switch to Columba's own instance to restore " +
"connectivity, or wait for the shared instance to come back online.",
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
)
@@ -192,13 +198,14 @@ fun SharedInstanceBannerCard(
} else {
// Normal state
Text(
text = if (isUsingSharedInstance) {
"Another app (e.g., Sideband) is managing the Reticulum network " +
"on this device. Columba is using that connection."
} else {
"Columba is running its own Reticulum instance. Toggle off to use " +
"a shared instance if available."
},
text =
if (isUsingSharedInstance) {
"Another app (e.g., Sideband) is managing the Reticulum network " +
"on this device. Columba is using that connection."
} else {
"Columba is running its own Reticulum instance. Toggle off to use " +
"a shared instance if available."
},
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
)
@@ -219,8 +226,9 @@ fun SharedInstanceBannerCard(
color = contentColor,
)
Text(
text = "• BLE connections to other Columba users require " +
"Columba's own instance",
text =
"• BLE connections to other Columba users require " +
"Columba's own instance",
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
)
@@ -240,11 +248,12 @@ fun SharedInstanceBannerCard(
Text(
text = "Use Columba's own instance",
style = MaterialTheme.typography.bodyMedium,
color = if (toggleEnabled) {
contentColor
} else {
contentColor.copy(alpha = 0.5f)
},
color =
if (toggleEnabled) {
contentColor
} else {
contentColor.copy(alpha = 0.5f)
},
)
Switch(
checked = preferOwnInstance,

View File

@@ -214,9 +214,10 @@ object RNodeConfigValidator {
stAlock: String,
ltAlock: String,
region: FrequencyRegion?,
): Boolean = validateConfigSilent(
RNodeConfigInput(name, frequency, bandwidth, spreadingFactor, codingRate, txPower, stAlock, ltAlock, region),
)
): Boolean =
validateConfigSilent(
RNodeConfigInput(name, frequency, bandwidth, spreadingFactor, codingRate, txPower, stAlock, ltAlock, region),
)
/**
* Validate the full configuration with error messages.
@@ -313,9 +314,10 @@ object RNodeConfigValidator {
stAlock: String,
ltAlock: String,
region: FrequencyRegion?,
): ConfigValidationResult = validateConfig(
RNodeConfigInput(name, frequency, bandwidth, spreadingFactor, codingRate, txPower, stAlock, ltAlock, region),
)
): ConfigValidationResult =
validateConfig(
RNodeConfigInput(name, frequency, bandwidth, spreadingFactor, codingRate, txPower, stAlock, ltAlock, region),
)
/**
* Get the maximum TX power for a region.

View File

@@ -805,6 +805,17 @@ class RNodeWizardViewModel
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode")
val errorMessage =
when (errorCode) {
1 -> "BLE scan failed: already started"
2 -> "BLE scan failed: app not registered"
3 -> "BLE scan failed: internal error"
4 -> "BLE scan failed: feature unsupported"
5 -> "BLE scan failed: out of hardware resources"
6 -> "BLE scan failed: scanning too frequently"
else -> "BLE scan failed: error code $errorCode"
}
setScanError(errorMessage)
}
}
@@ -814,6 +825,19 @@ class RNodeWizardViewModel
scanner.stopScan(callback)
} catch (e: SecurityException) {
Log.e(TAG, "BLE scan permission denied", e)
setScanError("Bluetooth permission required. Please grant Bluetooth permissions in Settings.")
}
}
/**
* Set a scan error message and stop scanning.
*/
private fun setScanError(message: String) {
_state.update {
it.copy(
scanError = message,
isScanning = false,
)
}
}
@@ -1495,4 +1519,14 @@ class RNodeWizardViewModel
val state = _state.value
return state.selectedDevice?.type ?: state.manualBluetoothType
}
/**
* Cleanup when ViewModel is cleared.
* Cancels any ongoing polling jobs to prevent memory leaks.
*/
override fun onCleared() {
super.onCleared()
stopRssiPolling()
Log.d(TAG, "RNodeWizardViewModel cleared, RSSI polling stopped")
}
}

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lxmf.messenger.data.repository.IdentityRepository
import com.lxmf.messenger.repository.SettingsRepository
import com.lxmf.messenger.reticulum.model.NetworkStatus
import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
import com.lxmf.messenger.ui.theme.AppTheme
import com.lxmf.messenger.ui.theme.PresetTheme
@@ -574,22 +573,23 @@ class SettingsViewModel
val rpcKeyPattern = Regex("""rpc_key\s*=\s*([a-fA-F0-9]+)""")
val match = rpcKeyPattern.find(input)
val result = when {
match != null -> {
val key = match.groupValues[1]
Log.d(TAG, "Parsed RPC key from config format (${key.length} chars)")
key
val result =
when {
match != null -> {
val key = match.groupValues[1]
Log.d(TAG, "Parsed RPC key from config format (${key.length} chars)")
key
}
input.trim().matches(Regex("^[a-fA-F0-9]+$")) -> {
val trimmed = input.trim()
Log.d(TAG, "Using raw hex RPC key (${trimmed.length} chars)")
trimmed
}
else -> {
Log.w(TAG, "Invalid RPC key format, ignoring")
null
}
}
input.trim().matches(Regex("^[a-fA-F0-9]+$")) -> {
val trimmed = input.trim()
Log.d(TAG, "Using raw hex RPC key (${trimmed.length} chars)")
trimmed
}
else -> {
Log.w(TAG, "Invalid RPC key format, ignoring")
null
}
}
return result
}
@@ -600,58 +600,59 @@ class SettingsViewModel
*/
private fun startSharedInstanceMonitor() {
sharedInstanceMonitorJob?.cancel()
sharedInstanceMonitorJob = viewModelScope.launch {
// Wait for initial setup
delay(INIT_DELAY_MS * 2)
sharedInstanceMonitorJob =
viewModelScope.launch {
// Wait for initial setup
delay(INIT_DELAY_MS * 2)
while (true) {
delay(SHARED_INSTANCE_MONITOR_INTERVAL_MS)
while (true) {
delay(SHARED_INSTANCE_MONITOR_INTERVAL_MS)
val currentState = _state.value
val currentState = _state.value
// Only monitor when we're using a shared instance
if (currentState.isSharedInstance && !currentState.preferOwnInstance) {
// Probe the shared instance port directly - more reliable than networkStatus
// because Python doesn't actively detect connection loss
val isPortOpen = probeSharedInstancePort()
// Only monitor when we're using a shared instance
if (currentState.isSharedInstance && !currentState.preferOwnInstance) {
// Probe the shared instance port directly - more reliable than networkStatus
// because Python doesn't actively detect connection loss
val isPortOpen = probeSharedInstancePort()
if (!isPortOpen) {
val now = System.currentTimeMillis()
if (sharedInstanceDisconnectedTime == null) {
sharedInstanceDisconnectedTime = now
Log.d(TAG, "Shared instance port closed, starting timer...")
if (!isPortOpen) {
val now = System.currentTimeMillis()
if (sharedInstanceDisconnectedTime == null) {
sharedInstanceDisconnectedTime = now
Log.d(TAG, "Shared instance port closed, starting timer...")
} else {
val disconnectedDuration = now - sharedInstanceDisconnectedTime!!
if (disconnectedDuration >= SHARED_INSTANCE_LOST_THRESHOLD_MS &&
!currentState.sharedInstanceLost
) {
Log.w(
TAG,
"Shared instance lost for ${disconnectedDuration / 1000}s, " +
"notifying user",
)
_state.value = currentState.copy(sharedInstanceLost = true)
}
}
} else {
val disconnectedDuration = now - sharedInstanceDisconnectedTime!!
if (disconnectedDuration >= SHARED_INSTANCE_LOST_THRESHOLD_MS &&
!currentState.sharedInstanceLost
) {
Log.w(
TAG,
"Shared instance lost for ${disconnectedDuration / 1000}s, " +
"notifying user",
)
_state.value = currentState.copy(sharedInstanceLost = true)
// Connection restored
if (sharedInstanceDisconnectedTime != null) {
Log.d(TAG, "Shared instance port open again")
sharedInstanceDisconnectedTime = null
if (currentState.sharedInstanceLost) {
_state.value = currentState.copy(sharedInstanceLost = false)
}
}
}
} else {
// Connection restored
if (sharedInstanceDisconnectedTime != null) {
Log.d(TAG, "Shared instance port open again")
sharedInstanceDisconnectedTime = null
if (currentState.sharedInstanceLost) {
_state.value = currentState.copy(sharedInstanceLost = false)
}
// Not in shared instance mode, reset tracking
sharedInstanceDisconnectedTime = null
if (currentState.sharedInstanceLost) {
_state.value = currentState.copy(sharedInstanceLost = false)
}
}
} else {
// Not in shared instance mode, reset tracking
sharedInstanceDisconnectedTime = null
if (currentState.sharedInstanceLost) {
_state.value = currentState.copy(sharedInstanceLost = false)
}
}
}
}
}
/**
@@ -685,37 +686,38 @@ class SettingsViewModel
*/
private fun startSharedInstanceAvailabilityMonitor() {
sharedInstanceAvailabilityJob?.cancel()
sharedInstanceAvailabilityJob = viewModelScope.launch {
// Wait for initial setup
delay(INIT_DELAY_MS * 4) // Give more time for service to fully start
sharedInstanceAvailabilityJob =
viewModelScope.launch {
// Wait for initial setup
delay(INIT_DELAY_MS * 4) // Give more time for service to fully start
while (true) {
val currentState = _state.value
while (true) {
val currentState = _state.value
// Probe when running our own instance (regardless of preference)
// This allows the toggle to know if switching to shared is possible
if (!currentState.isSharedInstance && !currentState.isRestarting) {
val isAvailable = probeSharedInstancePort()
// Probe when running our own instance (regardless of preference)
// This allows the toggle to know if switching to shared is possible
if (!currentState.isSharedInstance && !currentState.isRestarting) {
val isAvailable = probeSharedInstancePort()
if (isAvailable && !currentState.sharedInstanceAvailable) {
Log.i(TAG, "Shared instance detected on port $SHARED_INSTANCE_PORT")
_state.value = currentState.copy(sharedInstanceAvailable = true)
} else if (!isAvailable && currentState.sharedInstanceAvailable) {
// Shared instance went away
Log.d(TAG, "Shared instance no longer available")
_state.value = currentState.copy(sharedInstanceAvailable = false)
}
} else if (currentState.isSharedInstance) {
// Already using shared instance, reset availability flag
if (currentState.sharedInstanceAvailable) {
_state.value = currentState.copy(sharedInstanceAvailable = false)
if (isAvailable && !currentState.sharedInstanceAvailable) {
Log.i(TAG, "Shared instance detected on port $SHARED_INSTANCE_PORT")
_state.value = currentState.copy(sharedInstanceAvailable = true)
} else if (!isAvailable && currentState.sharedInstanceAvailable) {
// Shared instance went away
Log.d(TAG, "Shared instance no longer available")
_state.value = currentState.copy(sharedInstanceAvailable = false)
}
} else if (currentState.isSharedInstance) {
// Already using shared instance, reset availability flag
if (currentState.sharedInstanceAvailable) {
_state.value = currentState.copy(sharedInstanceAvailable = false)
}
}
// Wait before next poll
delay(SHARED_INSTANCE_MONITOR_INTERVAL_MS)
}
// Wait before next poll
delay(SHARED_INSTANCE_MONITOR_INTERVAL_MS)
}
}
}
/**
@@ -732,7 +734,9 @@ class SettingsViewModel
)
true
}
} catch (@Suppress("SwallowedException") e: Exception) {
} catch (
@Suppress("SwallowedException") e: Exception,
) {
// Connection refused, timeout, etc. = no shared instance
// Not logging to avoid spamming logs during polling
false

View File

@@ -79,9 +79,10 @@ class InterfaceRepositoryTest {
name = name,
type = "RNode",
enabled = enabled,
configJson = """{"target_device_name":"RNode-BT","connection_mode":"ble",""" +
""""frequency":915000000,"bandwidth":125000,"tx_power":7,""" +
""""spreading_factor":7,"coding_rate":5,"mode":"full","enable_framebuffer":true}""",
configJson =
"""{"target_device_name":"RNode-BT","connection_mode":"ble",""" +
""""frequency":915000000,"bandwidth":125000,"tx_power":7,""" +
""""spreading_factor":7,"coding_rate":5,"mode":"full","enable_framebuffer":true}""",
displayOrder = 2,
)
@@ -101,376 +102,403 @@ class InterfaceRepositoryTest {
// ========== Corruption Handling Tests ==========
@Test
fun `enabledInterfaces skips RNode with missing target_device_name field`() = runTest {
// Given: One valid interface and one RNode with missing target_device_name
val corruptedRNode = InterfaceEntity(
id = 1,
name = "Corrupted RNode",
type = "RNode",
enabled = true,
configJson = """{"frequency":915000000,"bandwidth":125000}""", // Missing "target_device_name"
displayOrder = 0,
)
val validAuto = createValidAutoInterfaceEntity(id = 2)
fun `enabledInterfaces skips RNode with missing target_device_name field`() =
runTest {
// Given: One valid interface and one RNode with missing target_device_name
val corruptedRNode =
InterfaceEntity(
id = 1,
name = "Corrupted RNode",
type = "RNode",
enabled = true,
configJson = """{"frequency":915000000,"bandwidth":125000}""", // Missing "target_device_name"
displayOrder = 0,
)
val validAuto = createValidAutoInterfaceEntity(id = 2)
// Set up mocks and create repository
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(corruptedRNode, validAuto))
val repository = InterfaceRepository(mockDao)
// Set up mocks and create repository
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(corruptedRNode, validAuto))
val repository = InterfaceRepository(mockDao)
// When: Collecting enabled interfaces
repository.enabledInterfaces.test {
val interfaces = awaitItem()
// When: Collecting enabled interfaces
repository.enabledInterfaces.test {
val interfaces = awaitItem()
// Then: Only the valid interface is returned
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AutoInterface)
assertEquals("Auto Discovery", interfaces[0].name)
// Then: Only the valid interface is returned
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AutoInterface)
assertEquals("Auto Discovery", interfaces[0].name)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces skips RNode with empty target_device_name field`() = runTest {
val corruptedRNode = InterfaceEntity(
id = 1,
name = "Empty Device Name RNode",
type = "RNode",
enabled = true,
configJson = """{"target_device_name":"","frequency":915000000}""", // Empty target_device_name
displayOrder = 0,
)
val validTcp = createValidTcpClientEntity(id = 2)
fun `enabledInterfaces skips RNode with empty target_device_name field`() =
runTest {
val corruptedRNode =
InterfaceEntity(
id = 1,
name = "Empty Device Name RNode",
type = "RNode",
enabled = true,
configJson = """{"target_device_name":"","frequency":915000000}""", // Empty target_device_name
displayOrder = 0,
)
val validTcp = createValidTcpClientEntity(id = 2)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(corruptedRNode, validTcp))
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(corruptedRNode, validTcp))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.TCPClient)
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.TCPClient)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces skips interface with invalid JSON`() = runTest {
val invalidJson = InterfaceEntity(
id = 1,
name = "Invalid JSON",
type = "AutoInterface",
enabled = true,
configJson = "not valid json {{{",
displayOrder = 0,
)
val validBle = createValidAndroidBleEntity(id = 2)
fun `enabledInterfaces skips interface with invalid JSON`() =
runTest {
val invalidJson =
InterfaceEntity(
id = 1,
name = "Invalid JSON",
type = "AutoInterface",
enabled = true,
configJson = "not valid json {{{",
displayOrder = 0,
)
val validBle = createValidAndroidBleEntity(id = 2)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(invalidJson, validBle))
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(invalidJson, validBle))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AndroidBLE)
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AndroidBLE)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces skips interface with unknown type`() = runTest {
val unknownType = InterfaceEntity(
id = 1,
name = "Unknown Interface",
type = "FutureInterface",
enabled = true,
configJson = """{"some":"config"}""",
displayOrder = 0,
)
val validRNode = createValidRNodeEntity(id = 2)
fun `enabledInterfaces skips interface with unknown type`() =
runTest {
val unknownType =
InterfaceEntity(
id = 1,
name = "Unknown Interface",
type = "FutureInterface",
enabled = true,
configJson = """{"some":"config"}""",
displayOrder = 0,
)
val validRNode = createValidRNodeEntity(id = 2)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(unknownType, validRNode))
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(unknownType, validRNode))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.RNode)
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.RNode)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces skips TCPClient with invalid port`() = runTest {
val invalidPort = InterfaceEntity(
id = 1,
name = "Invalid Port TCP",
type = "TCPClient",
enabled = true,
configJson = """{"target_host":"10.0.0.1","target_port":99999}""", // Port > 65535
displayOrder = 0,
)
val validAuto = createValidAutoInterfaceEntity(id = 2)
fun `enabledInterfaces skips TCPClient with invalid port`() =
runTest {
val invalidPort =
InterfaceEntity(
id = 1,
name = "Invalid Port TCP",
type = "TCPClient",
enabled = true,
configJson = """{"target_host":"10.0.0.1","target_port":99999}""", // Port > 65535
displayOrder = 0,
)
val validAuto = createValidAutoInterfaceEntity(id = 2)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(invalidPort, validAuto))
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(invalidPort, validAuto))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AutoInterface)
assertEquals(1, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AutoInterface)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `allInterfaces skips corrupted interfaces`() = runTest {
val corrupted = InterfaceEntity(
id = 1,
name = "Corrupted",
type = "RNode",
enabled = true,
configJson = """{"no_device":"here"}""",
displayOrder = 0,
)
val valid = createValidAutoInterfaceEntity(id = 2)
fun `allInterfaces skips corrupted interfaces`() =
runTest {
val corrupted =
InterfaceEntity(
id = 1,
name = "Corrupted",
type = "RNode",
enabled = true,
configJson = """{"no_device":"here"}""",
displayOrder = 0,
)
val valid = createValidAutoInterfaceEntity(id = 2)
every { mockDao.getAllInterfaces() } returns flowOf(listOf(corrupted, valid))
every { mockDao.getEnabledInterfaces() } returns flowOf(emptyList())
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(listOf(corrupted, valid))
every { mockDao.getEnabledInterfaces() } returns flowOf(emptyList())
val repository = InterfaceRepository(mockDao)
repository.allInterfaces.test {
val interfaces = awaitItem()
repository.allInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
assertEquals("Auto Discovery", interfaces[0].name)
assertEquals(1, interfaces.size)
assertEquals("Auto Discovery", interfaces[0].name)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `allInterfaces returns empty list when all interfaces are corrupted`() = runTest {
val corrupted1 = InterfaceEntity(
id = 1,
name = "Corrupted1",
type = "RNode",
enabled = true,
configJson = """{}""", // Missing target_device_name
displayOrder = 0,
)
val corrupted2 = InterfaceEntity(
id = 2,
name = "Corrupted2",
type = "UnknownType",
enabled = true,
configJson = """{"data":"test"}""",
displayOrder = 1,
)
fun `allInterfaces returns empty list when all interfaces are corrupted`() =
runTest {
val corrupted1 =
InterfaceEntity(
id = 1,
name = "Corrupted1",
type = "RNode",
enabled = true,
configJson = """{}""", // Missing target_device_name
displayOrder = 0,
)
val corrupted2 =
InterfaceEntity(
id = 2,
name = "Corrupted2",
type = "UnknownType",
enabled = true,
configJson = """{"data":"test"}""",
displayOrder = 1,
)
every { mockDao.getAllInterfaces() } returns flowOf(listOf(corrupted1, corrupted2))
every { mockDao.getEnabledInterfaces() } returns flowOf(emptyList())
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(listOf(corrupted1, corrupted2))
every { mockDao.getEnabledInterfaces() } returns flowOf(emptyList())
val repository = InterfaceRepository(mockDao)
repository.allInterfaces.test {
val interfaces = awaitItem()
repository.allInterfaces.test {
val interfaces = awaitItem()
assertTrue(interfaces.isEmpty())
assertTrue(interfaces.isEmpty())
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
// ========== Valid Interface Conversion Tests ==========
@Test
fun `enabledInterfaces correctly converts valid AutoInterface`() = runTest {
val entity = createValidAutoInterfaceEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
fun `enabledInterfaces correctly converts valid AutoInterface`() =
runTest {
val entity = createValidAutoInterfaceEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AutoInterface
assertEquals("Auto Discovery", config.name)
assertEquals("default", config.groupId)
assertEquals("link", config.discoveryScope)
assertNull(config.discoveryPort)
assertNull(config.dataPort)
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AutoInterface
assertEquals("Auto Discovery", config.name)
assertEquals("default", config.groupId)
assertEquals("link", config.discoveryScope)
assertNull(config.discoveryPort)
assertNull(config.dataPort)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `entityToConfig parses AutoInterface with only discovery_port`() = runTest {
val entity = InterfaceEntity(
id = 1,
name = "Auto With Discovery Port",
type = "AutoInterface",
enabled = true,
configJson = """{"group_id":"","discovery_scope":"link","discovery_port":29716,"mode":"full"}""",
displayOrder = 0,
)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
fun `entityToConfig parses AutoInterface with only discovery_port`() =
runTest {
val entity =
InterfaceEntity(
id = 1,
name = "Auto With Discovery Port",
type = "AutoInterface",
enabled = true,
configJson = """{"group_id":"","discovery_scope":"link","discovery_port":29716,"mode":"full"}""",
displayOrder = 0,
)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AutoInterface
assertEquals(29716, config.discoveryPort)
assertNull(config.dataPort)
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AutoInterface
assertEquals(29716, config.discoveryPort)
assertNull(config.dataPort)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `entityToConfig parses AutoInterface with only data_port`() = runTest {
val entity = InterfaceEntity(
id = 1,
name = "Auto With Data Port",
type = "AutoInterface",
enabled = true,
configJson = """{"group_id":"","discovery_scope":"link","data_port":42671,"mode":"full"}""",
displayOrder = 0,
)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
fun `entityToConfig parses AutoInterface with only data_port`() =
runTest {
val entity =
InterfaceEntity(
id = 1,
name = "Auto With Data Port",
type = "AutoInterface",
enabled = true,
configJson = """{"group_id":"","discovery_scope":"link","data_port":42671,"mode":"full"}""",
displayOrder = 0,
)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AutoInterface
assertNull(config.discoveryPort)
assertEquals(42671, config.dataPort)
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AutoInterface
assertNull(config.discoveryPort)
assertEquals(42671, config.dataPort)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces correctly converts valid TCPClient`() = runTest {
val entity = createValidTcpClientEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
fun `enabledInterfaces correctly converts valid TCPClient`() =
runTest {
val entity = createValidTcpClientEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.TCPClient
assertEquals("TCP Server", config.name)
assertEquals("10.0.0.1", config.targetHost)
assertEquals(4242, config.targetPort)
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.TCPClient
assertEquals("TCP Server", config.name)
assertEquals("10.0.0.1", config.targetHost)
assertEquals(4242, config.targetPort)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces correctly converts valid RNode`() = runTest {
val entity = createValidRNodeEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
fun `enabledInterfaces correctly converts valid RNode`() =
runTest {
val entity = createValidRNodeEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.RNode
assertEquals("RNode LoRa", config.name)
assertEquals("RNode-BT", config.targetDeviceName)
assertEquals(915000000L, config.frequency)
assertEquals(125000, config.bandwidth)
assertEquals(7, config.txPower)
assertEquals(7, config.spreadingFactor)
assertEquals(5, config.codingRate)
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.RNode
assertEquals("RNode LoRa", config.name)
assertEquals("RNode-BT", config.targetDeviceName)
assertEquals(915000000L, config.frequency)
assertEquals(125000, config.bandwidth)
assertEquals(7, config.txPower)
assertEquals(7, config.spreadingFactor)
assertEquals(5, config.codingRate)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces correctly converts valid AndroidBLE`() = runTest {
val entity = createValidAndroidBleEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
fun `enabledInterfaces correctly converts valid AndroidBLE`() =
runTest {
val entity = createValidAndroidBleEntity()
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(listOf(entity))
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AndroidBLE
assertEquals("Bluetooth LE", config.name)
assertEquals("MyDevice", config.deviceName)
assertEquals(7, config.maxConnections)
assertEquals(1, interfaces.size)
val config = interfaces[0] as InterfaceConfig.AndroidBLE
assertEquals("Bluetooth LE", config.name)
assertEquals("MyDevice", config.deviceName)
assertEquals(7, config.maxConnections)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
@Test
fun `enabledInterfaces handles mix of valid and corrupted interfaces`() = runTest {
val validAuto = createValidAutoInterfaceEntity(id = 1)
val corruptedRNode = InterfaceEntity(
id = 2,
name = "Bad RNode",
type = "RNode",
enabled = true,
configJson = """{"frequency":915000000}""", // Missing target_device_name
displayOrder = 1,
)
val validTcp = createValidTcpClientEntity(id = 3)
val invalidJson = InterfaceEntity(
id = 4,
name = "Bad JSON",
type = "AutoInterface",
enabled = true,
configJson = "{broken",
displayOrder = 2,
)
val validBle = createValidAndroidBleEntity(id = 5)
fun `enabledInterfaces handles mix of valid and corrupted interfaces`() =
runTest {
val validAuto = createValidAutoInterfaceEntity(id = 1)
val corruptedRNode =
InterfaceEntity(
id = 2,
name = "Bad RNode",
type = "RNode",
enabled = true,
configJson = """{"frequency":915000000}""", // Missing target_device_name
displayOrder = 1,
)
val validTcp = createValidTcpClientEntity(id = 3)
val invalidJson =
InterfaceEntity(
id = 4,
name = "Bad JSON",
type = "AutoInterface",
enabled = true,
configJson = "{broken",
displayOrder = 2,
)
val validBle = createValidAndroidBleEntity(id = 5)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns flowOf(
listOf(validAuto, corruptedRNode, validTcp, invalidJson, validBle),
)
val repository = InterfaceRepository(mockDao)
every { mockDao.getAllInterfaces() } returns flowOf(emptyList())
every { mockDao.getEnabledInterfaces() } returns
flowOf(
listOf(validAuto, corruptedRNode, validTcp, invalidJson, validBle),
)
val repository = InterfaceRepository(mockDao)
repository.enabledInterfaces.test {
val interfaces = awaitItem()
repository.enabledInterfaces.test {
val interfaces = awaitItem()
// Only 3 valid interfaces should be returned
assertEquals(3, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AutoInterface)
assertTrue(interfaces[1] is InterfaceConfig.TCPClient)
assertTrue(interfaces[2] is InterfaceConfig.AndroidBLE)
// Only 3 valid interfaces should be returned
assertEquals(3, interfaces.size)
assertTrue(interfaces[0] is InterfaceConfig.AutoInterface)
assertTrue(interfaces[1] is InterfaceConfig.TCPClient)
assertTrue(interfaces[2] is InterfaceConfig.AndroidBLE)
cancelAndIgnoreRemainingEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
}

View File

@@ -12,13 +12,14 @@ import org.junit.Test
class IdentityResultBuilderTest {
@Test
fun `buildIdentityResultJson includes all fields when present`() {
val result = buildIdentityResultJson(
identityHash = "abc123",
destinationHash = "def456",
filePath = "/path/to/identity",
keyDataBase64 = "dGVzdC1rZXktZGF0YQ==",
displayName = "Test User",
)
val result =
buildIdentityResultJson(
identityHash = "abc123",
destinationHash = "def456",
filePath = "/path/to/identity",
keyDataBase64 = "dGVzdC1rZXktZGF0YQ==",
displayName = "Test User",
)
val json = JSONObject(result)
assertEquals("abc123", json.getString("identity_hash"))
@@ -30,13 +31,14 @@ class IdentityResultBuilderTest {
@Test
fun `buildIdentityResultJson omits key_data when null`() {
val result = buildIdentityResultJson(
identityHash = "abc123",
destinationHash = "def456",
filePath = "/path/to/identity",
keyDataBase64 = null,
displayName = "Test User",
)
val result =
buildIdentityResultJson(
identityHash = "abc123",
destinationHash = "def456",
filePath = "/path/to/identity",
keyDataBase64 = null,
displayName = "Test User",
)
val json = JSONObject(result)
assertEquals("abc123", json.getString("identity_hash"))
@@ -46,13 +48,14 @@ class IdentityResultBuilderTest {
@Test
fun `buildIdentityResultJson handles null string fields`() {
val result = buildIdentityResultJson(
identityHash = null,
destinationHash = null,
filePath = null,
keyDataBase64 = null,
displayName = null,
)
val result =
buildIdentityResultJson(
identityHash = null,
destinationHash = null,
filePath = null,
keyDataBase64 = null,
displayName = null,
)
val json = JSONObject(result)
// JSONObject.put with null value results in JSONObject.NULL
@@ -65,13 +68,14 @@ class IdentityResultBuilderTest {
@Test
fun `buildIdentityResultJson with empty keyDataBase64 includes key_data`() {
val result = buildIdentityResultJson(
identityHash = "abc123",
destinationHash = "def456",
filePath = "/path/to/identity",
keyDataBase64 = "",
displayName = "Test User",
)
val result =
buildIdentityResultJson(
identityHash = "abc123",
destinationHash = "def456",
filePath = "/path/to/identity",
keyDataBase64 = "",
displayName = "Test User",
)
val json = JSONObject(result)
assertTrue(json.has("key_data"))

View File

@@ -3,18 +3,16 @@ package com.lxmf.messenger.service.manager
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class IdentityResultParserTest {
@Test
fun `parseIdentityResultJson with all fields present`() {
val keyData = byteArrayOf(1, 2, 3, 4, 5)
val keyDataBase64 = java.util.Base64.getEncoder().encodeToString(keyData)
val json = """
val json =
"""
{
"identity_hash": "abc123",
"destination_hash": "def456",
@@ -22,7 +20,7 @@ class IdentityResultParserTest {
"key_data": "$keyDataBase64",
"display_name": "Test User"
}
""".trimIndent()
""".trimIndent()
val result = parseIdentityResultJson(json)
@@ -35,11 +33,12 @@ class IdentityResultParserTest {
@Test
fun `parseIdentityResultJson with error field`() {
val json = """
val json =
"""
{
"error": "Identity creation failed"
}
""".trimIndent()
""".trimIndent()
val result = parseIdentityResultJson(json)
@@ -49,12 +48,13 @@ class IdentityResultParserTest {
@Test
fun `parseIdentityResultJson with missing optional fields`() {
val json = """
val json =
"""
{
"identity_hash": "abc123",
"destination_hash": "def456"
}
""".trimIndent()
""".trimIndent()
val result = parseIdentityResultJson(json)
@@ -69,12 +69,13 @@ class IdentityResultParserTest {
val emptyKeyData = byteArrayOf()
val keyDataBase64 = java.util.Base64.getEncoder().encodeToString(emptyKeyData)
val json = """
val json =
"""
{
"identity_hash": "abc123",
"key_data": "$keyDataBase64"
}
""".trimIndent()
""".trimIndent()
val result = parseIdentityResultJson(json)
@@ -85,12 +86,13 @@ class IdentityResultParserTest {
@Test
fun `parseIdentityResultJson with invalid base64 key_data omits key`() {
val json = """
val json =
"""
{
"identity_hash": "abc123",
"key_data": "not-valid-base64!!!"
}
""".trimIndent()
""".trimIndent()
val result = parseIdentityResultJson(json)

View File

@@ -769,4 +769,67 @@ class RNodeWizardViewModelTest {
assertEquals("boundary", state.interfaceMode)
}
}
// ========== BLE Scan Error Tests ==========
@Test
fun `scan error state can be set and cleared`() =
runTest {
viewModel.state.test {
awaitItem() // Initial state
// Directly access setScanError method via reflection to test error handling
// In production, this is called from onScanFailed callback
val setScanErrorMethod =
RNodeWizardViewModel::class.java.getDeclaredMethod("setScanError", String::class.java)
setScanErrorMethod.isAccessible = true
setScanErrorMethod.invoke(viewModel, "BLE scan failed: 1")
advanceUntilIdle()
val stateWithError = awaitItem()
assertEquals("BLE scan failed: 1", stateWithError.scanError)
assertFalse("Scanning should be stopped on error", stateWithError.isScanning)
}
}
// ========== Memory Leak Tests ==========
@Test
fun `rssiPollingJob is cancelled when onCleared is called`() =
runTest {
// Access private rssiPollingJob field via reflection
val rssiPollingJobField =
RNodeWizardViewModel::class.java.getDeclaredField("rssiPollingJob")
rssiPollingJobField.isAccessible = true
// Access private startRssiPolling method to simulate starting the polling
val startRssiPollingMethod =
RNodeWizardViewModel::class.java.getDeclaredMethod("startRssiPolling")
startRssiPollingMethod.isAccessible = true
// Start RSSI polling to create a job
// Note: Don't call advanceUntilIdle() here as the job has an infinite loop
startRssiPollingMethod.invoke(viewModel)
// Allow the coroutine to start but don't wait for it to complete
testScheduler.advanceTimeBy(100)
// Verify job was created and is active
val jobBefore = rssiPollingJobField.get(viewModel) as? kotlinx.coroutines.Job
assertNotNull("RSSI polling job should be created after startRssiPolling()", jobBefore)
assertTrue("RSSI polling job should be active", jobBefore!!.isActive)
// Call onCleared() via reflection (it's protected)
val onClearedMethod =
RNodeWizardViewModel::class.java.getDeclaredMethod("onCleared")
onClearedMethod.isAccessible = true
onClearedMethod.invoke(viewModel)
// Verify the job is cancelled after cleanup
val jobAfter = rssiPollingJobField.get(viewModel) as? kotlinx.coroutines.Job
assertTrue(
"RSSI polling job should be cancelled or null after onCleared()",
jobAfter == null || jobAfter.isCancelled,
)
}
}

View File

@@ -110,364 +110,392 @@ class SettingsViewModelTest {
// region parseRpcKey Tests
@Test
fun `parseRpcKey with full Sideband config extracts key`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with full Sideband config extracts key`() =
runTest {
viewModel = createViewModel()
val input = "shared_instance_type = tcp\nrpc_key = e17abc123def456"
val result = viewModel.parseRpcKey(input)
val input = "shared_instance_type = tcp\nrpc_key = e17abc123def456"
val result = viewModel.parseRpcKey(input)
assertEquals("e17abc123def456", result)
}
assertEquals("e17abc123def456", result)
}
@Test
fun `parseRpcKey with key only returns key`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with key only returns key`() =
runTest {
viewModel = createViewModel()
val input = "e17abc123def456"
val result = viewModel.parseRpcKey(input)
val input = "e17abc123def456"
val result = viewModel.parseRpcKey(input)
assertEquals("e17abc123def456", result)
}
assertEquals("e17abc123def456", result)
}
@Test
fun `parseRpcKey with spaces around equals extracts key`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with spaces around equals extracts key`() =
runTest {
viewModel = createViewModel()
val input = "rpc_key = e17abc"
val result = viewModel.parseRpcKey(input)
val input = "rpc_key = e17abc"
val result = viewModel.parseRpcKey(input)
assertEquals("e17abc", result)
}
assertEquals("e17abc", result)
}
@Test
fun `parseRpcKey with extra whitespace trims correctly`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with extra whitespace trims correctly`() =
runTest {
viewModel = createViewModel()
val input = " e17abc123 "
val result = viewModel.parseRpcKey(input)
val input = " e17abc123 "
val result = viewModel.parseRpcKey(input)
assertEquals("e17abc123", result)
}
assertEquals("e17abc123", result)
}
@Test
fun `parseRpcKey with invalid characters returns null`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with invalid characters returns null`() =
runTest {
viewModel = createViewModel()
val input = "not-a-hex-key!"
val result = viewModel.parseRpcKey(input)
val input = "not-a-hex-key!"
val result = viewModel.parseRpcKey(input)
assertNull(result)
}
assertNull(result)
}
@Test
fun `parseRpcKey with empty string returns null`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with empty string returns null`() =
runTest {
viewModel = createViewModel()
val input = ""
val result = viewModel.parseRpcKey(input)
val input = ""
val result = viewModel.parseRpcKey(input)
assertNull(result)
}
assertNull(result)
}
@Test
fun `parseRpcKey with null returns null`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with null returns null`() =
runTest {
viewModel = createViewModel()
val result = viewModel.parseRpcKey(null)
val result = viewModel.parseRpcKey(null)
assertNull(result)
}
assertNull(result)
}
@Test
fun `parseRpcKey with mixed case preserves case`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with mixed case preserves case`() =
runTest {
viewModel = createViewModel()
val input = "e17AbC123DeF"
val result = viewModel.parseRpcKey(input)
val input = "e17AbC123DeF"
val result = viewModel.parseRpcKey(input)
assertEquals("e17AbC123DeF", result)
}
assertEquals("e17AbC123DeF", result)
}
@Test
fun `parseRpcKey with multiline config and key in middle extracts key`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with multiline config and key in middle extracts key`() =
runTest {
viewModel = createViewModel()
val input = """
shared_instance_type = tcp
rpc_key = abcd1234ef56
some_other_setting = value
""".trimIndent()
val result = viewModel.parseRpcKey(input)
val input =
"""
shared_instance_type = tcp
rpc_key = abcd1234ef56
some_other_setting = value
""".trimIndent()
val result = viewModel.parseRpcKey(input)
assertEquals("abcd1234ef56", result)
}
assertEquals("abcd1234ef56", result)
}
@Test
fun `parseRpcKey with whitespace only returns null`() = runTest {
viewModel = createViewModel()
fun `parseRpcKey with whitespace only returns null`() =
runTest {
viewModel = createViewModel()
val input = " \n\t "
val result = viewModel.parseRpcKey(input)
val input = " \n\t "
val result = viewModel.parseRpcKey(input)
assertNull(result)
}
assertNull(result)
}
// endregion
// region Initial State Tests
@Test
fun `initial state has correct defaults`() = runTest {
viewModel = createViewModel()
fun `initial state has correct defaults`() =
runTest {
viewModel = createViewModel()
viewModel.state.test {
val state = awaitItem()
assertFalse(state.isSharedInstanceBannerExpanded)
assertFalse(state.isRestarting)
assertFalse(state.sharedInstanceLost)
assertFalse(state.sharedInstanceAvailable)
cancelAndConsumeRemainingEvents()
viewModel.state.test {
val state = awaitItem()
assertFalse(state.isSharedInstanceBannerExpanded)
assertFalse(state.isRestarting)
assertFalse(state.sharedInstanceLost)
assertFalse(state.sharedInstanceAvailable)
cancelAndConsumeRemainingEvents()
}
}
}
@Test
fun `initial state reflects repository values`() = runTest {
// Setup initial flow values before creating ViewModel
preferOwnInstanceFlow.value = true
isSharedInstanceFlow.value = true
rpcKeyFlow.value = "testkey123"
fun `initial state reflects repository values`() =
runTest {
// Setup initial flow values before creating ViewModel
preferOwnInstanceFlow.value = true
isSharedInstanceFlow.value = true
rpcKeyFlow.value = "testkey123"
viewModel = createViewModel()
viewModel = createViewModel()
viewModel.state.test {
// First emission may be initial defaults while loading
var state = awaitItem()
// Wait for the state to load (isLoading becomes false after loadSettings completes)
while (state.isLoading) {
state = awaitItem()
viewModel.state.test {
// First emission may be initial defaults while loading
var state = awaitItem()
// Wait for the state to load (isLoading becomes false after loadSettings completes)
while (state.isLoading) {
state = awaitItem()
}
assertTrue(state.preferOwnInstance)
assertTrue(state.isSharedInstance)
assertEquals("testkey123", state.rpcKey)
cancelAndConsumeRemainingEvents()
}
assertTrue(state.preferOwnInstance)
assertTrue(state.isSharedInstance)
assertEquals("testkey123", state.rpcKey)
cancelAndConsumeRemainingEvents()
}
}
// endregion
// region State Transition Tests
@Test
fun `toggleSharedInstanceBannerExpanded toggles state`() = runTest {
viewModel = createViewModel()
fun `toggleSharedInstanceBannerExpanded toggles state`() =
runTest {
viewModel = createViewModel()
viewModel.state.test {
// Initial state
assertFalse(awaitItem().isSharedInstanceBannerExpanded)
viewModel.state.test {
// Initial state
assertFalse(awaitItem().isSharedInstanceBannerExpanded)
// Toggle on
viewModel.toggleSharedInstanceBannerExpanded(true)
assertTrue(awaitItem().isSharedInstanceBannerExpanded)
// Toggle on
viewModel.toggleSharedInstanceBannerExpanded(true)
assertTrue(awaitItem().isSharedInstanceBannerExpanded)
// Toggle off
viewModel.toggleSharedInstanceBannerExpanded(false)
assertFalse(awaitItem().isSharedInstanceBannerExpanded)
// Toggle off
viewModel.toggleSharedInstanceBannerExpanded(false)
assertFalse(awaitItem().isSharedInstanceBannerExpanded)
cancelAndConsumeRemainingEvents()
cancelAndConsumeRemainingEvents()
}
}
}
@Test
fun `togglePreferOwnInstance saves to repository and triggers restart`() = runTest {
viewModel = createViewModel()
fun `togglePreferOwnInstance saves to repository and triggers restart`() =
runTest {
viewModel = createViewModel()
viewModel.togglePreferOwnInstance(true)
viewModel.togglePreferOwnInstance(true)
coVerify { settingsRepository.savePreferOwnInstance(true) }
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
@Test
fun `dismissSharedInstanceLostWarning clears flag`() = runTest {
viewModel = createViewModel()
// First, trigger the lost state by updating the internal state
viewModel.state.test {
awaitItem() // initial
// Manually trigger the method
viewModel.dismissSharedInstanceLostWarning()
// State should have sharedInstanceLost = false
val finalState = awaitItem()
assertFalse(finalState.sharedInstanceLost)
cancelAndConsumeRemainingEvents()
coVerify { settingsRepository.savePreferOwnInstance(true) }
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
}
@Test
fun `switchToOwnInstanceAfterLoss sets preferOwn and triggers restart`() = runTest {
viewModel = createViewModel()
fun `dismissSharedInstanceLostWarning clears flag`() =
runTest {
viewModel = createViewModel()
viewModel.switchToOwnInstanceAfterLoss()
// First, trigger the lost state by updating the internal state
viewModel.state.test {
awaitItem() // initial
coVerify { settingsRepository.savePreferOwnInstance(true) }
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
// Manually trigger the method
viewModel.dismissSharedInstanceLostWarning()
// State should have sharedInstanceLost = false
val finalState = awaitItem()
assertFalse(finalState.sharedInstanceLost)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `switchToSharedInstance clears preferOwn and triggers restart`() = runTest {
preferOwnInstanceFlow.value = true
viewModel = createViewModel()
fun `switchToOwnInstanceAfterLoss sets preferOwn and triggers restart`() =
runTest {
viewModel = createViewModel()
viewModel.switchToSharedInstance()
viewModel.switchToOwnInstanceAfterLoss()
coVerify { settingsRepository.savePreferOwnInstance(false) }
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
coVerify { settingsRepository.savePreferOwnInstance(true) }
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
@Test
fun `dismissSharedInstanceAvailable sets preferOwnInstance`() = runTest {
viewModel = createViewModel()
fun `switchToSharedInstance clears preferOwn and triggers restart`() =
runTest {
preferOwnInstanceFlow.value = true
viewModel = createViewModel()
viewModel.dismissSharedInstanceAvailable()
viewModel.switchToSharedInstance()
coVerify { settingsRepository.savePreferOwnInstance(true) }
}
coVerify { settingsRepository.savePreferOwnInstance(false) }
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
@Test
fun `dismissSharedInstanceAvailable sets preferOwnInstance`() =
runTest {
viewModel = createViewModel()
viewModel.dismissSharedInstanceAvailable()
coVerify { settingsRepository.savePreferOwnInstance(true) }
}
// endregion
// region saveRpcKey Tests
@Test
fun `saveRpcKey with valid config parses and saves`() = runTest {
isSharedInstanceFlow.value = true // Must be shared instance to trigger restart
viewModel = createViewModel()
fun `saveRpcKey with valid config parses and saves`() =
runTest {
isSharedInstanceFlow.value = true // Must be shared instance to trigger restart
viewModel = createViewModel()
viewModel.saveRpcKey("shared_instance_type = tcp\nrpc_key = abc123def")
viewModel.saveRpcKey("shared_instance_type = tcp\nrpc_key = abc123def")
coVerify { settingsRepository.saveRpcKey("abc123def") }
}
@Test
fun `saveRpcKey with raw hex saves directly`() = runTest {
isSharedInstanceFlow.value = true
viewModel = createViewModel()
viewModel.saveRpcKey("abc123def456")
coVerify { settingsRepository.saveRpcKey("abc123def456") }
}
@Test
fun `saveRpcKey with invalid input saves null`() = runTest {
isSharedInstanceFlow.value = true
viewModel = createViewModel()
viewModel.saveRpcKey("invalid-key!")
coVerify { settingsRepository.saveRpcKey(null) }
}
@Test
fun `saveRpcKey triggers service restart when shared instance`() = runTest {
isSharedInstanceFlow.value = true
viewModel = createViewModel()
// Wait for state to load so isSharedInstance is populated from flow
viewModel.state.test {
var state = awaitItem()
while (state.isLoading) {
state = awaitItem()
}
cancelAndConsumeRemainingEvents()
coVerify { settingsRepository.saveRpcKey("abc123def") }
}
viewModel.saveRpcKey("abc123")
@Test
fun `saveRpcKey with raw hex saves directly`() =
runTest {
isSharedInstanceFlow.value = true
viewModel = createViewModel()
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
viewModel.saveRpcKey("abc123def456")
coVerify { settingsRepository.saveRpcKey("abc123def456") }
}
@Test
fun `saveRpcKey does not restart when not shared instance`() = runTest {
isSharedInstanceFlow.value = false
viewModel = createViewModel()
fun `saveRpcKey with invalid input saves null`() =
runTest {
isSharedInstanceFlow.value = true
viewModel = createViewModel()
viewModel.saveRpcKey("abc123")
viewModel.saveRpcKey("invalid-key!")
// Should not call applyInterfaceChanges since not using shared instance
coVerify(exactly = 0) { interfaceConfigManager.applyInterfaceChanges() }
}
coVerify { settingsRepository.saveRpcKey(null) }
}
@Test
fun `saveRpcKey triggers service restart when shared instance`() =
runTest {
isSharedInstanceFlow.value = true
viewModel = createViewModel()
// Wait for state to load so isSharedInstance is populated from flow
viewModel.state.test {
var state = awaitItem()
while (state.isLoading) {
state = awaitItem()
}
cancelAndConsumeRemainingEvents()
}
viewModel.saveRpcKey("abc123")
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
@Test
fun `saveRpcKey does not restart when not shared instance`() =
runTest {
isSharedInstanceFlow.value = false
viewModel = createViewModel()
viewModel.saveRpcKey("abc123")
// Should not call applyInterfaceChanges since not using shared instance
coVerify(exactly = 0) { interfaceConfigManager.applyInterfaceChanges() }
}
// endregion
// region Flow Collection Tests
@Test
fun `state collects preferOwnInstance from repository`() = runTest {
viewModel = createViewModel()
fun `state collects preferOwnInstance from repository`() =
runTest {
viewModel = createViewModel()
viewModel.state.test {
var state = awaitItem()
assertFalse(state.preferOwnInstance)
viewModel.state.test {
var state = awaitItem()
assertFalse(state.preferOwnInstance)
// Update flow
preferOwnInstanceFlow.value = true
state = awaitItem()
assertTrue(state.preferOwnInstance)
// Update flow
preferOwnInstanceFlow.value = true
state = awaitItem()
assertTrue(state.preferOwnInstance)
cancelAndConsumeRemainingEvents()
cancelAndConsumeRemainingEvents()
}
}
}
@Test
fun `state collects isSharedInstance from repository`() = runTest {
viewModel = createViewModel()
fun `state collects isSharedInstance from repository`() =
runTest {
viewModel = createViewModel()
viewModel.state.test {
var state = awaitItem()
assertFalse(state.isSharedInstance)
viewModel.state.test {
var state = awaitItem()
assertFalse(state.isSharedInstance)
// Update flow
isSharedInstanceFlow.value = true
state = awaitItem()
assertTrue(state.isSharedInstance)
// Update flow
isSharedInstanceFlow.value = true
state = awaitItem()
assertTrue(state.isSharedInstance)
cancelAndConsumeRemainingEvents()
cancelAndConsumeRemainingEvents()
}
}
}
@Test
fun `state collects rpcKey from repository`() = runTest {
viewModel = createViewModel()
fun `state collects rpcKey from repository`() =
runTest {
viewModel = createViewModel()
viewModel.state.test {
var state = awaitItem()
assertNull(state.rpcKey)
viewModel.state.test {
var state = awaitItem()
assertNull(state.rpcKey)
// Update flow
rpcKeyFlow.value = "newkey456"
state = awaitItem()
assertEquals("newkey456", state.rpcKey)
// Update flow
rpcKeyFlow.value = "newkey456"
state = awaitItem()
assertEquals("newkey456", state.rpcKey)
cancelAndConsumeRemainingEvents()
cancelAndConsumeRemainingEvents()
}
}
}
// endregion
// region Restart State Tests
@Test
fun `restartService calls interfaceConfigManager`() = runTest {
viewModel = createViewModel()
fun `restartService calls interfaceConfigManager`() =
runTest {
viewModel = createViewModel()
viewModel.restartService()
viewModel.restartService()
// Verify the restart was triggered via interfaceConfigManager
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
// Verify the restart was triggered via interfaceConfigManager
coVerify { interfaceConfigManager.applyInterfaceChanges() }
}
// endregion
}

View File

@@ -122,19 +122,20 @@ class CustomThemeRepository
darkColors: ThemeColorSet,
baseTheme: String? = null,
): Long {
val entity = buildThemeEntity(
id = 0L,
name = name,
description = description,
baseTheme = baseTheme,
seedPrimary = seedPrimary,
seedSecondary = seedSecondary,
seedTertiary = seedTertiary,
lightColors = lightColors,
darkColors = darkColors,
createdTimestamp = System.currentTimeMillis(),
modifiedTimestamp = System.currentTimeMillis(),
)
val entity =
buildThemeEntity(
id = 0L,
name = name,
description = description,
baseTheme = baseTheme,
seedPrimary = seedPrimary,
seedSecondary = seedSecondary,
seedTertiary = seedTertiary,
lightColors = lightColors,
darkColors = darkColors,
createdTimestamp = System.currentTimeMillis(),
modifiedTimestamp = System.currentTimeMillis(),
)
return customThemeDao.insertTheme(entity)
}
@@ -153,19 +154,20 @@ class CustomThemeRepository
darkColors: ThemeColorSet,
baseTheme: String? = null,
) {
val entity = buildThemeEntity(
id = id,
name = name,
description = description,
baseTheme = baseTheme,
seedPrimary = seedPrimary,
seedSecondary = seedSecondary,
seedTertiary = seedTertiary,
lightColors = lightColors,
darkColors = darkColors,
createdTimestamp = 0L, // Will be ignored in update
modifiedTimestamp = System.currentTimeMillis(),
)
val entity =
buildThemeEntity(
id = id,
name = name,
description = description,
baseTheme = baseTheme,
seedPrimary = seedPrimary,
seedSecondary = seedSecondary,
seedTertiary = seedTertiary,
lightColors = lightColors,
darkColors = darkColors,
createdTimestamp = 0L, // Will be ignored in update
modifiedTimestamp = System.currentTimeMillis(),
)
customThemeDao.updateTheme(entity)
}
@@ -322,62 +324,63 @@ private fun buildThemeEntity(
darkColors: ThemeColorSet,
createdTimestamp: Long,
modifiedTimestamp: Long,
): CustomThemeEntity = CustomThemeEntity(
id = id,
name = name,
description = description,
baseTheme = baseTheme,
createdTimestamp = createdTimestamp,
modifiedTimestamp = modifiedTimestamp,
seedPrimary = seedPrimary,
seedSecondary = seedSecondary,
seedTertiary = seedTertiary,
lightPrimary = lightColors.primary,
lightOnPrimary = lightColors.onPrimary,
lightPrimaryContainer = lightColors.primaryContainer,
lightOnPrimaryContainer = lightColors.onPrimaryContainer,
lightSecondary = lightColors.secondary,
lightOnSecondary = lightColors.onSecondary,
lightSecondaryContainer = lightColors.secondaryContainer,
lightOnSecondaryContainer = lightColors.onSecondaryContainer,
lightTertiary = lightColors.tertiary,
lightOnTertiary = lightColors.onTertiary,
lightTertiaryContainer = lightColors.tertiaryContainer,
lightOnTertiaryContainer = lightColors.onTertiaryContainer,
lightError = lightColors.error,
lightOnError = lightColors.onError,
lightErrorContainer = lightColors.errorContainer,
lightOnErrorContainer = lightColors.onErrorContainer,
lightBackground = lightColors.background,
lightOnBackground = lightColors.onBackground,
lightSurface = lightColors.surface,
lightOnSurface = lightColors.onSurface,
lightSurfaceVariant = lightColors.surfaceVariant,
lightOnSurfaceVariant = lightColors.onSurfaceVariant,
lightOutline = lightColors.outline,
lightOutlineVariant = lightColors.outlineVariant,
darkPrimary = darkColors.primary,
darkOnPrimary = darkColors.onPrimary,
darkPrimaryContainer = darkColors.primaryContainer,
darkOnPrimaryContainer = darkColors.onPrimaryContainer,
darkSecondary = darkColors.secondary,
darkOnSecondary = darkColors.onSecondary,
darkSecondaryContainer = darkColors.secondaryContainer,
darkOnSecondaryContainer = darkColors.onSecondaryContainer,
darkTertiary = darkColors.tertiary,
darkOnTertiary = darkColors.onTertiary,
darkTertiaryContainer = darkColors.tertiaryContainer,
darkOnTertiaryContainer = darkColors.onTertiaryContainer,
darkError = darkColors.error,
darkOnError = darkColors.onError,
darkErrorContainer = darkColors.errorContainer,
darkOnErrorContainer = darkColors.onErrorContainer,
darkBackground = darkColors.background,
darkOnBackground = darkColors.onBackground,
darkSurface = darkColors.surface,
darkOnSurface = darkColors.onSurface,
darkSurfaceVariant = darkColors.surfaceVariant,
darkOnSurfaceVariant = darkColors.onSurfaceVariant,
darkOutline = darkColors.outline,
darkOutlineVariant = darkColors.outlineVariant,
)
): CustomThemeEntity =
CustomThemeEntity(
id = id,
name = name,
description = description,
baseTheme = baseTheme,
createdTimestamp = createdTimestamp,
modifiedTimestamp = modifiedTimestamp,
seedPrimary = seedPrimary,
seedSecondary = seedSecondary,
seedTertiary = seedTertiary,
lightPrimary = lightColors.primary,
lightOnPrimary = lightColors.onPrimary,
lightPrimaryContainer = lightColors.primaryContainer,
lightOnPrimaryContainer = lightColors.onPrimaryContainer,
lightSecondary = lightColors.secondary,
lightOnSecondary = lightColors.onSecondary,
lightSecondaryContainer = lightColors.secondaryContainer,
lightOnSecondaryContainer = lightColors.onSecondaryContainer,
lightTertiary = lightColors.tertiary,
lightOnTertiary = lightColors.onTertiary,
lightTertiaryContainer = lightColors.tertiaryContainer,
lightOnTertiaryContainer = lightColors.onTertiaryContainer,
lightError = lightColors.error,
lightOnError = lightColors.onError,
lightErrorContainer = lightColors.errorContainer,
lightOnErrorContainer = lightColors.onErrorContainer,
lightBackground = lightColors.background,
lightOnBackground = lightColors.onBackground,
lightSurface = lightColors.surface,
lightOnSurface = lightColors.onSurface,
lightSurfaceVariant = lightColors.surfaceVariant,
lightOnSurfaceVariant = lightColors.onSurfaceVariant,
lightOutline = lightColors.outline,
lightOutlineVariant = lightColors.outlineVariant,
darkPrimary = darkColors.primary,
darkOnPrimary = darkColors.onPrimary,
darkPrimaryContainer = darkColors.primaryContainer,
darkOnPrimaryContainer = darkColors.onPrimaryContainer,
darkSecondary = darkColors.secondary,
darkOnSecondary = darkColors.onSecondary,
darkSecondaryContainer = darkColors.secondaryContainer,
darkOnSecondaryContainer = darkColors.onSecondaryContainer,
darkTertiary = darkColors.tertiary,
darkOnTertiary = darkColors.onTertiary,
darkTertiaryContainer = darkColors.tertiaryContainer,
darkOnTertiaryContainer = darkColors.onTertiaryContainer,
darkError = darkColors.error,
darkOnError = darkColors.onError,
darkErrorContainer = darkColors.errorContainer,
darkOnErrorContainer = darkColors.onErrorContainer,
darkBackground = darkColors.background,
darkOnBackground = darkColors.onBackground,
darkSurface = darkColors.surface,
darkOnSurface = darkColors.onSurface,
darkSurfaceVariant = darkColors.surfaceVariant,
darkOnSurfaceVariant = darkColors.onSurfaceVariant,
darkOutline = darkColors.outline,
darkOutlineVariant = darkColors.outlineVariant,
)

View File

@@ -0,0 +1,269 @@
# RPC Authentication Error in Shared Instance Mode
## Overview
When Columba connects to a shared Reticulum instance (e.g., Sideband), message delivery fails with RPC authentication errors. This document explains the root cause and provides solutions.
## Key Finding: RPC is NOT Required for Message Delivery
**Core packet routing uses LocalClientInterface socket connections directly - NOT RPC.**
The RPC subsystem is only used for:
- Statistics queries (RSSI, SNR, link quality)
- Path table queries
- Management operations (drop paths, clear queues)
However, a bug in RNS causes message delivery to crash when RPC authentication fails.
---
## LXMF Delivery Methods
| Method | Description | Max Size | RPC Required? |
|--------|-------------|----------|---------------|
| **OPPORTUNISTIC** | Single encrypted packet, no link | ~295 bytes | No |
| **DIRECT** | Link-based, reliable delivery | Unlimited | No |
| **PROPAGATED** | Store-and-forward via propagation node | Unlimited | No |
| **PAPER** | QR code/URI for manual transfer | ~1760 bytes | No |
All methods deliver through `LXMRouter.lxmf_delivery()` → delivery callback. None require RPC.
---
## The Bug: RPC Auth Error Crashes Message Delivery
### Evidence from Logcat
```
12-07 23:45:11.910 I python.stdout: [2025-12-07 23:45:11] [Error] Traceback (most recent call last):
12-07 23:45:11.910 I python.stdout: File ".../RNS/Interfaces/LocalInterface.py", line 193, in process_incoming
12-07 23:45:11.910 I python.stdout: File ".../RNS/Transport.py", line 1889, in inbound
12-07 23:45:11.910 I python.stdout: File ".../RNS/Link.py", line 995, in receive
12-07 23:45:11.910 I python.stdout: File ".../RNS/Link.py", line 837, in __update_phy_stats
12-07 23:45:11.910 I python.stdout: File ".../RNS/Reticulum.py", line 1280, in get_packet_rssi
12-07 23:45:11.910 I python.stdout: File ".../RNS/Reticulum.py", line 969, in get_rpc_client
12-07 23:45:11.911 I python.stdout: multiprocessing.context.AuthenticationError: digest sent was rejected
12-07 23:45:13.279 I sidebandservice: [Error] An error ocurred while handling RPC call from local client: digest received was wrong
```
### Exact Call Chain
```
1. Packet arrives at LocalClientInterface (connected to Sideband's shared instance)
2. LocalInterface.process_incoming() [LocalInterface.py:193]
- Receives raw packet bytes from shared instance socket
3. Transport.inbound() [Transport.py:1889]
- Routes packet to appropriate handler
- For link-related packets, calls link.receive()
4. Link.receive() [Link.py:995]
- Processes the link packet (DATA, LINKIDENTIFY, REQUEST, etc.)
- Calls __update_phy_stats() to record signal quality
5. Link.__update_phy_stats() [Link.py:837]
- Called with force_update=True at 17 different sites in receive()
- Tries to get RSSI/SNR/Q from the packet
- If connected to shared instance, makes RPC call
6. Reticulum.get_packet_rssi() [Reticulum.py:1280]
- Makes RPC call to shared instance to query cached RSSI value
7. Reticulum.get_rpc_client() [Reticulum.py:969]
- Creates RPC connection with authkey
- AuthenticationError: Columba's authkey doesn't match Sideband's
```
### Root Cause
**Columba and Sideband have different RPC keys** because:
- Each app has its own config directory (Android sandboxing)
- RPC keys are derived from app-specific identity private keys
- Without explicit key sharing, authentication fails
### Why This Crashes Message Delivery
The `__update_phy_stats()` method does NOT have exception handling:
```python
# Link.py lines 833-850 (simplified)
def __update_phy_stats(self, packet, force_update=False):
if self.__track_phy_stats or force_update:
if RNS.Reticulum.get_instance().is_connected_to_shared_instance:
# NO TRY-CATCH HERE!
self.rssi = RNS.Reticulum.get_instance().get_packet_rssi(packet.packet_hash)
self.snr = RNS.Reticulum.get_instance().get_packet_snr(packet.packet_hash)
self.q = RNS.Reticulum.get_instance().get_packet_q(packet.packet_hash)
```
When `get_packet_rssi()` raises `AuthenticationError`, it propagates up through:
- `__update_phy_stats()``receive()``Transport.inbound()``process_incoming()`
This crashes the entire packet processing chain.
### Why `force_update=True` Is Used
Even though `__track_phy_stats` defaults to `False`, certain call sites in `Link.receive()` use `force_update=True`:
- **Line 218**: Incoming link request acceptance
- **Lines 995, 1031, 1040, 1053, 1060, 1064, 1069**: Various DATA packet types
- **Lines 1107, 1129, 1138, 1147, 1167, 1176, 1185**: RESOURCE_ADV, LINKCLOSE, etc.
This is automatic RNS behavior - **Columba does not explicitly request signal stats**.
---
## Can RPC Key Enable Interface Configuration?
**NO** - Even with valid RPC key, shared instance clients cannot configure interfaces:
```python
# From Reticulum.py _add_interface()
if not self.is_connected_to_shared_instance:
# Process interface configuration
```
Interface configuration is blocked for connected clients. This is by design - the shared instance owns the interfaces.
---
## Solutions
### Option 1: Patch RNS in Columba's Bundled Copy
Add exception handling in `Link.__update_phy_stats()`:
```python
def __update_phy_stats(self, packet, force_update=False):
if self.__track_phy_stats or force_update:
try:
if RNS.Reticulum.get_instance().is_connected_to_shared_instance:
self.rssi = RNS.Reticulum.get_instance().get_packet_rssi(packet.packet_hash)
self.snr = RNS.Reticulum.get_instance().get_packet_snr(packet.packet_hash)
self.q = RNS.Reticulum.get_instance().get_packet_q(packet.packet_hash)
else:
# ... existing local cache lookup
except Exception as e:
RNS.log(f"Could not update physical layer stats: {e}", RNS.LOG_DEBUG)
# Stats remain None, but packet processing continues
```
**Pros**: Immediate fix, no user action required
**Cons**: Diverges from upstream RNS
### Option 2: Configure RPC Key
Allow users to paste the RPC key from Sideband (Settings → Connectivity → "Share Instance Access").
**Pros**: Enables full stats functionality
**Cons**: Requires user action, key management
### Option 3: Both Approaches
Patch for resilience + allow RPC key for full functionality.
**Pros**: Best of both worlds
**Cons**: More implementation work
### Option 4: Upstream PR
Submit fix to Reticulum repository.
**Pros**: Fixes for everyone
**Cons**: Dependent on upstream acceptance timeline
---
## Sideband RPC Key Export
Location in Sideband: **Settings → Connectivity → "Share Instance Access"**
The exported config includes:
```
shared_instance_type = tcp
rpc_key = <hex_key>
```
---
---
## Why Other RNS Clients Don't Have This Problem
### NomadNet & MeshChat Approach
Both NomadNet and reticulum-meshchat use a simple initialization pattern:
```python
# NomadNet (nomadnet/NomadNetworkApp.py:95)
self.rns = RNS.Reticulum(configdir=rnsconfigdir)
# MeshChat (meshchat.py:113-115)
self.reticulum = RNS.Reticulum(reticulum_config_dir)
```
**Why they work:** These are typically run on Linux/desktop systems where:
- All apps can share the same `~/.reticulum` config directory
- The RPC key is derived from the same identity file
- RPC authentication succeeds because both sides use the same key
### Sideband's Android Solution
Sideband explicitly handles the Android sandboxing problem:
**File: `/home/tyler/repos/Sideband/sbapp/sideband/core.py:607-608`**
```python
self.identity = RNS.Identity.from_file(self.identity_path)
self.rpc_key = RNS.Identity.full_hash(self.identity.get_private_key())
```
**File: `/home/tyler/repos/Sideband/sbapp/main.py:3914-3925`** - Export feature:
```python
rpc_string = "shared_instance_type = tcp\n"
rpc_string += "rpc_key = " + RNS.hexrep(self.sideband.reticulum.rpc_key, delimit=False)
```
Sideband provides a UI to export this configuration, which other apps can paste into their Reticulum config.
### The Columba Problem
Columba is unique because:
1. **Android sandbox**: Columba has its own app directory, can't read Sideband's config
2. **Bundled Python**: Uses Chaquopy with its own RNS installation
3. **Own identity**: Columba creates its own identity file (different from Sideband's)
4. **Derived RPC key**: RNS derives RPC key from identity: `Identity.full_hash(private_key)`
When Columba connects to Sideband's shared instance:
- **Data routing works** (LocalClientInterface uses raw sockets)
- **RPC fails** (Columba's derived key != Sideband's derived key)
### RPC Key Configuration in RNS
**File: `/home/tyler/repos/Reticulum/RNS/Reticulum.py:472-478`**
```python
if option == "rpc_key":
try:
value = bytes.fromhex(self.config["reticulum"][option])
self.rpc_key = value
except Exception as e:
RNS.log("Invalid shared instance RPC key specified, falling back to default key", RNS.LOG_ERROR)
self.rpc_key = None
```
RNS supports explicit RPC key configuration for exactly this scenario - when apps can't share config directories.
---
## Summary
| Aspect | Status |
|--------|--------|
| Message delivery without RPC | Should work (bug prevents it) |
| Root cause | Missing exception handling in `__update_phy_stats()` |
| Why other clients work | They share config directories or explicitly configure RPC key |
| Columba's issue | Android sandbox prevents config sharing, RPC key mismatch |
| Interface configuration | Always disabled for shared instance clients |
| Recommended fix | Patch RNS + support RPC key configuration in UI |

View File

@@ -2897,17 +2897,12 @@ class ReticulumWrapper:
Dict with 'success' boolean and optional 'error' string
"""
# Prevent concurrent initialization (race condition fix)
# Quick check without lock first for performance
if self._rnode_initializing:
log_info("ReticulumWrapper", "initialize_rnode_interface",
"RNode initialization already in progress, skipping duplicate call")
return {'success': True, 'message': 'Initialization already in progress'}
# Acquire lock and double-check
# Acquire lock before checking/setting initialization flag to prevent race condition
# (Double-check locking without proper memory barriers is broken in Python)
with self._rnode_init_lock:
if self._rnode_initializing:
log_info("ReticulumWrapper", "initialize_rnode_interface",
"RNode initialization already in progress (after lock), skipping")
"RNode initialization already in progress, skipping duplicate call")
return {'success': True, 'message': 'Initialization already in progress'}
self._rnode_initializing = True
@@ -2965,13 +2960,6 @@ class ReticulumWrapper:
self.rnode_interface.setOnErrorReceived(on_rnode_error)
# Register with RNS Transport BEFORE starting
# This ensures the interface is tracked even if initial connection fails
# (auto-reconnect may succeed later)
RNS.Transport.interfaces.append(self.rnode_interface)
log_info("ReticulumWrapper", "initialize_rnode_interface",
"Registered ColumbaRNodeInterface with RNS Transport")
# Set up online status callback to notify Kotlin when interface comes online
def on_online_status_change(is_online):
log_info("ReticulumWrapper", "RNodeStatus",
@@ -2988,10 +2976,20 @@ class ReticulumWrapper:
log_debug("ReticulumWrapper", "initialize_rnode_interface",
"Set online status callback")
# Start the interface
# Start the interface FIRST before registering with Transport
# This ensures we catch any fatal initialization errors before committing
log_info("ReticulumWrapper", "initialize_rnode_interface", "Starting ColumbaRNodeInterface...")
start_success = self.rnode_interface.start()
# Register with RNS Transport after starting
# The interface is registered even if start() returns False because:
# 1. The interface has auto-reconnect capability
# 2. start() failure may be transient (Bluetooth not ready, device not in range)
# 3. RNS Transport checks online status before sending
RNS.Transport.interfaces.append(self.rnode_interface)
log_info("ReticulumWrapper", "initialize_rnode_interface",
f"Registered ColumbaRNodeInterface with RNS Transport (start_success={start_success})")
if start_success:
log_info("ReticulumWrapper", "initialize_rnode_interface",
f"✅ ColumbaRNodeInterface started successfully, online={self.rnode_interface.online}")

View File

@@ -243,7 +243,7 @@ class ColumbaRNodeInterface:
# Read thread
self._read_thread = None
self._running = False
self._running = threading.Event() # Thread-safe flag for read loop control
self._read_lock = threading.Lock()
# Auto-reconnection
@@ -321,7 +321,7 @@ class ColumbaRNodeInterface:
self.kotlin_bridge.setOnConnectionStateChanged(self._on_connection_state_changed)
# Start read thread
self._running = True
self._running.set()
self._read_thread = threading.Thread(target=self._read_loop, daemon=True)
self._read_thread.start()
@@ -337,7 +337,7 @@ class ColumbaRNodeInterface:
def stop(self):
"""Stop the interface and disconnect."""
self._running = False
self._running.clear()
self._reconnecting = False # Stop any reconnection attempts
self._set_online(False)
@@ -513,8 +513,11 @@ class ColumbaRNodeInterface:
return True
# Exponential backoff delays for write retries (in seconds)
WRITE_BACKOFF_DELAYS = [0.3, 1.0, 3.0]
def _write(self, data, max_retries=3):
"""Write data to the RNode via Kotlin bridge with retry logic."""
"""Write data to the RNode via Kotlin bridge with exponential backoff retry."""
if self.kotlin_bridge is None:
raise IOError("Kotlin bridge not available")
@@ -526,8 +529,10 @@ class ColumbaRNodeInterface:
last_error = f"expected {len(data)}, wrote {written}"
if attempt < max_retries - 1:
RNS.log(f"Write attempt {attempt + 1} failed ({last_error}), retrying...", RNS.LOG_WARNING)
time.sleep(0.3) # Brief delay before retry
# Use exponential backoff delay (0.3s, 1.0s, 3.0s, ...)
delay = self.WRITE_BACKOFF_DELAYS[min(attempt, len(self.WRITE_BACKOFF_DELAYS) - 1)]
RNS.log(f"Write attempt {attempt + 1} failed ({last_error}), retrying in {delay}s...", RNS.LOG_WARNING)
time.sleep(delay)
raise IOError(f"Write failed after {max_retries} attempts: {last_error}")
@@ -616,7 +621,7 @@ class ColumbaRNodeInterface:
RNS.log("RNode read loop started", RNS.LOG_DEBUG)
while self._running:
while self._running.is_set():
try:
# Read available data
raw_data = self.kotlin_bridge.read()
@@ -714,7 +719,7 @@ class ColumbaRNodeInterface:
pass # Device ready
except Exception as e:
if self._running:
if self._running.is_set():
RNS.log(f"Read loop error: {e}", RNS.LOG_ERROR)
time.sleep(0.1)

View File

@@ -349,6 +349,176 @@ class TestValidateFirmware:
assert iface.firmware_ok is False
class TestThreadSafety:
"""Tests for thread-safe read loop control using threading.Event."""
def create_interface(self):
"""Create a test interface with mocked dependencies."""
import threading
with patch.object(ColumbaRNodeInterface, '_get_kotlin_bridge'):
with patch.object(ColumbaRNodeInterface, '_validate_config'):
config = {
'frequency': 915000000,
'bandwidth': 125000,
'txpower': 17,
'sf': 8,
'cr': 5,
}
iface = ColumbaRNodeInterface.__new__(ColumbaRNodeInterface)
iface.frequency = config['frequency']
iface.bandwidth = config['bandwidth']
iface.txpower = config['txpower']
iface.sf = config['sf']
iface.cr = config['cr']
iface.st_alock = None
iface.lt_alock = None
iface.name = "test-rnode"
iface.target_device_name = "TestRNode"
iface.connection_mode = 0
iface.kotlin_bridge = None
iface.enable_framebuffer = False
iface.framebuffer_enabled = False
iface._read_thread = None
iface._running = threading.Event()
iface._read_lock = threading.Lock()
iface._reconnect_thread = None
iface._reconnecting = False
iface._max_reconnect_attempts = 30
iface._reconnect_interval = 10.0
iface._on_error_callback = None
iface._on_online_status_changed = None
return iface
def test_running_flag_is_threading_event(self):
"""_running should be a threading.Event for thread-safe signaling."""
import threading
iface = self.create_interface()
assert isinstance(iface._running, threading.Event)
def test_running_event_initially_not_set(self):
"""_running event should not be set on initialization."""
iface = self.create_interface()
assert not iface._running.is_set()
def test_running_event_can_be_set_and_cleared(self):
"""_running event should support set() and clear() operations."""
iface = self.create_interface()
# Initially not set
assert not iface._running.is_set()
# Set the event
iface._running.set()
assert iface._running.is_set()
# Clear the event
iface._running.clear()
assert not iface._running.is_set()
def test_running_event_thread_safe_signaling(self):
"""Verify threading.Event provides thread-safe stop signaling."""
import threading
import time
iface = self.create_interface()
loop_iterations = []
def mock_loop():
"""Simulate read loop behavior."""
while iface._running.is_set():
loop_iterations.append(1)
time.sleep(0.01)
# Start the event and thread
iface._running.set()
thread = threading.Thread(target=mock_loop)
thread.start()
# Let it run a bit
time.sleep(0.05)
# Clear event to stop the loop
iface._running.clear()
thread.join(timeout=1.0)
# Thread should have stopped cleanly
assert not thread.is_alive()
assert len(loop_iterations) > 0 # Loop ran at least once
class TestWriteRetry:
"""Tests for write retry with exponential backoff."""
def create_interface_for_write(self):
"""Create a test interface with mocked dependencies."""
import threading
with patch.object(ColumbaRNodeInterface, '_get_kotlin_bridge'):
with patch.object(ColumbaRNodeInterface, '_validate_config'):
iface = ColumbaRNodeInterface.__new__(ColumbaRNodeInterface)
iface.name = "test-rnode"
iface.kotlin_bridge = MagicMock()
return iface
def test_write_uses_exponential_backoff(self):
"""Write retry should use exponential backoff delays."""
import time
iface = self.create_interface_for_write()
test_data = b"test"
# Mock writeSync to always fail (return 0 bytes written)
iface.kotlin_bridge.writeSync.return_value = 0
sleep_calls = []
def mock_sleep(seconds):
sleep_calls.append(seconds)
# Don't actually sleep in tests
with patch('time.sleep', mock_sleep):
with pytest.raises(IOError):
iface._write(test_data)
# With 3 retries, we get 2 sleep calls between attempts
# Should have exponential backoff: 0.3s, 1.0s
assert len(sleep_calls) == 2
assert sleep_calls[0] == pytest.approx(0.3, rel=0.01)
assert sleep_calls[1] == pytest.approx(1.0, rel=0.01)
def test_write_succeeds_on_first_attempt(self):
"""Write should not retry if first attempt succeeds."""
import time
iface = self.create_interface_for_write()
test_data = b"test"
# Mock writeSync to succeed
iface.kotlin_bridge.writeSync.return_value = len(test_data)
sleep_calls = []
with patch('time.sleep', lambda s: sleep_calls.append(s)):
iface._write(test_data) # Should not raise
# No retries needed
assert len(sleep_calls) == 0
assert iface.kotlin_bridge.writeSync.call_count == 1
def test_write_succeeds_on_retry(self):
"""Write should succeed if a retry works."""
import time
iface = self.create_interface_for_write()
test_data = b"test"
# Mock writeSync to fail first, then succeed
iface.kotlin_bridge.writeSync.side_effect = [0, len(test_data)]
sleep_calls = []
with patch('time.sleep', lambda s: sleep_calls.append(s)):
iface._write(test_data) # Should not raise
# One retry delay
assert len(sleep_calls) == 1
assert iface.kotlin_bridge.writeSync.call_count == 2
class TestKISSConstants:
"""Tests for KISS protocol constants."""

View File

@@ -705,11 +705,12 @@ class KotlinBLEBridge(
// Extract identity prefix from device name
// Protocol v2.2 format: "RNS-XXXXXX" or "Reticulum-XXXXXX" where X is hex
val identityPrefix = when {
deviceName.startsWith("RNS-") -> deviceName.removePrefix("RNS-").lowercase()
deviceName.startsWith("Reticulum-") -> deviceName.removePrefix("Reticulum-").lowercase()
else -> return false
}
val identityPrefix =
when {
deviceName.startsWith("RNS-") -> deviceName.removePrefix("RNS-").lowercase()
deviceName.startsWith("Reticulum-") -> deviceName.removePrefix("Reticulum-").lowercase()
else -> return false
}
// Check if any recently deduplicated identity starts with this prefix
// (device name has 6 hex chars = 3 bytes, full identity is 32 hex chars)

View File

@@ -1127,15 +1127,16 @@ class BleGattClient(
connections[address]?.consecutiveKeepaliveFailures = 0
}
} else {
val failures = connectionsMutex.withLock {
val conn = connections[address]
if (conn != null) {
conn.consecutiveKeepaliveFailures++
conn.consecutiveKeepaliveFailures
} else {
0
val failures =
connectionsMutex.withLock {
val conn = connections[address]
if (conn != null) {
conn.consecutiveKeepaliveFailures++
conn.consecutiveKeepaliveFailures
} else {
0
}
}
}
Log.w(
TAG,
"Keepalive failed for $address ($failures/${BleConstants.MAX_CONNECTION_FAILURES} failures)",

View File

@@ -193,6 +193,9 @@ class KotlinRNodeBridge(
@Volatile
private var bleServicesDiscovered = false
@Volatile
private var bleMtuCallbackReceived = false // Tracks if onMtuChanged callback fired
@Volatile
private var bleRssi: Int = -100 // Current RSSI (-100 = unknown)
@@ -208,6 +211,7 @@ class KotlinRNodeBridge(
private val writeMutex = Mutex()
// BLE write synchronization - Android BLE is async, we must wait for each write to complete
// Latch-based synchronization with null check prevents stale callbacks from corrupting state
@Volatile
private var bleWriteLatch: CountDownLatch? = null
private val bleWriteStatus = AtomicInteger(BluetoothGatt.GATT_SUCCESS)
@@ -578,6 +582,7 @@ class KotlinRNodeBridge(
// Reset BLE state
bleConnected = false
bleServicesDiscovered = false
bleMtuCallbackReceived = false
bleRxCharacteristic = null
bleTxCharacteristic = null
@@ -690,10 +695,20 @@ class KotlinRNodeBridge(
) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.i(TAG, "BLE connected, discovering services...")
Log.i(TAG, "BLE connected, requesting MTU...")
bleConnected = true
bleMtuCallbackReceived = false
// Request higher MTU for better throughput
gatt.requestMtu(512)
// Timeout: if onMtuChanged doesn't fire within 2 seconds,
// proceed with service discovery anyway to prevent hang
scope.launch {
delay(2000)
if (!bleMtuCallbackReceived && bleConnected) {
Log.w(TAG, "MTU callback timeout, proceeding with service discovery")
gatt.discoverServices()
}
}
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.i(TAG, "BLE disconnected (status=${gattStatusToString(status)})")
@@ -710,9 +725,12 @@ class KotlinRNodeBridge(
mtu: Int,
status: Int,
) {
bleMtuCallbackReceived = true
if (status == BluetoothGatt.GATT_SUCCESS) {
bleMtu = mtu
Log.d(TAG, "BLE MTU changed to $mtu")
} else {
Log.w(TAG, "MTU change failed with status $status, using default MTU")
}
// Discover services after MTU negotiation
gatt.discoverServices()
@@ -784,9 +802,17 @@ class KotlinRNodeBridge(
status: Int,
) {
// Signal write completion to waiting thread
bleWriteStatus.set(status)
// Use write ID to prevent race condition where delayed callback
// corrupts status for a different write operation
synchronized(bleWriteLock) {
bleWriteLatch?.countDown()
val currentLatch = bleWriteLatch
if (currentLatch != null) {
bleWriteStatus.set(status)
currentLatch.countDown()
} else {
// Stale callback - latch was already cleared (write timed out or completed)
Log.w(TAG, "Ignoring stale BLE write callback (no latch)")
}
}
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "BLE write failed: $status")
@@ -822,6 +848,7 @@ class KotlinRNodeBridge(
bleTxCharacteristic = null
bleConnected = false
bleServicesDiscovered = false
bleMtuCallbackReceived = false
}
/**
@@ -1096,6 +1123,7 @@ class KotlinRNodeBridge(
val chunkData = chunk.toByteArray()
// Create latch BEFORE starting write so callback can find it
// Latch null check in callback prevents stale callbacks from corrupting state
val latch = CountDownLatch(1)
synchronized(bleWriteLock) {
bleWriteLatch = latch

View File

@@ -408,10 +408,11 @@ class KotlinBLEBridgeDeduplicationTest {
bridge: KotlinBLEBridge,
deviceName: String?,
): Boolean {
val method = KotlinBLEBridge::class.java.getDeclaredMethod(
"shouldSkipDiscoveredDevice",
String::class.java,
)
val method =
KotlinBLEBridge::class.java.getDeclaredMethod(
"shouldSkipDiscoveredDevice",
String::class.java,
)
method.isAccessible = true
return method.invoke(bridge, deviceName) as Boolean
}

View File

@@ -15,7 +15,6 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.ConcurrentHashMap
/**
* Unit tests for BleGattClient keepalive failure tracking.
@@ -175,8 +174,9 @@ class BleGattClientKeepaliveTest {
fun `time to disconnect after continuous failures is approximately 45 seconds`() {
// With 15s interval and 3 failures needed:
// Failure 1 at ~15s, Failure 2 at ~30s, Failure 3 at ~45s -> disconnect
val expectedTimeToDisconnect = BleConstants.CONNECTION_KEEPALIVE_INTERVAL_MS *
BleConstants.MAX_CONNECTION_FAILURES
val expectedTimeToDisconnect =
BleConstants.CONNECTION_KEEPALIVE_INTERVAL_MS *
BleConstants.MAX_CONNECTION_FAILURES
assertEquals(
"Should disconnect after ~45 seconds of failures",
@@ -216,9 +216,10 @@ class BleGattClientKeepaliveTest {
every { mockGatt.device } returns mockDevice
every { mockDevice.address } returns address
val connectionDataClass = Class.forName(
"com.lxmf.messenger.reticulum.ble.client.BleGattClient\$ConnectionData",
)
val connectionDataClass =
Class.forName(
"com.lxmf.messenger.reticulum.ble.client.BleGattClient\$ConnectionData",
)
// Find constructor with parameters:
// gatt: BluetoothGatt, address: String, mtu: Int, rxCharacteristic, txCharacteristic,

View File

@@ -14,15 +14,16 @@ class InterfaceConfigExtTest {
@Test
fun `AutoInterface toJsonString contains all fields`() {
val config = InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
groupId = "test-group",
discoveryScope = "site",
discoveryPort = 12345,
dataPort = 12346,
mode = "gateway",
)
val config =
InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
groupId = "test-group",
discoveryScope = "site",
discoveryPort = 12345,
dataPort = 12346,
mode = "gateway",
)
val json = JSONObject(config.toJsonString())
@@ -35,15 +36,16 @@ class InterfaceConfigExtTest {
@Test
fun `AutoInterface toJsonString omits both ports when null`() {
val config = InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
groupId = "test-group",
discoveryScope = "link",
discoveryPort = null,
dataPort = null,
mode = "full",
)
val config =
InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
groupId = "test-group",
discoveryScope = "link",
discoveryPort = null,
dataPort = null,
mode = "full",
)
val json = JSONObject(config.toJsonString())
@@ -56,12 +58,13 @@ class InterfaceConfigExtTest {
@Test
fun `AutoInterface toJsonString includes only discoveryPort when dataPort is null`() {
val config = InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
discoveryPort = 29716,
dataPort = null,
)
val config =
InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
discoveryPort = 29716,
dataPort = null,
)
val json = JSONObject(config.toJsonString())
@@ -72,12 +75,13 @@ class InterfaceConfigExtTest {
@Test
fun `AutoInterface toJsonString includes only dataPort when discoveryPort is null`() {
val config = InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
discoveryPort = null,
dataPort = 42671,
)
val config =
InterfaceConfig.AutoInterface(
name = "Test Auto",
enabled = true,
discoveryPort = null,
dataPort = 42671,
)
val json = JSONObject(config.toJsonString())
@@ -88,16 +92,17 @@ class InterfaceConfigExtTest {
@Test
fun `TCPClient toJsonString contains all fields`() {
val config = InterfaceConfig.TCPClient(
name = "Test TCP",
enabled = true,
targetHost = "10.0.0.1",
targetPort = 4242,
kissFraming = true,
mode = "full",
networkName = "testnet",
passphrase = "secret",
)
val config =
InterfaceConfig.TCPClient(
name = "Test TCP",
enabled = true,
targetHost = "10.0.0.1",
targetPort = 4242,
kissFraming = true,
mode = "full",
networkName = "testnet",
passphrase = "secret",
)
val json = JSONObject(config.toJsonString())
@@ -111,14 +116,15 @@ class InterfaceConfigExtTest {
@Test
fun `TCPClient toJsonString omits null networkName and passphrase`() {
val config = InterfaceConfig.TCPClient(
name = "Test TCP",
enabled = true,
targetHost = "10.0.0.1",
targetPort = 4242,
networkName = null,
passphrase = null,
)
val config =
InterfaceConfig.TCPClient(
name = "Test TCP",
enabled = true,
targetHost = "10.0.0.1",
targetPort = 4242,
networkName = null,
passphrase = null,
)
val json = JSONObject(config.toJsonString())
@@ -128,21 +134,22 @@ class InterfaceConfigExtTest {
@Test
fun `RNode toJsonString contains all fields`() {
val config = InterfaceConfig.RNode(
name = "Test RNode",
enabled = true,
targetDeviceName = "RNode-BT",
connectionMode = "ble",
frequency = 868000000L,
bandwidth = 250000,
txPower = 14,
spreadingFactor = 9,
codingRate = 7,
stAlock = 5.0,
ltAlock = 10.0,
mode = "roaming",
enableFramebuffer = true,
)
val config =
InterfaceConfig.RNode(
name = "Test RNode",
enabled = true,
targetDeviceName = "RNode-BT",
connectionMode = "ble",
frequency = 868000000L,
bandwidth = 250000,
txPower = 14,
spreadingFactor = 9,
codingRate = 7,
stAlock = 5.0,
ltAlock = 10.0,
mode = "roaming",
enableFramebuffer = true,
)
val json = JSONObject(config.toJsonString())
@@ -161,13 +168,14 @@ class InterfaceConfigExtTest {
@Test
fun `RNode toJsonString omits null airtime limits`() {
val config = InterfaceConfig.RNode(
name = "Test RNode",
enabled = true,
targetDeviceName = "RNode-BT",
stAlock = null,
ltAlock = null,
)
val config =
InterfaceConfig.RNode(
name = "Test RNode",
enabled = true,
targetDeviceName = "RNode-BT",
stAlock = null,
ltAlock = null,
)
val json = JSONObject(config.toJsonString())
@@ -177,15 +185,16 @@ class InterfaceConfigExtTest {
@Test
fun `UDP toJsonString contains all fields`() {
val config = InterfaceConfig.UDP(
name = "Test UDP",
enabled = true,
listenIp = "192.168.1.1",
listenPort = 5000,
forwardIp = "192.168.1.255",
forwardPort = 5001,
mode = "boundary",
)
val config =
InterfaceConfig.UDP(
name = "Test UDP",
enabled = true,
listenIp = "192.168.1.1",
listenPort = 5000,
forwardIp = "192.168.1.255",
forwardPort = 5001,
mode = "boundary",
)
val json = JSONObject(config.toJsonString())
@@ -198,13 +207,14 @@ class InterfaceConfigExtTest {
@Test
fun `AndroidBLE toJsonString contains all fields`() {
val config = InterfaceConfig.AndroidBLE(
name = "Test BLE",
enabled = true,
deviceName = "MyDevice",
maxConnections = 5,
mode = "access_point",
)
val config =
InterfaceConfig.AndroidBLE(
name = "Test BLE",
enabled = true,
deviceName = "MyDevice",
maxConnections = 5,
mode = "access_point",
)
val json = JSONObject(config.toJsonString())
@@ -215,13 +225,14 @@ class InterfaceConfigExtTest {
@Test
fun `AndroidBLE toJsonString handles empty deviceName`() {
val config = InterfaceConfig.AndroidBLE(
name = "Test BLE",
enabled = true,
deviceName = "",
maxConnections = 7,
mode = "roaming",
)
val config =
InterfaceConfig.AndroidBLE(
name = "Test BLE",
enabled = true,
deviceName = "",
maxConnections = 7,
mode = "roaming",
)
val json = JSONObject(config.toJsonString())