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>
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>
- Add explicit log message and comment in RMSP client to clarify that
hash verification happens before server object creation (addressing
code review concern about P0 security vulnerability)
- Add resetForTest() to ImageCache to properly reset hit/miss stats
between tests, fixing flaky getStats test
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Server D needs ["*"] instead of [] for global coverage after the
covers_geohash behavior change.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Both now require explicit "*" for global coverage; empty list means
no coverage data available. This prevents servers with incomplete
announce data from incorrectly matching all geohash queries.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Previous 10s max was too short for mesh networks. Now uses 30s fixed
timeout (or half of total for short timeouts), giving path establishment
reasonable time while still leaving most of the timeout for data transfer.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
RMSP servers announce with IN direction (listening for requests),
not OUT. Using OUT direction caused the hash computation to always
fail for legitimate servers, defeating the security check.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace exponential backoff with fixed 250ms polling for path requests.
Reduce path timeout to 30% of caller's timeout (max 10s) to avoid
nested timeout confusion.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
100K tiles was too restrictive for large region downloads (~100km radius).
Updated limit to 1M in both Kotlin and Python code with corresponding tests.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Compute expected destination hash from provided public key and compare
with claimed dest_hash to prevent server impersonation attacks.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of unreliably checking if response < 1000 bytes, detect error
responses by checking for msgpack map format markers (0x80-0x8f fixmap,
0xde map16, 0xdf map32). This correctly handles small valid tiles and
avoids misinterpreting binary tile data as errors.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The test was expecting exactly 10 sleep calls, but background threads
(from other tests or the same test) may also call time.sleep, causing
the count to be higher. Changed to assertGreaterEqual(10) which still
verifies the retry loop ran while being robust to background activity.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add tile_count (max 100,000) and tile size (max 1MB) validation to
match the Kotlin implementation. Prevents DoS attacks from malicious
RMSP servers sending invalid counts or sizes.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace fixed 0.1s sleep with exponential backoff starting at 50ms,
capping at 1s. Reduces CPU usage during link establishment while
maintaining responsiveness.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace fixed 0.5s sleep with exponential backoff starting at 100ms,
capping at 2s. This improves responsiveness when paths are established
quickly while reducing CPU usage during longer waits.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add Robolectric UI tests for offline maps screens:
- OfflineMapsScreenTest: tests for EmptyOfflineMapsState, StorageSummaryCard,
OfflineMapRegionCard, StatusChip, and delete dialog interactions
- OfflineMapDownloadScreenTest: tests for LocationSelectionStep,
RadiusSelectionStep, ConfirmDownloadStep, DownloadingStep status states
Also includes:
- OfflineMapsViewModelTest: comprehensive ViewModel tests
- TileDownloadManagerTest: tests for geohash, tile coords, RMSP downloads
- MBTilesWriterTest: tests for MBTiles file creation
- Python tests for rmsp_client.py and reticulum_wrapper.py
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements RMSP (Reticulum Map Service Protocol) tile fetching through
the cross-process AIDL interface, enabling offline map downloads over
mesh networks.
Key changes:
- Add fetchRmspTiles() and getRmspServers() to IReticulumService.aidl
- Implement RMSP methods in ReticulumServiceBinder with Python interop
- Add ServiceReticulumProtocol.fetchRmspTiles() for suspend function access
- Update ReticulumModule to expose ServiceReticulumProtocol for injection
- Add TileSource.Rmsp sealed class for RMSP tile source abstraction
- Implement multi-geohash coverage calculation for complete region downloads
- decodeGeohashBounds() to get bounding box from geohash
- geohashesForBounds() to find all cells covering a region
- Update downloadRegionRmsp() to iterate over all geohash cells
- Add RMSP server discovery UI in OfflineMapDownloadScreen
- Update rmsp_client.py with announce waiting and path resolution
- Add public key parameter for server identity reconstruction
The RMSP download now fetches tiles for all geohash cells covering the
requested region, matching HTTP download coverage behavior.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>