feat: add event-driven UI refresh for RNode connection status

- Register interface with RNS.Transport before start() to fix race
  condition where auto-reconnect succeeds but interface wasn't tracked
- Add online status callback chain: Python → KotlinRNodeBridge →
  ReticulumServiceBinder → ServiceReticulumProtocol → ViewModel
- ViewModel now observes interfaceStatusChanged flow for immediate
  refresh when RNode connects/disconnects
- Change diagnostic logs from INFO to DEBUG level for production
- Add unit tests for RNodeOnlineStatusListener functionality

🤖 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 11:40:28 -05:00
parent dff463a7d0
commit d8300627a9
9 changed files with 480 additions and 26 deletions

View File

@@ -447,16 +447,20 @@ class ColumbaApplication : Application() {
return
}
android.util.Log.d("ColumbaApplication", "Found ${associations.size} companion device association(s)")
android.util.Log.d("ColumbaApplication", "████ COMPANION DEVICE REGISTRATION ████ Found ${associations.size} association(s)")
for (association in associations) {
try {
val macAddress = association.deviceMacAddress?.toString()
if (macAddress != null) {
android.util.Log.d(
"ColumbaApplication",
"████ REGISTERING OBSERVER ████ MAC=$macAddress name=${association.displayName}",
)
companionDeviceManager.startObservingDevicePresence(macAddress)
android.util.Log.d(
"ColumbaApplication",
"Registered device presence observer for: ${association.displayName ?: macAddress}",
"████ OBSERVER REGISTERED ████ MAC=$macAddress",
)
}
} catch (e: Exception) {

View File

@@ -32,7 +32,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -76,6 +78,11 @@ class ServiceReticulumProtocol(
private val _networkStatus = MutableStateFlow<NetworkStatus>(NetworkStatus.CONNECTING)
override val networkStatus: StateFlow<NetworkStatus> = _networkStatus.asStateFlow()
// SharedFlow for interface status change events (triggers UI refresh)
// replay=0 means events are not replayed to late subscribers
private val _interfaceStatusChanged = MutableSharedFlow<Unit>(replay = 0, extraBufferCapacity = 1)
val interfaceStatusChanged: SharedFlow<Unit> = _interfaceStatusChanged.asSharedFlow()
// Phase 2, Task 2.3: Readiness tracking for explicit service binding notification
// Thread-safe: Protected by bindLock to prevent race between callback and continuation storage
private var readinessContinuation: kotlin.coroutines.Continuation<Unit>? = null
@@ -287,6 +294,13 @@ class ServiceReticulumProtocol(
}
override fun onStatusChanged(status: String) {
// Handle RNode online/offline status changes - emit event to trigger UI refresh
if (status == "RNODE_ONLINE" || status == "RNODE_OFFLINE") {
Log.d(TAG, "████ RNODE STATUS EVENT ████ $status - triggering interface refresh")
_interfaceStatusChanged.tryEmit(Unit)
return // Don't update network status for interface-specific events
}
// Phase 2.1: Emit to StateFlow for reactive updates
val newStatus =
when {

View File

@@ -95,7 +95,7 @@ class RNodeCompanionService : CompanionDeviceService() {
@SuppressLint("MissingPermission")
override fun onDeviceAppeared(associationInfo: AssociationInfo) {
val deviceName = associationInfo.displayName ?: "Unknown"
Log.i(TAG, "RNode device appeared: $deviceName")
Log.d(TAG, "████ RNODE APPEARED ████ name=$deviceName")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val device = associationInfo.associatedDevice?.bluetoothDevice
@@ -113,7 +113,7 @@ class RNodeCompanionService : CompanionDeviceService() {
* Android 13+ (API 33) version with AssociationInfo.
*/
override fun onDeviceDisappeared(associationInfo: AssociationInfo) {
Log.i(TAG, "RNode device disappeared: ${associationInfo.displayName ?: "Unknown"}")
Log.d(TAG, "████ RNODE DISAPPEARED ████ name=${associationInfo.displayName ?: "Unknown"}")
// Cancel any pending reconnection if device disappears
pendingReconnect?.let {
@@ -129,7 +129,7 @@ class RNodeCompanionService : CompanionDeviceService() {
*/
@Deprecated("Use onDeviceAppeared(AssociationInfo) for Android 13+")
override fun onDeviceAppeared(address: String) {
Log.i(TAG, "RNode device appeared (legacy): $address")
Log.d(TAG, "████ RNODE APPEARED (legacy) ████ address=$address")
scheduleReconnection()
}
@@ -139,7 +139,7 @@ class RNodeCompanionService : CompanionDeviceService() {
*/
@Deprecated("Use onDeviceDisappeared(AssociationInfo) for Android 13+")
override fun onDeviceDisappeared(address: String) {
Log.i(TAG, "RNode device disappeared (legacy): $address")
Log.d(TAG, "████ RNODE DISAPPEARED (legacy) ████ address=$address")
// Cancel any pending reconnection
pendingReconnect?.let { handler.removeCallbacks(it) }

View File

@@ -111,6 +111,19 @@ class ReticulumServiceBinder(
},
)
// Register online status listener to trigger UI refresh when RNode connects/disconnects
rnodeBridge?.addOnlineStatusListener(
object : com.lxmf.messenger.reticulum.rnode.RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
Log.d(TAG, "████ RNODE ONLINE STATUS CHANGED ████ online=$isOnline")
// Broadcast status change so UI can refresh interface list
broadcaster.broadcastStatusChange(
if (isOnline) "RNODE_ONLINE" else "RNODE_OFFLINE",
)
}
},
)
wrapper.callAttr("set_rnode_bridge", rnodeBridge)
Log.d(TAG, "RNode bridge set before Python initialization")
} catch (e: Exception) {
@@ -442,10 +455,11 @@ class ReticulumServiceBinder(
}
override fun reconnectRNodeInterface() {
Log.i(TAG, "reconnectRNodeInterface() called - attempting to reconnect RNode")
Log.d(TAG, "████ RECONNECT RNODE ████ reconnectRNodeInterface() called")
scope.launch(Dispatchers.IO) {
try {
wrapperManager.withWrapper { wrapper ->
Log.d(TAG, "████ RECONNECT RNODE ████ calling Python initialize_rnode_interface()")
val result = wrapper.callAttr("initialize_rnode_interface")
@Suppress("UNCHECKED_CAST")
@@ -453,14 +467,14 @@ class ReticulumServiceBinder(
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"}")
Log.d(TAG, "████ RECONNECT RNODE SUCCESS ████ ${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")
Log.w(TAG, "████ RECONNECT RNODE FAILED ████ $error")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error reconnecting RNode interface", e)
Log.e(TAG, "████ RECONNECT RNODE ERROR ████", e)
}
}
}

View File

@@ -134,6 +134,7 @@ class InterfaceManagementViewModel
observeBluetoothState()
checkExternalPendingChanges()
startPollingInterfaceStatus()
observeInterfaceStatusChanges()
}
/**
@@ -162,6 +163,32 @@ class InterfaceManagementViewModel
}
}
/**
* Observe interface status change events for immediate refresh.
* This provides event-driven updates when RNode connects/disconnects,
* supplementing the polling mechanism for faster UI updates.
*/
private fun observeInterfaceStatusChanges() {
// Check if protocol is ServiceReticulumProtocol which has the event flow
val serviceProtocol =
reticulumProtocol as? com.lxmf.messenger.reticulum.protocol.ServiceReticulumProtocol
if (serviceProtocol == null) {
Log.d(TAG, "Protocol is not ServiceReticulumProtocol, skipping event observation")
return
}
viewModelScope.launch {
serviceProtocol.interfaceStatusChanged.collect {
Log.d(TAG, "████ INTERFACE STATUS EVENT ████ Triggering immediate refresh")
try {
fetchInterfaceStatus()
} catch (e: Exception) {
Log.e(TAG, "Error refreshing interface status after event", e)
}
}
}
}
/**
* Fetch interface online status from Reticulum.
*/

View File

@@ -2965,17 +2965,41 @@ class ReticulumWrapper:
self.rnode_interface.setOnErrorReceived(on_rnode_error)
# Start the interface
log_info("ReticulumWrapper", "initialize_rnode_interface", "Starting ColumbaRNodeInterface...")
if not self.rnode_interface.start():
error_msg = "Failed to start RNode interface"
log_error("ReticulumWrapper", "initialize_rnode_interface", error_msg)
return {'success': False, 'error': error_msg}
# Register with RNS Transport
# 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",
f" ColumbaRNodeInterface started and registered, online={self.rnode_interface.online}")
"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",
f"████ RNODE ONLINE STATUS CHANGED ████ online={is_online}")
if self.kotlin_rnode_bridge:
try:
self.kotlin_rnode_bridge.notifyOnlineStatusChanged(is_online)
except Exception as e:
log_error("ReticulumWrapper", "RNodeStatus",
f"Failed to notify Kotlin of online status: {e}")
if hasattr(self.rnode_interface, 'setOnOnlineStatusChanged'):
self.rnode_interface.setOnOnlineStatusChanged(on_online_status_change)
log_debug("ReticulumWrapper", "initialize_rnode_interface",
"Set online status callback")
# Start the interface
log_info("ReticulumWrapper", "initialize_rnode_interface", "Starting ColumbaRNodeInterface...")
start_success = self.rnode_interface.start()
if start_success:
log_info("ReticulumWrapper", "initialize_rnode_interface",
f"✅ ColumbaRNodeInterface started successfully, online={self.rnode_interface.online}")
else:
# Interface failed to start initially, but it has auto-reconnect capability
# Don't return failure - the interface is registered and will auto-reconnect
log_warning("ReticulumWrapper", "initialize_rnode_interface",
"Initial RNode connection failed, but interface registered with auto-reconnect enabled")
# Clear the pending config
self._pending_rnode_config = None

View File

@@ -254,6 +254,8 @@ class ColumbaRNodeInterface:
# Error callback for surfacing RNode errors to UI
self._on_error_callback = None
# Online status change callback for UI refresh
self._on_online_status_changed = None
# Validate configuration
self._validate_config()
@@ -337,7 +339,7 @@ class ColumbaRNodeInterface:
"""Stop the interface and disconnect."""
self._running = False
self._reconnecting = False # Stop any reconnection attempts
self.online = False
self._set_online(False)
if self.kotlin_bridge:
self.kotlin_bridge.disconnect()
@@ -376,7 +378,7 @@ class ColumbaRNodeInterface:
# Validate configuration
if self._validate_radio_state():
self.interface_ready = True
self.online = True
self._set_online(True)
RNS.log(f"RNode '{self.name}' is online", RNS.LOG_INFO)
# Display Columba logo on RNode if enabled
@@ -752,7 +754,7 @@ class ColumbaRNodeInterface:
self._reconnecting = False
else:
RNS.log(f"RNode disconnected: {device_name}", RNS.LOG_WARNING)
self.online = False
self._set_online(False)
self.detected = False
# Start auto-reconnection if not already reconnecting
self._start_reconnection_loop()
@@ -768,6 +770,33 @@ class ColumbaRNodeInterface:
"""
self._on_error_callback = callback
def setOnOnlineStatusChanged(self, callback):
"""
Set callback for online status change events.
The callback will be called when the interface's online status changes,
with signature: callback(is_online: bool)
This enables event-driven UI updates when the RNode connects/disconnects.
@param callback: Callable that receives (is_online)
"""
self._on_online_status_changed = callback
def _set_online(self, is_online):
"""
Set online status and notify callback if status changed.
@param is_online: New online status
"""
old_status = self.online
self.online = is_online
if old_status != is_online and self._on_online_status_changed:
try:
self._on_online_status_changed(is_online)
except Exception as e:
RNS.log(f"Error in online status callback: {e}", RNS.LOG_ERROR)
def _start_reconnection_loop(self):
"""Start a background thread to attempt reconnection."""
if self._reconnecting:

View File

@@ -65,6 +65,20 @@ interface RNodeErrorListener {
)
}
/**
* Listener interface for RNode online status changes.
* Implement this to receive notifications when RNode connects or disconnects.
* This enables event-driven UI updates for the network interfaces display.
*/
interface RNodeOnlineStatusListener {
/**
* Called when RNode online status changes.
*
* @param isOnline True if RNode is now online, false if offline
*/
fun onRNodeOnlineStatusChanged(isOnline: Boolean)
}
/**
* Kotlin RNode Bridge for Bluetooth communication.
*
@@ -212,6 +226,9 @@ class KotlinRNodeBridge(
// Kotlin error listeners (for UI notification)
private val errorListeners = mutableListOf<RNodeErrorListener>()
// Kotlin online status listeners (for UI notification)
private val onlineStatusListeners = mutableListOf<RNodeOnlineStatusListener>()
/**
* Set callback for received data.
* Called on background thread when data arrives from RNode.
@@ -294,6 +311,53 @@ class KotlinRNodeBridge(
}
}
/**
* Register a Kotlin listener for online status changes.
* Listeners will be called on a background thread when status changes.
*
* @param listener The listener to register
*/
fun addOnlineStatusListener(listener: RNodeOnlineStatusListener) {
synchronized(onlineStatusListeners) {
if (!onlineStatusListeners.contains(listener)) {
onlineStatusListeners.add(listener)
}
}
}
/**
* Unregister a Kotlin online status listener.
*
* @param listener The listener to remove
*/
fun removeOnlineStatusListener(listener: RNodeOnlineStatusListener) {
synchronized(onlineStatusListeners) {
onlineStatusListeners.remove(listener)
}
}
/**
* Notify online status change callbacks.
* Called from Python via the bridge when RNode online status changes.
* This enables event-driven UI updates for network interfaces display.
*
* @param isOnline True if RNode is now online, false if offline
*/
fun notifyOnlineStatusChanged(isOnline: Boolean) {
Log.d(TAG, "████ RNODE ONLINE STATUS ████ online=$isOnline")
// Notify Kotlin listeners
synchronized(onlineStatusListeners) {
onlineStatusListeners.forEach { listener ->
try {
listener.onRNodeOnlineStatusChanged(isOnline)
} catch (e: Exception) {
Log.e(TAG, "Online status listener threw exception", e)
}
}
}
}
/**
* Get list of paired RNode devices.
* Filters bonded devices for names starting with "RNode ".
@@ -460,7 +524,7 @@ class KotlinRNodeBridge(
deviceName: String,
adapter: BluetoothAdapter,
): Boolean {
Log.i(TAG, "Connecting to $deviceName via BLE...")
Log.d(TAG, "████ RNODE BLE CONNECT ████ deviceName=$deviceName")
// First check bonded devices
var device: BluetoothDevice? =
@@ -478,7 +542,7 @@ class KotlinRNodeBridge(
}
if (device == null) {
Log.e(TAG, "BLE device not found: $deviceName")
Log.e(TAG, "████ RNODE BLE FAILED ████ device not found: $deviceName")
return false
}
@@ -499,7 +563,7 @@ class KotlinRNodeBridge(
}
}
Log.e(TAG, "BLE connection failed after $BLE_CONNECT_MAX_RETRIES attempts")
Log.e(TAG, "████ RNODE BLE FAILED ████ failed after $BLE_CONNECT_MAX_RETRIES attempts")
return false
}
@@ -543,7 +607,7 @@ class KotlinRNodeBridge(
connectionMode = RNodeConnectionMode.BLE
isConnected.set(true)
Log.i(TAG, "Connected to $deviceName via BLE (MTU=$bleMtu)")
Log.d(TAG, "████ RNODE BLE SUCCESS ████ deviceName=$deviceName MTU=$bleMtu")
// Notify Python
onConnectionStateChanged?.callAttr("__call__", true, deviceName)

View File

@@ -0,0 +1,278 @@
package com.lxmf.messenger.reticulum.rnode
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* Unit tests for KotlinRNodeBridge online status listener functionality.
*
* Tests the event-driven online status notification system that enables
* UI refresh when RNode connects or disconnects.
*/
class KotlinRNodeBridgeOnlineStatusTest {
private lateinit var mockContext: Context
private lateinit var mockBluetoothManager: BluetoothManager
private lateinit var mockBluetoothAdapter: BluetoothAdapter
@Before
fun setup() {
mockContext = mockk<Context>(relaxed = true)
mockBluetoothManager = mockk<BluetoothManager>(relaxed = true)
mockBluetoothAdapter = mockk<BluetoothAdapter>(relaxed = true)
every { mockContext.applicationContext } returns mockContext
every { mockContext.getSystemService(Context.BLUETOOTH_SERVICE) } returns mockBluetoothManager
every { mockBluetoothManager.adapter } returns mockBluetoothAdapter
every { mockBluetoothAdapter.isEnabled } returns true
}
@After
fun tearDown() {
clearAllMocks()
}
// ========== RNodeOnlineStatusListener Tests ==========
@Test
fun `addOnlineStatusListener registers listener correctly`() {
val bridge = KotlinRNodeBridge(mockContext)
val receivedStatuses = mutableListOf<Boolean>()
val listener =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
receivedStatuses.add(isOnline)
}
}
bridge.addOnlineStatusListener(listener)
bridge.notifyOnlineStatusChanged(true)
assertEquals("Listener should receive status", 1, receivedStatuses.size)
assertTrue("Status should be online", receivedStatuses[0])
}
@Test
fun `removeOnlineStatusListener stops notifications`() {
val bridge = KotlinRNodeBridge(mockContext)
val receivedStatuses = mutableListOf<Boolean>()
val listener =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
receivedStatuses.add(isOnline)
}
}
bridge.addOnlineStatusListener(listener)
bridge.notifyOnlineStatusChanged(true)
bridge.removeOnlineStatusListener(listener)
bridge.notifyOnlineStatusChanged(false)
assertEquals("Should only receive one notification", 1, receivedStatuses.size)
assertTrue("First status should be online", receivedStatuses[0])
}
@Test
fun `notifyOnlineStatusChanged notifies all registered listeners`() {
val bridge = KotlinRNodeBridge(mockContext)
val listener1Count = AtomicInteger(0)
val listener2Count = AtomicInteger(0)
val listener1 =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
listener1Count.incrementAndGet()
}
}
val listener2 =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
listener2Count.incrementAndGet()
}
}
bridge.addOnlineStatusListener(listener1)
bridge.addOnlineStatusListener(listener2)
bridge.notifyOnlineStatusChanged(true)
assertEquals("Listener 1 should be notified", 1, listener1Count.get())
assertEquals("Listener 2 should be notified", 1, listener2Count.get())
}
@Test
fun `notifyOnlineStatusChanged with true indicates online`() {
val bridge = KotlinRNodeBridge(mockContext)
var receivedStatus: Boolean? = null
bridge.addOnlineStatusListener(
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
receivedStatus = isOnline
}
},
)
bridge.notifyOnlineStatusChanged(true)
assertTrue("Status should be true (online)", receivedStatus == true)
}
@Test
fun `notifyOnlineStatusChanged with false indicates offline`() {
val bridge = KotlinRNodeBridge(mockContext)
var receivedStatus: Boolean? = null
bridge.addOnlineStatusListener(
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
receivedStatus = isOnline
}
},
)
bridge.notifyOnlineStatusChanged(false)
assertFalse("Status should be false (offline)", receivedStatus == true)
}
@Test
fun `duplicate listener registration is prevented`() {
val bridge = KotlinRNodeBridge(mockContext)
val notificationCount = AtomicInteger(0)
val listener =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
notificationCount.incrementAndGet()
}
}
// Register same listener twice
bridge.addOnlineStatusListener(listener)
bridge.addOnlineStatusListener(listener)
bridge.notifyOnlineStatusChanged(true)
assertEquals("Should only receive one notification despite duplicate registration", 1, notificationCount.get())
}
@Test
fun `listener exception does not affect other listeners`() {
val bridge = KotlinRNodeBridge(mockContext)
val listener2Called = AtomicBoolean(false)
val throwingListener =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
error("Test exception")
}
}
val normalListener =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
listener2Called.set(true)
}
}
bridge.addOnlineStatusListener(throwingListener)
bridge.addOnlineStatusListener(normalListener)
// Should not throw and should still notify second listener
bridge.notifyOnlineStatusChanged(true)
assertTrue("Second listener should still be called", listener2Called.get())
}
@Test
fun `multiple status changes are all delivered`() {
val bridge = KotlinRNodeBridge(mockContext)
val receivedStatuses = mutableListOf<Boolean>()
bridge.addOnlineStatusListener(
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
receivedStatuses.add(isOnline)
}
},
)
bridge.notifyOnlineStatusChanged(true)
bridge.notifyOnlineStatusChanged(false)
bridge.notifyOnlineStatusChanged(true)
assertEquals("Should receive all three status changes", 3, receivedStatuses.size)
assertTrue("First status should be online", receivedStatuses[0])
assertFalse("Second status should be offline", receivedStatuses[1])
assertTrue("Third status should be online", receivedStatuses[2])
}
@Test
fun `no listeners registered does not cause error`() {
val bridge = KotlinRNodeBridge(mockContext)
// Should not throw any exception
bridge.notifyOnlineStatusChanged(true)
bridge.notifyOnlineStatusChanged(false)
}
@Test
fun `removing non-existent listener does not cause error`() {
val bridge = KotlinRNodeBridge(mockContext)
val listener =
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
// No-op listener for testing removal
}
}
// Should not throw any exception
bridge.removeOnlineStatusListener(listener)
}
// ========== Thread Safety Tests ==========
@Test
fun `concurrent listener registration is thread safe`() {
val bridge = KotlinRNodeBridge(mockContext)
val listenerCount = 10
val latch = CountDownLatch(listenerCount)
val notificationCount = AtomicInteger(0)
// Register listeners from multiple threads
repeat(listenerCount) {
Thread {
bridge.addOnlineStatusListener(
object : RNodeOnlineStatusListener {
override fun onRNodeOnlineStatusChanged(isOnline: Boolean) {
notificationCount.incrementAndGet()
}
},
)
latch.countDown()
}.start()
}
assertTrue("All registrations should complete", latch.await(5, TimeUnit.SECONDS))
bridge.notifyOnlineStatusChanged(true)
assertEquals("All listeners should be notified", listenerCount, notificationCount.get())
}
}