mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
feat: add automatic RNode reconnection and interface status UI
RNode Auto-Reconnection: - RNodeCompanionService now triggers reconnection when CompanionDeviceManager detects the RNode has reappeared after going out of BLE range - Add reconnectRNodeInterface() to AIDL interface and ReticulumServiceBinder - Add thread-safe initialization lock in reticulum_wrapper.py to prevent concurrent RNode initialization race conditions - Use 2-second debounce delay before reconnecting to ensure device stability Interface Status UI Improvements: - InterfaceManagementViewModel now polls Reticulum every 3 seconds for interface online/offline status - Update isBleInterface() to include RNode type for proper BLE handling - Add "Interface Offline" error state to getErrorMessage() for enabled interfaces that aren't passing traffic - Make error badges clickable to show detailed error dialog - Add InterfaceErrorDialog component for detailed interface issue info - IdentityScreen: make offline interface rows clickable for troubleshooting Build & Deploy: - deploy.sh now supports multiple connected devices, deploying to all of them in sequence instead of requiring a single device 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -254,4 +254,11 @@ interface IReticulumService {
|
||||
* @return RSSI in dBm, or -100 if not connected or not available
|
||||
*/
|
||||
int getRNodeRssi();
|
||||
|
||||
/**
|
||||
* Reconnect to the RNode interface.
|
||||
* Called when CompanionDeviceManager detects the RNode has reappeared
|
||||
* after going out of BLE range.
|
||||
*/
|
||||
void reconnectRNodeInterface();
|
||||
}
|
||||
|
||||
@@ -3,9 +3,17 @@ package com.lxmf.messenger.service
|
||||
import android.annotation.SuppressLint
|
||||
import android.companion.AssociationInfo
|
||||
import android.companion.CompanionDeviceService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.lxmf.messenger.IReticulumService
|
||||
|
||||
/**
|
||||
* Companion Device Service for RNode Bluetooth devices.
|
||||
@@ -18,6 +26,9 @@ import androidx.annotation.RequiresApi
|
||||
* - Bound when the associated RNode is within BLE range or connected via Bluetooth
|
||||
* - Unbound when the device moves out of range or disconnects
|
||||
*
|
||||
* When the RNode reappears (comes back into BLE range), this service triggers
|
||||
* the ReticulumBridgeService to reconnect to the RNode interface.
|
||||
*
|
||||
* Requires Android 12 (API 31) or higher.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@@ -25,6 +36,29 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RNodeCompanionService"
|
||||
private const val RECONNECT_DELAY_MS = 2000L // Wait 2s before reconnecting to ensure device is stable
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var pendingReconnect: Runnable? = null
|
||||
private var reticulumService: IReticulumService? = null
|
||||
private var isServiceBound = false
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
Log.d(TAG, "Connected to ReticulumBridgeService")
|
||||
reticulumService = IReticulumService.Stub.asInterface(binder)
|
||||
isServiceBound = true
|
||||
|
||||
// Now that we're connected, trigger the reconnection
|
||||
triggerReconnection()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
Log.d(TAG, "Disconnected from ReticulumBridgeService")
|
||||
reticulumService = null
|
||||
isServiceBound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -35,6 +69,20 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d(TAG, "RNodeCompanionService destroyed")
|
||||
|
||||
// Cancel any pending reconnect
|
||||
pendingReconnect?.let { handler.removeCallbacks(it) }
|
||||
pendingReconnect = null
|
||||
|
||||
// Unbind from service if bound
|
||||
if (isServiceBound) {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error unbinding from service", e)
|
||||
}
|
||||
isServiceBound = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +91,8 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onDeviceAppeared(associationInfo: AssociationInfo) {
|
||||
Log.i(TAG, "RNode device appeared: ${associationInfo.displayName ?: "Unknown"}")
|
||||
val deviceName = associationInfo.displayName ?: "Unknown"
|
||||
Log.i(TAG, "RNode device appeared: $deviceName")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val device = associationInfo.associatedDevice?.bluetoothDevice
|
||||
@@ -51,6 +100,9 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
Log.d(TAG, "Device: ${device.name ?: device.address}")
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule reconnection with debounce
|
||||
scheduleReconnection()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +111,13 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
*/
|
||||
override fun onDeviceDisappeared(associationInfo: AssociationInfo) {
|
||||
Log.i(TAG, "RNode device disappeared: ${associationInfo.displayName ?: "Unknown"}")
|
||||
|
||||
// Cancel any pending reconnection if device disappears
|
||||
pendingReconnect?.let {
|
||||
handler.removeCallbacks(it)
|
||||
Log.d(TAG, "Cancelled pending reconnection due to device disappearance")
|
||||
}
|
||||
pendingReconnect = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +127,7 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
@Deprecated("Use onDeviceAppeared(AssociationInfo) for Android 13+")
|
||||
override fun onDeviceAppeared(address: String) {
|
||||
Log.i(TAG, "RNode device appeared (legacy): $address")
|
||||
scheduleReconnection()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,5 +137,68 @@ class RNodeCompanionService : CompanionDeviceService() {
|
||||
@Deprecated("Use onDeviceDisappeared(AssociationInfo) for Android 13+")
|
||||
override fun onDeviceDisappeared(address: String) {
|
||||
Log.i(TAG, "RNode device disappeared (legacy): $address")
|
||||
|
||||
// Cancel any pending reconnection
|
||||
pendingReconnect?.let { handler.removeCallbacks(it) }
|
||||
pendingReconnect = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection with debounce to avoid spamming reconnection attempts
|
||||
* if the device appears/disappears rapidly.
|
||||
*/
|
||||
private fun scheduleReconnection() {
|
||||
// Cancel any existing pending reconnection
|
||||
pendingReconnect?.let { handler.removeCallbacks(it) }
|
||||
|
||||
Log.d(TAG, "Scheduling RNode reconnection in ${RECONNECT_DELAY_MS}ms")
|
||||
|
||||
pendingReconnect = Runnable {
|
||||
Log.d(TAG, "Executing scheduled RNode reconnection")
|
||||
bindAndReconnect()
|
||||
}
|
||||
|
||||
handler.postDelayed(pendingReconnect!!, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to ReticulumBridgeService and trigger reconnection.
|
||||
*/
|
||||
private fun bindAndReconnect() {
|
||||
if (isServiceBound && reticulumService != null) {
|
||||
// Already bound, just trigger reconnection
|
||||
triggerReconnection()
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Binding to ReticulumBridgeService for reconnection")
|
||||
|
||||
try {
|
||||
val intent = Intent(this, ReticulumService::class.java)
|
||||
val bound = bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
if (!bound) {
|
||||
Log.e(TAG, "Failed to bind to ReticulumBridgeService")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error binding to ReticulumBridgeService", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the reconnectRNodeInterface method on the service.
|
||||
*/
|
||||
private fun triggerReconnection() {
|
||||
val service = reticulumService
|
||||
if (service == null) {
|
||||
Log.e(TAG, "Cannot trigger reconnection - service is null")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Triggering RNode interface reconnection")
|
||||
service.reconnectRNodeInterface()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calling reconnectRNodeInterface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,29 @@ class ReticulumServiceBinder(
|
||||
return bridge.getRssi()
|
||||
}
|
||||
|
||||
override fun reconnectRNodeInterface() {
|
||||
Log.i(TAG, "reconnectRNodeInterface() called - attempting to reconnect RNode")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
wrapperManager.withWrapper { wrapper ->
|
||||
val result = wrapper.callAttr("initialize_rnode_interface")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val resultDict = result?.asMap() as? Map<com.chaquo.python.PyObject, com.chaquo.python.PyObject>
|
||||
val success = resultDict?.entries?.find { it.key.toString() == "success" }?.value?.toBoolean() ?: false
|
||||
if (success) {
|
||||
val message = resultDict?.entries?.find { it.key.toString() == "message" }?.value?.toString()
|
||||
Log.i(TAG, "RNode interface reconnected: ${message ?: "success"}")
|
||||
} else {
|
||||
val error = resultDict?.entries?.find { it.key.toString() == "error" }?.value?.toString() ?: "Unknown error"
|
||||
Log.e(TAG, "Failed to reconnect RNode interface: $error")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reconnecting RNode interface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Private Helpers
|
||||
// ===========================================
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -336,6 +337,8 @@ fun ReticulumInfoCard(debugInfo: DebugInfo) {
|
||||
|
||||
@Composable
|
||||
fun InterfacesCard(interfaces: List<InterfaceInfo>) {
|
||||
var selectedInterface by remember { mutableStateOf<InterfaceInfo?>(null) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -363,15 +366,63 @@ fun InterfacesCard(interfaces: List<InterfaceInfo>) {
|
||||
)
|
||||
} else {
|
||||
interfaces.forEach { iface ->
|
||||
InterfaceRow(iface)
|
||||
InterfaceRow(
|
||||
iface = iface,
|
||||
onClick = if (!iface.online) {
|
||||
{ selectedInterface = iface }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error dialog for offline interfaces
|
||||
selectedInterface?.let { iface ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedInterface = null },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = { Text("Interface Offline") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = iface.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "This interface is currently offline and not passing traffic.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Check that the device is powered on, in range, and properly configured.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { selectedInterface = null }) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InterfaceRow(iface: InterfaceInfo) {
|
||||
fun InterfaceRow(
|
||||
iface: InterfaceInfo,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -380,6 +431,13 @@ fun InterfaceRow(iface: InterfaceInfo) {
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(8.dp),
|
||||
)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -400,7 +458,7 @@ fun InterfaceRow(iface: InterfaceInfo) {
|
||||
|
||||
Icon(
|
||||
imageVector = if (iface.online) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = if (iface.online) "Online" else "Offline",
|
||||
contentDescription = if (iface.online) "Online" else "Offline - tap for details",
|
||||
tint =
|
||||
if (iface.online) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
|
||||
@@ -88,6 +88,9 @@ fun InterfaceManagementScreen(
|
||||
// State for delete confirmation dialog
|
||||
var interfaceToDelete by remember { mutableStateOf<InterfaceEntity?>(null) }
|
||||
|
||||
// State for error dialog
|
||||
var errorDialogInterface by remember { mutableStateOf<InterfaceEntity?>(null) }
|
||||
|
||||
// State for interface type selection
|
||||
var showTypeSelector by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -232,6 +235,7 @@ fun InterfaceManagementScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.interfaces) { iface ->
|
||||
val isOnline = state.interfaceOnlineStatus[iface.name]
|
||||
InterfaceCard(
|
||||
interfaceEntity = iface,
|
||||
onToggle = { enabled ->
|
||||
@@ -249,6 +253,10 @@ fun InterfaceManagementScreen(
|
||||
onDelete = { interfaceToDelete = iface },
|
||||
bluetoothState = state.bluetoothState,
|
||||
blePermissionsGranted = state.blePermissionsGranted,
|
||||
isOnline = isOnline,
|
||||
onErrorClick = {
|
||||
errorDialogInterface = iface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -330,6 +338,23 @@ fun InterfaceManagementScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Interface Error Dialog
|
||||
errorDialogInterface?.let { iface ->
|
||||
val isOnline = state.interfaceOnlineStatus[iface.name]
|
||||
val errorMessage = iface.getErrorMessage(
|
||||
state.bluetoothState,
|
||||
state.blePermissionsGranted,
|
||||
isOnline,
|
||||
)
|
||||
if (errorMessage != null) {
|
||||
InterfaceErrorDialog(
|
||||
interfaceName = iface.name,
|
||||
errorMessage = errorMessage,
|
||||
onDismiss = { errorDialogInterface = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Changes Blocking Dialog
|
||||
if (state.isApplyingChanges) {
|
||||
ApplyChangesDialog()
|
||||
@@ -381,10 +406,12 @@ fun InterfaceCard(
|
||||
onDelete: () -> Unit,
|
||||
bluetoothState: Int,
|
||||
blePermissionsGranted: Boolean,
|
||||
isOnline: Boolean? = null,
|
||||
onErrorClick: (() -> Unit)? = null,
|
||||
) {
|
||||
// Determine if toggle should be enabled and if there's an error
|
||||
val toggleEnabled = interfaceEntity.shouldToggleBeEnabled(bluetoothState, blePermissionsGranted)
|
||||
val errorMessage = interfaceEntity.getErrorMessage(bluetoothState, blePermissionsGranted)
|
||||
val errorMessage = interfaceEntity.getErrorMessage(bluetoothState, blePermissionsGranted, isOnline)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -452,26 +479,54 @@ fun InterfaceCard(
|
||||
|
||||
// Error Badge (only show if interface is enabled and there's an error)
|
||||
if (interfaceEntity.enabled && errorMessage != null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
if (onErrorClick != null) {
|
||||
// Clickable error badge
|
||||
Surface(
|
||||
onClick = onErrorClick,
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = "Tap for details",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-clickable error badge
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -861,3 +916,45 @@ private fun InterfaceTypeOption(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog to show detailed interface error information.
|
||||
*/
|
||||
@Composable
|
||||
fun InterfaceErrorDialog(
|
||||
interfaceName: String,
|
||||
errorMessage: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = { Text("Interface Issue") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = interfaceName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import com.lxmf.messenger.data.database.entity.InterfaceEntity
|
||||
|
||||
/**
|
||||
* Check if this interface is a BLE (Bluetooth Low Energy) interface.
|
||||
* Both AndroidBLE and RNode interfaces use BLE for connectivity.
|
||||
*/
|
||||
fun InterfaceEntity.isBleInterface(): Boolean {
|
||||
return this.type == "AndroidBLE"
|
||||
return this.type == "AndroidBLE" || this.type == "RNode"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,26 +37,32 @@ fun InterfaceEntity.canOperate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an error message explaining why this BLE interface cannot operate.
|
||||
* Get an error message explaining why this interface has an issue.
|
||||
*
|
||||
* @param bluetoothState Current Bluetooth adapter state
|
||||
* @param permissionsGranted Whether BLE permissions are granted
|
||||
* @param isOnline Whether the interface is currently online (from debug info)
|
||||
* @return Error message string, or null if no error
|
||||
*/
|
||||
fun InterfaceEntity.getErrorMessage(
|
||||
bluetoothState: Int,
|
||||
permissionsGranted: Boolean,
|
||||
isOnline: Boolean? = null,
|
||||
): String? {
|
||||
if (!isBleInterface()) {
|
||||
// Non-BLE interfaces don't have BT-related errors
|
||||
return null
|
||||
// Check BLE-related errors for BLE interfaces
|
||||
if (isBleInterface()) {
|
||||
when {
|
||||
bluetoothState != BluetoothAdapter.STATE_ON -> return "Bluetooth Off"
|
||||
!permissionsGranted -> return "Permission Required"
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
bluetoothState != BluetoothAdapter.STATE_ON -> "Bluetooth Off"
|
||||
!permissionsGranted -> "Permission Required"
|
||||
else -> null
|
||||
// Check online status for all enabled interfaces
|
||||
if (enabled && isOnline == false) {
|
||||
return "Interface Offline"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,10 +9,12 @@ import com.lxmf.messenger.data.model.BleConnectionsState
|
||||
import com.lxmf.messenger.data.repository.BleStatusRepository
|
||||
import com.lxmf.messenger.repository.InterfaceRepository
|
||||
import com.lxmf.messenger.reticulum.model.InterfaceConfig
|
||||
import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
|
||||
import com.lxmf.messenger.service.InterfaceConfigManager
|
||||
import com.lxmf.messenger.util.validation.InputValidator
|
||||
import com.lxmf.messenger.util.validation.ValidationResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -45,6 +47,8 @@ data class InterfaceManagementState(
|
||||
val blePermissionsGranted: Boolean = false,
|
||||
// Info message for transient notifications (lighter than error/success)
|
||||
val infoMessage: String? = null,
|
||||
// Interface online status from Python/RNS (interface name -> online status)
|
||||
val interfaceOnlineStatus: Map<String, Boolean> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -107,9 +111,11 @@ class InterfaceManagementViewModel
|
||||
private val interfaceRepository: InterfaceRepository,
|
||||
private val configManager: InterfaceConfigManager,
|
||||
private val bleStatusRepository: BleStatusRepository,
|
||||
private val reticulumProtocol: ReticulumProtocol,
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "InterfaceMgmtVM"
|
||||
private const val STATUS_POLL_INTERVAL_MS = 3000L
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(InterfaceManagementState())
|
||||
@@ -126,6 +132,7 @@ class InterfaceManagementViewModel
|
||||
loadInterfaces()
|
||||
observeBluetoothState()
|
||||
checkExternalPendingChanges()
|
||||
startPollingInterfaceStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +145,44 @@ class InterfaceManagementViewModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for interface online status from Python/RNS.
|
||||
*/
|
||||
private fun startPollingInterfaceStatus() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
try {
|
||||
fetchInterfaceStatus()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error polling interface status", e)
|
||||
}
|
||||
delay(STATUS_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch interface online status from Reticulum.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private suspend fun fetchInterfaceStatus() {
|
||||
try {
|
||||
val debugInfo = reticulumProtocol.getDebugInfo()
|
||||
val interfacesData = debugInfo["interfaces"] as? List<Map<String, Any>> ?: return
|
||||
|
||||
val statusMap = mutableMapOf<String, Boolean>()
|
||||
for (ifaceMap in interfacesData) {
|
||||
val name = ifaceMap["name"] as? String ?: continue
|
||||
val online = ifaceMap["online"] as? Boolean ?: false
|
||||
statusMap[name] = online
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(interfaceOnlineStatus = statusMap)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch interface status", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all interfaces from the database.
|
||||
*/
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues></ManuallySuppressedIssues>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>CyclomaticComplexMethod:AnnounceEntity.kt$AnnounceEntity$override fun equals(other: Any?): Boolean</ID>
|
||||
<ID>CyclomaticComplexMethod:EnrichedContact.kt$EnrichedContact$override fun equals(other: Any?): Boolean</ID>
|
||||
<ID>LongMethod:DatabaseModule.kt$DatabaseModule.<no name provided>$override fun migrate(database: SupportSQLiteDatabase)</ID>
|
||||
<ID>LongParameterList:AnnounceRepository.kt$AnnounceRepository$( destinationHash: String, peerName: String, publicKey: ByteArray, appData: ByteArray?, hops: Int, timestamp: Long, nodeType: String, receivingInterface: String? = null, aspect: String? = null, )</ID>
|
||||
<ID>LongParameterList:CustomThemeRepository.kt$CustomThemeRepository$( id: Long, name: String, description: String, seedPrimary: Int, seedSecondary: Int, seedTertiary: Int, lightColors: ThemeColorSet, darkColors: ThemeColorSet, baseTheme: String? = null, )</ID>
|
||||
<ID>LongParameterList:CustomThemeRepository.kt$CustomThemeRepository$( name: String, description: String, seedPrimary: Int, seedSecondary: Int, seedTertiary: Int, lightColors: ThemeColorSet, darkColors: ThemeColorSet, baseTheme: String? = null, )</ID>
|
||||
<ID>MaxLineLength:AnnounceDao.kt$AnnounceDao$"SELECT * FROM announces WHERE isFavorite = 1 AND (peerName LIKE '%' || :query || '%' OR destinationHash LIKE '%' || :query || '%') ORDER BY favoritedTimestamp DESC"</ID>
|
||||
<ID>MaxLineLength:AnnounceDao.kt$AnnounceDao$"SELECT * FROM announces WHERE peerName LIKE '%' || :query || '%' OR destinationHash LIKE '%' || :query || '%' ORDER BY lastSeenTimestamp DESC"</ID>
|
||||
<ID>MaxLineLength:AnnounceDao.kt$AnnounceDao$@Query("UPDATE announces SET isFavorite = :isFavorite, favoritedTimestamp = :timestamp WHERE destinationHash = :destinationHash")</ID>
|
||||
<ID>MaxLineLength:ConversationRepository.kt$ConversationRepository$android.util.Log.d("ConversationRepository", "Returning ${identitiesWithKeys.size} peer identities for restoration")</ID>
|
||||
<ID>MaxLineLength:ConversationRepository.kt$ConversationRepository$android.util.Log.d("ConversationRepository", "Stored public key for peer: $peerHash (${publicKey.size} bytes)")</ID>
|
||||
@@ -37,7 +34,6 @@
|
||||
<ID>TooManyFunctions:AnnounceRepository.kt$AnnounceRepository</ID>
|
||||
<ID>TooManyFunctions:ContactRepository.kt$ContactRepository</ID>
|
||||
<ID>TooManyFunctions:ConversationRepository.kt$ConversationRepository</ID>
|
||||
<ID>UseCheckOrError:AnnounceRepository.kt$AnnounceRepository$throw IllegalStateException("No active identity found")</ID>
|
||||
<ID>UseCheckOrError:ConversationRepository.kt$ConversationRepository$throw IllegalStateException("No active identity found")</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
||||
81
deploy.sh
81
deploy.sh
@@ -100,7 +100,8 @@ print_success "Build complete: $APK_PATH"
|
||||
|
||||
# Step 3: Check for connected devices
|
||||
print_info "Checking for connected devices..."
|
||||
DEVICE_COUNT=$(adb devices | grep -v "List of devices" | grep -c "device$" || true)
|
||||
DEVICES=$(adb devices | grep "device$" | awk '{print $1}')
|
||||
DEVICE_COUNT=$(echo "$DEVICES" | grep -c . || true)
|
||||
|
||||
if [ "$DEVICE_COUNT" -eq 0 ]; then
|
||||
print_error "No Android devices found"
|
||||
@@ -110,45 +111,59 @@ if [ "$DEVICE_COUNT" -eq 0 ]; then
|
||||
echo " 2. Device is authorized (check phone screen)"
|
||||
echo " 3. ADB can detect the device: adb devices"
|
||||
exit 1
|
||||
elif [ "$DEVICE_COUNT" -gt 1 ]; then
|
||||
print_info "Multiple devices connected:"
|
||||
adb devices
|
||||
print_error "Please connect only one device or specify target with ADB_SERIAL environment variable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEVICE_INFO=$(adb devices | grep "device$" | head -n 1)
|
||||
print_success "Device connected: $DEVICE_INFO"
|
||||
print_success "Found $DEVICE_COUNT device(s)"
|
||||
echo ""
|
||||
|
||||
# Step 4: Install APK
|
||||
print_info "Installing APK to device..."
|
||||
if ! adb install -r -d "$APK_PATH" 2>&1; then
|
||||
echo ""
|
||||
print_error "Installation failed (likely signature mismatch)"
|
||||
echo ""
|
||||
echo "This usually means the Gradle daemon has stale environment variables."
|
||||
echo "Try: ./gradlew --stop && ./deploy.sh"
|
||||
echo ""
|
||||
echo "If you want to uninstall and lose app data, run:"
|
||||
echo " adb uninstall $PACKAGE_NAME && ./deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Installation complete"
|
||||
# Step 4: Deploy to each device
|
||||
FAILED_DEVICES=()
|
||||
for DEVICE_SERIAL in $DEVICES; do
|
||||
print_header "Deploying to device: $DEVICE_SERIAL"
|
||||
|
||||
# Step 5: Launch app (optional)
|
||||
if [ "$LAUNCH" = true ]; then
|
||||
print_info "Launching app..."
|
||||
adb shell am start -n "${PACKAGE_NAME}/${MAIN_ACTIVITY}"
|
||||
print_success "App launched"
|
||||
|
||||
# Step 6: Show logs (optional)
|
||||
if [ "$LOGS" = true ]; then
|
||||
# Install APK
|
||||
print_info "Installing APK to $DEVICE_SERIAL..."
|
||||
if ! adb -s "$DEVICE_SERIAL" install -r -d "$APK_PATH" 2>&1; then
|
||||
echo ""
|
||||
print_info "Showing logcat (Ctrl+C to exit)..."
|
||||
print_error "Installation failed on $DEVICE_SERIAL (likely signature mismatch)"
|
||||
echo ""
|
||||
adb logcat -c # Clear logs
|
||||
adb logcat | grep -E "(${PACKAGE_NAME}|BLE|Reticulum|RNS)"
|
||||
echo "This usually means the Gradle daemon has stale environment variables."
|
||||
echo "Try: ./gradlew --stop && ./deploy.sh"
|
||||
echo ""
|
||||
echo "If you want to uninstall and lose app data, run:"
|
||||
echo " adb -s $DEVICE_SERIAL uninstall $PACKAGE_NAME && ./deploy.sh"
|
||||
FAILED_DEVICES+=("$DEVICE_SERIAL")
|
||||
continue
|
||||
fi
|
||||
print_success "Installation complete on $DEVICE_SERIAL"
|
||||
|
||||
# Launch app (optional)
|
||||
if [ "$LAUNCH" = true ]; then
|
||||
print_info "Launching app on $DEVICE_SERIAL..."
|
||||
adb -s "$DEVICE_SERIAL" shell am start -n "${PACKAGE_NAME}/${MAIN_ACTIVITY}"
|
||||
print_success "App launched on $DEVICE_SERIAL"
|
||||
|
||||
# Show logs (optional)
|
||||
if [ "$LOGS" = true ]; then
|
||||
echo ""
|
||||
print_info "Showing logcat for $DEVICE_SERIAL (Ctrl+C to exit)..."
|
||||
echo ""
|
||||
adb -s "$DEVICE_SERIAL" logcat -c # Clear logs
|
||||
adb -s "$DEVICE_SERIAL" logcat | grep -E "(${PACKAGE_NAME}|BLE|Reticulum|RNS)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Check if any deployments failed
|
||||
if [ ${#FAILED_DEVICES[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
print_error "Deployment failed on ${#FAILED_DEVICES[@]} device(s):"
|
||||
for FAILED_DEVICE in "${FAILED_DEVICES[@]}"; do
|
||||
echo " • $FAILED_DEVICE"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -108,6 +108,8 @@ class ReticulumWrapper:
|
||||
self.rnode_interface = None # ColumbaRNodeInterface instance (if enabled)
|
||||
self.kotlin_rnode_bridge = None # KotlinRNodeBridge instance (passed from Kotlin)
|
||||
self._pending_rnode_config = None # Stored RNode config during initialization
|
||||
self._rnode_init_lock = threading.Lock() # Lock to prevent concurrent RNode initialization
|
||||
self._rnode_initializing = False # Flag to track if RNode initialization is in progress
|
||||
|
||||
# Delivery status callback support (for event-driven message status updates)
|
||||
self.kotlin_delivery_status_callback = None # Callback to Kotlin for delivery status events
|
||||
@@ -2893,6 +2895,21 @@ class ReticulumWrapper:
|
||||
Returns:
|
||||
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
|
||||
with self._rnode_init_lock:
|
||||
if self._rnode_initializing:
|
||||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||||
"RNode initialization already in progress (after lock), skipping")
|
||||
return {'success': True, 'message': 'Initialization already in progress'}
|
||||
self._rnode_initializing = True
|
||||
|
||||
try:
|
||||
if not self.initialized:
|
||||
return {'success': False, 'error': 'Reticulum not initialized'}
|
||||
@@ -2943,6 +2960,9 @@ class ReticulumWrapper:
|
||||
traceback.print_exc()
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
finally:
|
||||
self._rnode_initializing = False
|
||||
|
||||
# ========== Identity Management Methods ==========
|
||||
|
||||
def _resolve_identity_file_path(self, identity_hash: str) -> Optional[str]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues></ManuallySuppressedIssues>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:KotlinBLEBridge.kt$KotlinBLEBridge$peer.isCentral && !peer.isPeripheral || !peer.isCentral && peer.isPeripheral</ID>
|
||||
<ID>ComplexCondition:KotlinBLEBridge.kt$KotlinBLEBridge$storedServiceUuid == null || storedRxCharUuid == null || storedTxCharUuid == null || storedIdentityCharUuid == null</ID>
|
||||
@@ -14,6 +14,7 @@
|
||||
<ID>LongMethod:BleGattClient.kt$BleGattClient$private suspend fun handleServicesDiscovered( address: String, gatt: BluetoothGatt, status: Int, )</ID>
|
||||
<ID>LongMethod:BleOperationQueue.kt$BleOperationQueue$private suspend fun executeOperation(operation: BleOperation): OperationResult</ID>
|
||||
<ID>LongMethod:KotlinBLEBridge.kt$KotlinBLEBridge$private suspend fun handleIdentityReceived( address: String, identityHash: String, isCentralConnection: Boolean, )</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:KotlinRNodeBridge.kt$KotlinRNodeBridge$while</ID>
|
||||
<ID>MaxLineLength:AppDataParser.kt$AppDataParser$val printableRatio = str.count { it.isLetterOrDigit() || it.isWhitespace() || it in ".-_@#'\"()[]{},:;!?" } / str.length.toFloat()</ID>
|
||||
<ID>MaxLineLength:BleAdvertiser.kt$BleAdvertiser$"RNS-${identity.take(BleConstants.IDENTITY_BYTES_IN_ADVERTISED_NAME).joinToString("") { "%02x".format(it) }}"</ID>
|
||||
<ID>MaxLineLength:BleAdvertiser.kt$BleAdvertiser$.</ID>
|
||||
@@ -56,6 +57,8 @@
|
||||
<ID>ReturnCount:KotlinBLEBridge.kt$KotlinBLEBridge$fun shouldConnect(peerAddress: String): Boolean</ID>
|
||||
<ID>ReturnCount:KotlinBLEBridge.kt$KotlinBLEBridge$suspend fun connect(address: String)</ID>
|
||||
<ID>ReturnCount:KotlinBLEBridge.kt$KotlinBLEBridge$suspend fun start( serviceUuid: String, rxCharUuid: String, txCharUuid: String, identityCharUuid: String, ): Result<Unit></ID>
|
||||
<ID>ReturnCount:KotlinRNodeBridge.kt$KotlinRNodeBridge$fun connect(deviceName: String, mode: String): Boolean</ID>
|
||||
<ID>ReturnCount:KotlinRNodeBridge.kt$KotlinRNodeBridge$private fun connectBle(deviceName: String, adapter: BluetoothAdapter): Boolean</ID>
|
||||
<ID>SwallowedException:BleAdvertiser.kt$BleAdvertiser$e: SecurityException</ID>
|
||||
<ID>SwallowedException:ErrorHandlingIntegrationTest.kt$ErrorHandlingIntegrationTest$e: Exception</ID>
|
||||
<ID>SwallowedException:ErrorHandlingIntegrationTest.kt$ErrorHandlingIntegrationTest$e: RuntimeException</ID>
|
||||
@@ -71,13 +74,13 @@
|
||||
<ID>TooManyFunctions:BleGattClient.kt$BleGattClient</ID>
|
||||
<ID>TooManyFunctions:BleGattServer.kt$BleGattServer</ID>
|
||||
<ID>TooManyFunctions:KotlinBLEBridge.kt$KotlinBLEBridge</ID>
|
||||
<ID>TooManyFunctions:KotlinRNodeBridge.kt$KotlinRNodeBridge</ID>
|
||||
<ID>TooManyFunctions:MockReticulumProtocol.kt$MockReticulumProtocol : ReticulumProtocol</ID>
|
||||
<ID>TooManyFunctions:ReticulumProtocol.kt$ReticulumProtocol</ID>
|
||||
<ID>UnusedParameter:BleGattClient.kt$BleGattClient$characteristic: BluetoothGattCharacteristic</ID>
|
||||
<ID>UnusedParameter:BleGattServer.kt$BleGattServer$preparedWrite: Boolean</ID>
|
||||
<ID>UnusedParameter:BleOperationQueue.kt$BleOperationQueue$key: String</ID>
|
||||
<ID>UnusedPrivateMember:BleConnectionManager.kt$BleConnectionManager$private fun getIdentityMac(identityHash: String): String?</ID>
|
||||
<ID>UnusedPrivateMember:KotlinBLEBridgeMacRotationTest.kt$KotlinBLEBridgeMacRotationTest$private fun setOnDisconnected(bridge: KotlinBLEBridge, callback: PyObject)</ID>
|
||||
<ID>UnusedPrivateProperty:ErrorHandlingIntegrationTest.kt$ErrorHandlingIntegrationTest$val disconnectOp = queue.enqueue( BleOperationQueue.BleOperation.Disconnect(mockGatt), timeoutMs = 500, )</ID>
|
||||
<ID>UnusedPrivateProperty:ErrorHandlingIntegrationTest.kt$ErrorHandlingIntegrationTest$val firstOpResult = try { queue.enqueue( BleOperationQueue.BleOperation.WriteCharacteristic( gatt = mockGatt, characteristic = mockChar, data = byteArrayOf(0x01), writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, ), timeoutMs = 1000, ) fail("Should have thrown exception") } catch (e: RuntimeException) { // Expected "exception_thrown" }</ID>
|
||||
<ID>UnusedPrivateProperty:ErrorHandlingIntegrationTest.kt$ErrorHandlingIntegrationTest$var advertiserStopped = false</ID>
|
||||
|
||||
Reference in New Issue
Block a user