Add signal quality metrics (RSSI and SNR) to received messages, displayed
on the message detail screen. RNode-delivered messages show both metrics;
BLE shows RSSI only (Android limitation).
Changes:
- Python layer: New signal_quality.py module for extracting metrics from
Reticulum interfaces at message delivery time
- Database: Add receivedRssi (INTEGER) and receivedSnr (REAL) columns with
migration 30->31
- Protocol: Parse rssi/snr from JSON in ServiceReticulumProtocol and
EventHandler
- UI: New SignalQualityInfo utility with color-coded signal strength/quality
display in MessageDetailScreen
Signal thresholds:
- RSSI: Excellent (>-50), Good (-50 to -70), Fair (-70 to -85), Weak (-85 to -100), Very Weak (<-100) dBm
- SNR: Excellent (>10), Good (5-10), Fair (0-5), Poor (-5-0), Very Poor (<-5) dB
Closes#209
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The TestAndroidBLEDriverKotlinBridge tests were replacing
sys.modules['reticulum_wrapper'] with a MagicMock but not restoring
the original module. This corrupted the module state for later tests
that use @patch('reticulum_wrapper.RNS'), causing 210 tests to fail
when run in alphabetical order (CI) but pass when run individually.
Fix: Save the original reticulum_wrapper module in setUp and restore
it in tearDown to ensure proper test isolation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests verify the fix for the bug where stale identity_to_address mappings
caused legitimate reconnections to be incorrectly rejected as duplicates
after BLE MAC address rotation.
Test scenarios:
- Buggy implementation incorrectly rejects reconnection
- Fixed implementation allows reconnection during grace period
- Fixed implementation allows same-MAC reconnection
- Fixed implementation allows when no pending detach and not connected
- Fixed implementation still rejects true duplicate connections
- Real-world MAC rotation scenarios with buggy vs fixed implementations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add consistent Columba:BLE:<Layer>:<Component> log tag pattern across
all BLE components for improved debugging and log filtering.
Kotlin components now use abbreviated tags (K:Bridge, K:Scan, K:Client,
K:Adv, K:Server, K:Queue, K:ConnMgr, K:Pair) and Python driver uses
Py:Driver. Also fixes callback trace logging from WARNING to DEBUG level.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add comprehensive tests for new code in android_ble_driver.py:
- TestEnsureBytesFunction: tests ensure_bytes() utility function
- TestEnsureBytesWithMockJarray: tests with mock Chaquopy jarray
- TestHandleDuplicateIdentityDetected: tests _handle_duplicate_identity_detected
- TestHandleDataReceivedEnsureBytes: tests ensure_bytes usage in callbacks
These tests cover the 16 lines that were missing coverage in the PR.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When Kotlin passes ByteArray to Python via Chaquopy, it arrives as a
jarray('B') (Java array), not Python bytes. Java arrays don't have
Python methods like .hex(), so calling .hex() on identity bytes
caused "jarray('B')' object has no attribute 'hex'" errors.
Add ensure_bytes() helper function and apply it to:
- _handle_data_received: data parameter
- _handle_duplicate_identity_detected: identity_bytes parameter
This fixes the duplicate identity detection callback which was failing
silently and allowing connections that should have been rejected.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
On Linux, BLEInterface sets driver.on_duplicate_identity_detected to
check if an identity is already connected at a different MAC address.
On Android, this callback was never wired up, allowing duplicate
connections during MAC rotation.
Changes:
- KotlinBLEBridge: Add onDuplicateIdentityDetected callback field
- KotlinBLEBridge: Add setOnDuplicateIdentityDetected setter method
- KotlinBLEBridge: Check for duplicates in handleIdentityReceived()
before accepting connection, using safe message format
- AndroidBLEDriver: Wire up callback in _setup_kotlin_callbacks()
- AndroidBLEDriver: Add _handle_duplicate_identity_detected() method
- Update tests to verify callback is properly wired
- Update BLE architecture docs with callback flow
When duplicate detected, connection is rejected with safe log message
"Duplicate identity rejected for {address}" which doesn't trigger the
blacklist mechanism.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
LXST's Mixer.unmute() has a bug where unmute(True) sets muted=True
instead of muted=False (inverted logic). The implementation correctly
uses mute_transmit(False) to unmute, so update the test to match.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Python 3.13's socket.if_nametoindex() doesn't work on Android/Chaquopy,
causing AutoInterface to crash. Python 3.11 works correctly.
Changes:
- Revert Chaquopy from Python 3.13 to 3.11 (both app and reticulum modules)
- Use pycodec2 wheel with pure Python ctypes wrapper instead of Cython
extension to avoid Android linker namespace symbol resolution issues
- Remove socket.if_nametoindex patch (not needed on Python 3.11)
- Add refresh trigger for delivery status updates to fix UI not showing
double checkmarks until next message sent
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
On Android, socket.if_nametoindex() is not available in Chaquopy and
the fallback via netinfo.interface_names_to_indexes() returns None.
The /sys/class/net/ifindex fallback is blocked by SELinux.
AutoInterface adds interfaces to adopted_interfaces before calling
interface_name_to_index(). When the exception was caught, the interface
remained in adopted_interfaces, causing final_init() to crash later.
The fix cleans up adopted_interfaces before raising the exception so
final_init() has nothing to iterate over for interfaces that can't
get their index. AutoInterface now gracefully degrades with a warning.
Also temporarily disabled x86_64 ABI due to wheel resolution issues
for audioop-lts and pycodec2 (only arm64 needed for real devices).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- CallActionReceiver: Replace GlobalScope with goAsync() pattern to prevent
process termination before hangup completes. This fixes a potential bug
where declining/ending calls from notifications could fail silently.
- ReticulumServiceBinder: Extract setupBridges() into 9 focused helper methods
(setupBleCoordinator, initializeRNodeInterface, setupReticulumBridgeCallback,
setupAlternativeRelayCallback, setupLocationTelemetryCallback, setupReactionCallback,
setupPropagationStateCallback, setupLxstCallManager, registerCallBridgeListeners)
- KotlinAudioBridge: Extract startRecording() into 6 focused helper methods
(calculateMinBufferSize, ensureAudioModeForRecording, createAudioRecord,
configurePreferredInputDevice, startRecordingAndLaunchLoop, logAudioRoutingState)
- MockReticulumProtocol: Use idiomatic = Unit for empty function bodies
- ServicePersistenceManager: Add ReturnCount suppression for cascading lookup pattern
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove redundant path request code from call_manager.py - LXST's
Telephony.call() already handles path discovery correctly
- Move AIDL call operations to IO dispatcher to prevent ANR when
LXST blocks for path discovery (up to 70s)
- Add retry mechanism in CallViewModel for CallManager initialization
(retries up to 10x with 1s delay after fresh install)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
LXST's Mixer.unmute() has a bug where unmute(True) sets self.muted=True
instead of self.muted=False (inverted logic). This caused the unmute
button to not work - calling unmute_transmit() actually kept audio muted.
Changes:
- call_manager.py: Use telephone.mute_transmit(muted) with explicit
True/False instead of separate mute_transmit()/unmute_transmit() calls
- KotlinAudioBridge: Remove software mute (LXST handles at codec level)
- ReticulumServiceBinder: Improve logging for mute state changes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Complete voice call implementation for Android with cross-process support:
- Add call AIDL interface (IReticulumService/IReticulumServiceCallback)
- Implement ServiceReticulumProtocol for call IPC to :reticulum process
- Add ReticulumServiceBinder call methods bridging to Python CallManager
- Wire KotlinAudioBridge to Chaquopy audio backend for audio streaming
- Add incoming call notifications with IPC broadcast to UI process
- Fix microphone recording by adding FOREGROUND_SERVICE_MICROPHONE
- Support mute/unmute via software mute in audio bridge
- Add libopus native dependency for Opus codec support
Audio routing now works correctly between devices with earpiece default.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements issue #262 - adds a detailed About section at the bottom of
the Settings screen displaying app information, device details, protocol
versions, identity info, links, and build metadata.
Features:
- App information (version, build code, git commit, build date)
- Device information (Android version, API level, model, manufacturer)
- Protocol versions (Reticulum, LXMF, BLE-Reticulum)
- Identity hash display
- Links to GitHub repository, issue tracker, and Reticulum website
- Legal information and dependency attribution
- Copy to clipboard functionality for system info
Technical changes:
- Add BuildConfig fields for git commit hash and build timestamp
- Create DeviceInfoUtil for collecting and formatting system info
- Add Python methods to retrieve protocol versions
- Add AIDL and Kotlin wrappers for protocol version retrieval
- Update SettingsViewModel with retry logic for fetching versions
- Create AboutCard composable with comprehensive UI
- Add 23 unit tests (8 for DeviceInfoUtil, 15 for AboutCard)
All quality checks passing (detekt, ktlint, cpd, unit tests).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The production code was fixed to not pass None to LXMF's
set_outbound_propagation_node() since LXMF doesn't support it.
Updated the tests to verify that the router method is NOT called
when clearing with None, only internal state is cleared.
Fixes two failing tests:
- test_set_propagation_node_clears_when_none
- test_set_outbound_propagation_node_clears_with_none
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
LXMF's set_outbound_propagation_node() expects a valid bytes hash and
calls len() on it, which fails with TypeError when passed None.
When clearing the propagation node, just clear our internal tracking
variable instead of calling the LXMF method. LXMF will handle the case
where no valid propagation node is set.
Fixes error: "object of type 'NoneType' has no len()"
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Two-layer defense against spurious failure callbacks for propagated messages:
Python layer:
- Track successfully propagated messages in _successfully_propagated dict
- Guard in _on_message_failed to ignore failures for tracked messages
- Cleanup stale tracking entries (24h TTL)
- Immediate state check after propagation retry
Kotlin layer:
- Add isTerminalSuccessStatus() helper
- Guard in handleDeliveryStatusUpdate to block status degradation
from terminal success states (sent/propagated/delivered) to failed
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When messages are sent via propagation node, the status should update
to "propagated" (not "sent") when the relay confirms receipt.
Changes:
- Modify _on_message_sent() to check desired_method and emit
'propagated' status for PROPAGATED method messages
- Add state check after propagation retry in _on_message_failed()
to detect immediate propagation success
- Handle pending file notifications when propagation is confirmed
This fixes the issue where Message Details showed "Sent" for messages
that were successfully propagated to a relay node.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Problem: Auto-select relay was enabled but no relay was being selected.
Root cause: Different queries had inconsistent filtering:
- getAllAnnouncesPaged() had NO filter - showed deprecated nodes
- getAnnouncesByTypes() filtered deprecated nodes (stampCostFlexibility IS NULL)
- This caused relays to appear in announce stream but not be available for auto-selection
Changes:
- Make all announce queries consistently filter deprecated propagation nodes
- Add logging when pn_announce_data_is_valid() returns False (Python)
- Add debug logging to PropagationNodeManager for auto-selection diagnosis
Deprecated nodes are propagation nodes running old LXMF versions that
don't include stamp cost data in their announces. These cause sync failures
so they are intentionally filtered from relay selection.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The test was expecting the interface's .name attribute value, but the
code uses type().__name__ to get the class name. Fixed by:
- Setting mock_interface.__class__.__name__ = 'TCPInterface'
- Updating assertion to expect "TCPInterface" (class name)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests the opportunistic message scenario where LXMF provides
the receiving_interface directly on the message object, rather
than needing to look it up from path_table.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
AutoInterfacePeer uses 'ifname' attribute (network interface name like
'wlan0') instead of 'name'. For the first opportunistic message before
path_table is populated, the interface was not being captured.
Changes:
- Use class name (type().__name__) to identify interface type reliably
- Extract shared interface display logic to InterfaceUtils.kt
- Both MessageDetailScreen and AnnounceDetailScreen use same utility
- AutoInterface class names map to "Local Network" with Wifi icon
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add module-level LXMF field constants (FIELD_FILE_ATTACHMENTS,
FIELD_IMAGE, FIELD_AUDIO) so code works without LXMF imported
- Use local booleans to track captured hops/interface instead of
hasattr() which always returns True for Mock objects
- Add isinstance() checks for hop count (int), interface name (str),
and public key (bytes) before using them
- This ensures JSON serialization doesn't fail when tests use
MagicMock objects that auto-create attributes
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
For opportunistic (single-packet) messages that arrive before the path_table
is populated, we now capture the receiving interface and hop count directly
from the LXMF message object. This is enabled by a patch to our LXMF fork
that passes packet.receiving_interface and packet.hops to the delivery callback.
Changes:
- Update LXMF dependency to feature/receiving-interface-capture branch
- Check for receiving_interface and receiving_hops attributes on LXMessage first
- Fall back to path_table lookup for link-based messages (unchanged behavior)
This fixes the issue where messages received before an announce wouldn't have
hop count or interface info in the Message Details screen.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Move message received callback registration from setupBridges() to
setupPreInitializationBridges() so it's registered BEFORE Python's
LXMF router starts. This ensures messages that arrive immediately
after initialization use the event-driven path with hop count and
interface data, rather than falling back to polling.
Also clarify in comments that hop/interface capture is only available
when a path exists in path_table (opportunistic messages without an
established path won't have this info).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Python callback now sends full message data including content, fields,
public key, hop count, and receiving interface. Kotlin processes the
message directly from the callback JSON instead of polling back to
Python.
This eliminates the round-trip poll and makes message delivery truly
event-driven.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The interface object may have a name attribute that is None, resulting
in "None" being captured as the interface name. Now checks that
interface.name is truthy before using it, and skips storing if the
result is "None".
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The kotlin_message_received_callback was missing hops and receiving_interface
fields. While the current flow triggers a poll that also includes this data,
including it in the callback makes the data available for a future true
event-driven implementation without polling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The path table stores the interface through which we received the
message, regardless of hop count. For multi-hop messages, this is
the interface of the last relay that delivered to us.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add 7 Kotlin tests for getReceivingInterfaceInfo() covering all interface
type branches (AutoInterface, TCP, BLE, RNode, Serial, unknown, null)
- Add 7 Python tests for _on_lxmf_delivery() hop count and interface capture
- Use performScrollTo() for UI assertions on scrollable content
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests were mocking __str__ but code now uses .name attribute when
available. Added .name to mock interfaces to match actual behavior.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
RNS.Transport.hops_to() returns current routing info which may change
after message reception. Now capture hop count and interface immediately
in _on_lxmf_delivery() and store on message object for later retrieval.
This ensures hop count reflects the actual path taken at reception time,
not a potentially different path discovered later.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Path table stores outbound routing info, not reception history.
For multi-hop messages, path_entry[5] is the next-hop interface for
sending TO the sender, not the interface that received the message.
Interface info is only accurate when hops=0 (direct delivery).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests verify that poll_received_messages correctly:
- Extracts valid hop counts (positive integers)
- Extracts zero hop count (direct delivery)
- Skips negative hop counts (-1)
- Skips None hop counts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Apply same fix to _announce_handler and poll_received_announces
for consistency across all interface name extraction.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Capture network metadata when messages are received and display it on the
message info screen:
- Add receivedHopCount and receivedInterface columns to MessageEntity
- Create database migration 28→29
- Capture hop count via RNS.Transport.hops_to() at message reception
- Capture receiving interface from RNS path table at message reception
- Pass new fields through EventHandler and ServicePersistenceManager
- Update Message, MessageUi models and mappers
- Display hop count (Direct/N hops) and interface (WiFi/BLE/LoRa/TCP)
on received message info screen with appropriate icons
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use .get() instead of checking membership then accessing, which
eliminates the race condition where another thread could remove
the entry between the two operations.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Change RNS.Destination direction from IN to OUT when verifying server
identity. Servers announce with OUT direction (announcing their service),
so verification must use the same direction to correctly validate the
destination hash against the public key.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The `server = None` assignment was unreachable since `return None`
immediately follows. Removed the dead code for clarity.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>