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:
torlando-tech
2025-12-05 15:58:21 -05:00
parent acb10d8e95
commit d91287ef66
11 changed files with 467 additions and 73 deletions

View File

@@ -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();
}

View File

@@ -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)
}
}
}

View File

@@ -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
// ===========================================

View File

@@ -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

View File

@@ -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")
}
},
)
}

View File

@@ -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
}
/**

View File

@@ -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.
*/

View File

@@ -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.&lt;no name provided&gt;$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>

View File

@@ -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 ""

View File

@@ -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]:

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:KotlinBLEBridge.kt$KotlinBLEBridge$peer.isCentral &amp;&amp; !peer.isPeripheral || !peer.isCentral &amp;&amp; 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&lt;Unit&gt;</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>