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>
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>
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>
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>
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>
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>
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>
- Fix MapSourcesCard toggle not working (preserve state in combine flow)
- Add navigation routes for OfflineMapsScreen and OfflineMapDownloadScreen
- Add Offline Maps FAB on MapScreen with navigation callback
- Hide bottom nav bar on offline maps screens
- Fix FAB positioning with navigationBarsPadding
- Fix OpenFreeMap tile URL (add version string)
- Fix SQLite threading issues:
- Remove transactions (incompatible with coroutine thread pools)
- Add mutex for serialized database writes
- Use beginTransactionNonExclusive for safer transaction handling
- Add logging to TileDownloadManager for debugging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Clean up verbose debug logging added during troubleshooting of
incoming link detection. The Transport.active_links fallback is
now a simple, silent check since backchannel_links should be
populated via link.identify() in normal operation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove LinkSpeedProbe and enhance ConversationLinkManager to provide
all link metrics needed for image quality recommendations:
- Add link status indicator next to online indicator in conversation
- Enhance LinkState with expectedRateBps, nextHopBitrateBps, rttSeconds,
hops, and linkMtu fields from Python establish_link response
- Add periodic refresh to detect incoming links from peers
- Call link.identify() after link establishment to enable backchannel
detection on the remote peer (populates their backchannel_links)
- Update ImageQualitySelectionDialog to use LinkState instead of ProbeState
- Remove LinkSpeedProbe.kt and related probe_link_speed Python function
- Update tests to use ConversationLinkManager instead of LinkSpeedProbe
The key fix: when establishing a link, we now call link.identify() which
triggers the remote peer's delivery_remote_identified callback, adding
the link to their backchannel_links dictionary. This allows bidirectional
link detection without requiring a message to be sent first.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
BLEInterface.py is in ble_reticulum, not ble_modules. This was
accidentally changed in 94c37167, causing the BLE interface to
fail deployment silently and preventing the AndroidBLE interface
from loading.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- LinkSpeedProbeResult.bestRateBps now prefers expectedRateBps (actual
measured throughput from prior transfers) over establishment_rate
or interface bitrate
- For propagated delivery mode, also check backchannel link for
expected_rate from the recipient
- Remove unused conversationLinkManager from LinkSpeedProbe
- Add comprehensive unit tests for LinkSpeedProbeResult (23 tests)
- Add Python tests for backchannel expected_rate in propagated mode
This improves transfer time estimates by using actual measured
throughput from prior image transfers rather than heuristics.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Change ORIGINAL preset maxDimensionPx from Int.MAX_VALUE to 8192
to prevent OOM errors on large images (Android Canvas limit ~16K)
- Add negative transfer time validation in formatTransferTime()
to handle edge case of invalid probe data
- Fix backchannel_links cleanup: when a stale link is found during
establish_link(), clean up both direct_links and backchannel_links
dictionaries using both key formats (bytes and hex string)
- Update tests to expect new 8K dimension limit for ORIGINAL preset
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When no active propagation link exists, return status="success" instead
of "no_link" so Kotlin UI uses the nextHopBitrateBps heuristic for
compression recommendations instead of treating it as a failure.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove conditional that only checked recipient_dest.hash when hashes
didn't match - links are stored under recipient_dest.hash so this check
must always run. Also clean up stale links from both direct_links and
backchannel_links when found.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Clean up stale/closed links when discovered during existing link check
- Use identity comparison in finally block to only remove our own link,
preventing concurrent calls from incorrectly removing each other's links
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Clean up stored link and call teardown() when an exception occurs,
matching the cleanup done on timeout. Safely handles cases where
recipient_dest or link may not be defined yet.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Previously, request_path() was called unconditionally even when a path
already existed. This generated unnecessary network broadcasts. Now we
only request a path if we don't have one. If the existing path is stale,
link establishment will fail and can be retried.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When link establishment times out, the link object was being removed
from direct_links but teardown() wasn't called. This left the underlying
RNS link in limbo, potentially consuming resources. Now properly closes
the link before removing from the dictionary.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add missing delivery_method field to error returns in send_lxmf_message_with_method
- Use try/finally for temp image file cleanup to prevent leaks
- Add race condition guard in LinkSpeedProbe.probe() to skip if already probing
- Populate next_hop_bitrate_bps in probe_link_speed results from RNS interface
- Add try/finally bitmap recycling in ImageUtils to prevent memory leaks
- Fix None→0 conversion in RoutingManager helpers to omit field instead
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of sending empty LXMF messages to probe link speed, now uses
the reliable establish_link() function. Also checks backchannel_links
for existing incoming links from peer.
This removes ~80 lines of complex probe message handling code.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Always request fresh path even when one exists (avoids stale routes)
- Check backchannel_links in addition to direct_links for existing links
- Links are bidirectional, so incoming links from peer count as "active"
This fixes the issue where Device A shows "Online" but Device B shows
"last seen X minutes ago" even though they have an active link between them.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixed 3 bugs in establish_link() that prevented manual link establishment
from working while LXMF DIRECT messages succeeded:
1. Missing identity recall fallbacks - now tries destination hash,
identity hash, and local cache (matching send_lxmf_message pattern)
2. Reversed path request logic - now requests path when one doesn't
exist instead of when one already exists
3. Wrong key for storing links - now uses recipient_dest.hash
(created destination hash) instead of input hash
Also updated close_link() and get_link_status() with same hash
handling logic for consistency.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add ConversationLinkManager to track active links per conversation
- Open link when entering conversation, close after 5min inactivity
- Update online indicator to show "Online" when link is active
- Add establish_link(), close_link(), get_link_status() to Python wrapper
- Add AIDL methods and protocol support for link management
- LinkSpeedProbe now uses existing links from ConversationLinkManager
Note: Manual link establishment times out (investigating), but detecting
existing links from LXMF DIRECT message exchange works correctly.
🐾 Generated with [Letta Code](https://letta.com)
Co-Authored-By: Letta <noreply@letta.com>
Large images (>500KB) exceed Android Binder IPC transaction limits (~1MB).
Now large images are written to temp files and the path is passed through
AIDL instead of the raw bytes.
Changes:
- Add imageDataPath parameter to AIDL sendLxmfMessageWithMethod
- ServiceReticulumProtocol writes large images to temp files
- Python reads from file path and deletes temp after reading
- Cap loadBitmap to MAX_PREVIEW_DIMENSION regardless of preset
(prevents Canvas crash for ORIGINAL preset with huge images)
🐛 Generated with [Letta Code](https://letta.com)
Co-Authored-By: Letta <noreply@letta.com>
- Fix direct_links key lookup (LXMF uses hex strings, not bytes)
- Add find_direct_link() helper to check both key formats
- Fix float-to-long conversion for link rates from Python
- Reset probe state before starting new probe
- Check for late link establishment after timeout
🐛 Generated with [Letta Code](https://letta.com)
Co-Authored-By: Letta <noreply@letta.com>
- Send empty LXMF message to establish link instead of raw Link
- Add delivery method parameter to probe (direct vs propagated)
- Filter empty probe messages on receive side to prevent rendering
- Fix UnboundLocalError for has_text_content in message filtering
- Add next_hop_bitrate_bps field for heuristic-based recommendations
- Monitor message state for SENT/DELIVERED detection
🐛 Generated with [Letta Code](https://letta.com)
Co-Authored-By: Letta <noreply@letta.com>
Add ability to probe actual link speed to a destination by establishing
a Link and measuring the establishment rate. This provides end-to-end
path speed measurement that accounts for all intermediate hops.
Python layer:
- probe_link_speed() establishes link, measures rate, tears down
- Reuses existing active links if available
- Returns establishment_rate_bps, expected_rate_bps, rtt_seconds, hops
Kotlin layer:
- LinkSpeedProbeResult data class with helper methods
- Full AIDL/Service/Protocol stack integration
- Mock implementation for testing
Next: UI integration for quality selection dialog
🤖 Generated with [Letta Code](https://letta.com)
Co-Authored-By: Letta <noreply@letta.com>
- Move native stamp generator registration to pre-init callback
(prevents Python's multiprocessing.Manager() from hanging on Android)
- Start PropagationNodeManager early after service binding
- Add retry logic for relay sync when Python not yet initialized
- Fall back to DIRECT when PROPAGATED requested but no relay set
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>