Add bidirectional location sharing between contacts:
- LocationSharingManager handles outgoing sharing sessions
- LocationTelemetry model with cease flag for stop notifications
- ReceivedLocationDao/Entity for storing incoming locations
- ContactLocationBottomSheet for viewing contact details on map
- MapViewModel combines contacts + announces for display names
- Python wrapper filters location-only messages from chat view
When a user stops sharing, a cease message is sent to recipients
causing immediate marker removal instead of waiting for expiry.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement sending and receiving file attachments of any type using
LXMF Field 5 (FILE_ATTACHMENTS). Key features:
- Send any file type via file picker (multiple files supported)
- 512KB combined size limit (same as images)
- File display card with type icon, filename, and size
- Size indicator with visual feedback when approaching limits
- Tap received files to save them
Technical changes:
- Add FileAttachment data class and FileUtils utilities
- Add FileAttachmentCard and FileAttachmentPreviewRow UI components
- Extend MessagingViewModel with file attachment state management
- Update Python wrapper to handle Field 5 send/receive
- Add Field 5 parsing to MessageMapper
- Update protocol layer to pass file attachments through
- Handle Java ArrayList to Python list conversion in wrapper
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace continuous 2-30s fallback polling with a one-time startup drain.
The pending_inbound queue already handles messages that arrive before
callback registration, so we only need to drain it once at startup.
Changes:
- Remove startMessagesPolling() loop entirely from PollingManager
- Remove messagesPoller SmartPoller (no longer needed)
- Add drainPendingMessages() for one-time startup drain
- Update ReticulumServiceBinder to call drain instead of startPolling
- Remove messagePollingJob from ServiceState
- Update Python docstrings to reflect new architecture
Architecture is now:
- Startup: drain any queued messages
- Runtime: 100% event-driven via callbacks
- No continuous polling for messages
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Message delivery is now primarily event-driven via Python callbacks.
The aggressive 1s conversationPoller is removed since the event-driven
callback infrastructure is already in place and working:
- ReticulumServiceBinder registers kotlin_message_received_callback
- Python _on_lxmf_delivery() invokes callback for immediate delivery
- PollingManager.handleMessageReceivedEvent() processes the events
Changes:
- Remove conversationPoller (1s fixed interval) from PollingManager
- Keep messagesPoller (2-30s adaptive) as fallback safety net
- Simplify setConversationActive() to just track state
- Reduce verbose debug logging in poll_received_messages()
- Remove PATH TABLE DIAGNOSTIC spam from Python polling
This significantly reduces battery drain when conversations are active
by eliminating the Python/Kotlin boundary crossing every second.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Added 5 Python tests for set_stamp_generator_callback:
- Callback storage in instance variable
- Registration with LXMF LXStamper
- Graceful handling of import errors
- Graceful handling of registration errors
- Setting callback to None (clearing)
Also fixed detekt EqualsNullCall violation in StampGeneratorTest.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace Python multiprocessing-based stamp generation with native Kotlin
implementation. Python multiprocessing fails on Android due to lack of
sem_open support and aggressive process killing.
Changes:
- Add StampGenerator.kt with HKDF, SHA256, and parallel stamp search
- Add StampGeneratorTest.kt with Python-generated test vectors
- Add callback in PythonWrapperManager to bridge Python to Kotlin
- Register stamp generator in ReticulumServiceBinder
- Update requirements.txt to use LXMF fork with external generator support
- Add msgpack-core dependency for MessagePack encoding
Performance: ~9300 rounds/sec (vs ~1400 with broken Python multiprocessing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Added comprehensive integration tests that actually execute the production code:
LXStamper Threading Patch Tests:
- test_initialize_patches_lxstamper_job_android: Calls initialize() and then
exercises the patched job_android function with low difficulty
- test_initialize_lxstamper_cancellation_path: Tests cancellation via active_jobs
- Fixed module mocking to properly set mock_lxmf.LXStamper attribute
SENT State Check Tests:
- test_send_does_not_call_on_message_sent_for_outbound_state
- test_send_handles_missing_state_attribute_gracefully
- test_send_handles_state_check_exception_gracefully
- test_send_with_propagated_state_does_not_trigger_sent_callback
Coverage increased from 81% to 83%.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add test coverage for the threading-based stamp generation and
immediate SENT state check to improve patch coverage.
Tests added:
- test_lxstamper_patched_to_use_threading: Verify LXStamper.job_android is patched
- test_lxstamper_patch_graceful_failure: Verify graceful failure handling
- TestLXStamperThreading: Test threading-based stamp generation behavior
- test_send_with_immediate_sent_state_check: Test SENT state detection
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When sending messages via propagation (directly to relay), the message
status was stuck at "pending" because send_lxmf_message_with_method()
didn't check for immediate SENT state transition like the older
send_lxmf_message() method does.
For PROPAGATED messages, the relay accepts the message immediately and
LXMF transitions to SENT state. However, the end recipient never sends
a delivery confirmation (they pull from the relay), so we must check
the state immediately after handle_outbound() and invoke the status
callback.
This fix adds the same state check that exists in send_lxmf_message()
to send_lxmf_message_with_method(), ensuring propagated messages show
"sent" status (single checkmark) instead of staying "pending".
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add ContactToggleResult emission tests to MessagingViewModelTest
- Add public key extraction tests to test_wrapper_messaging.py
- Create MessageCollectorTest.kt for public key handling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Previously, MessagingScreen showed "Saved to Contacts" immediately
without waiting for the async operation result. If the peer's public
key wasn't available (e.g., they never announced), the save would
silently fail despite showing success.
Changes:
- Add ContactToggleResult sealed class to emit success/error from toggleContact()
- Update MessagingScreen to collect results and show accurate toasts
- Extract sender's public key from RNS identity cache in poll_received_messages()
- Add publicKey field to ReceivedMessage and pass through the pipeline
- Store public key from messages in peer_identities table
Now users see "Identity not available - peer hasn't announced" when
save fails, and contacts can be saved from message senders even if
they never announced.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Configure TCPClientInterface.SYNCHRONOUS_START = False during RNS import
to run TCP connections in background threads instead of blocking.
Problem: Reticulum initialization was taking 15.7s, exceeding the 15s
ANR timeout. The main cause was synchronous TCP connection attempts to
unreachable hosts, each blocking for up to 5 seconds.
Solution: Set SYNCHRONOUS_START = False after importing RNS but before
calling RNS.Reticulum(). This makes TCP connections non-blocking while
preserving all reconnection logic (initial_connect() already handles
success/failure paths and spawns reconnect threads as needed).
Result: Initialization time reduced from 15.7s to 11.9s, well under
the 15s timeout threshold.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
"Apply Changes" was taking 4.5+ minutes with ~4000 peer identities because
the previous implementation created full RNS objects for each peer:
- Created RNS.Identity from public key
- Created RNS.Destination for LXMF delivery
- Registered with RNS.Transport
- Called RNS.Identity.recall() to verify
But RNS's Identity.known_destinations is just a dict that remember() populates
directly. This change bypasses the expensive object creation and directly
populates the dict.
## Changes
### Python (`reticulum_wrapper.py`)
- Add `bulk_restore_announce_identities()`: Direct dict population for announces
where destination_hash is already available (no computation needed)
- Add `bulk_restore_peer_identities()`: Lightweight hash computation for peer
identities - computes LXMF delivery destination hash from public key without
creating full RNS objects
### Kotlin
- Add `restoreAnnounceIdentities()` to AIDL interface, binder, and protocol
- Update `MessagingManager` to call new bulk Python methods
- Update `InterfaceConfigManager` to use appropriate bulk restore for each type
### Tests
- Add 25 new Python tests for bulk restore functions covering:
- Success cases with valid data
- Invalid inputs (missing fields, invalid hex, invalid base64)
- Empty lists
- Large batch performance
- Equivalence with Identity.remember() dict format
## Performance Results (tested with ~4000 identities)
| Operation | Before | After |
|-----------|--------|-------|
| 1832 peer identities | ~4.5 min | 88ms |
| 2080 announce identities | (included above) | 142ms |
| **Total** | ~4.5 min | **~230ms** |
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add comprehensive test coverage for TCP RNode functionality:
RNodeWizardViewModelTest.kt (+16 tests):
- getPopularPresetsForRegion tests for all region types (US, AU, EU sub-bands)
- Tests for regions with no presets (Brazil, Russia, Japan)
- Asia-Pacific region filtering tests
- Frequency band exclusion tests
ServiceReticulumProtocolTest.kt (+2 tests):
- buildConfigJson includes TCP host/port for RNode
- buildConfigJson omits tcp_host when null (Bluetooth mode)
InterfaceRepositoryTest.kt (+1 test):
- Validates TCP RNode with invalid hostname format is skipped
test_wrapper_config.py (+6 tests):
- TCP RNode config generation with all LoRa parameters
- Airtime limits (st_alock, lt_alock) handling
- Interface mode handling (gateway, boundary, full)
test_wrapper_ble.py (+5 tests):
- get_paired_rnodes success/error/exception handling
- Empty device list and null response handling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
## Config Format Fix
The Android RNodeInterface expects `tcp_host = hostname` as a config
field, not `port = tcp://hostname`. This is because the Android
interface's __init__() reads `tcp_host` to set `use_tcp = True`,
which then bypasses USB/Bluetooth code paths entirely.
Changed reticulum_wrapper.py to output:
```
tcp_host = 10.0.0.9
```
Instead of:
```
port = tcp://10.0.0.9
```
## Stub Modules for Android RNodeInterface
Added minimal stub modules required for Android RNodeInterface
initialization on Chaquopy:
- python/usbserial4a/__init__.py - Stub serial4a class
- python/jnius/__init__.py - Stub autoclass() function
- python/usb4a/__init__.py - Stub usb class with get_usb_device()
These stubs satisfy unconditional import checks in Android
RNodeInterface.__init__() that happen BEFORE TCP mode is evaluated.
When use_tcp=True, the actual USB/Bluetooth code paths are never
executed, so the stubs don't need full implementations.
## Additional Fixes
- ServiceReticulumProtocol.kt: Add missing tcp_host/tcp_port serialization
- ReviewConfigStep.kt: Show WiFi icon and "WiFi / TCP" for TCP mode
- RNodeWizardViewModel.kt: Add helper methods for TCP mode detection
- DeviceDiscoveryStep.kt: Remove port field (hardcoded in RNS)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add support for connecting to WiFi-enabled RNode devices via TCP as an
alternative to Bluetooth. This enables RNodes with WiFi chips to be
accessed over the network using the standard Reticulum RNodeInterface.
## Data Model Changes
- Add `tcpHost` and `tcpPort` fields to `InterfaceConfig.RNode`
- Update `connectionMode` to support "tcp" in addition to "classic"/"ble"
- Extend serialization/deserialization in InterfaceRepository with validation
## UI Changes (DeviceDiscoveryStep)
- Add connection type selector with "Bluetooth" and "WiFi / TCP" chips
- Add TcpConnectionForm composable with:
- IP address or hostname input field
- Port input field (default: 7633)
- "Test Connection" button with validation feedback
- Extract BluetoothDeviceDiscovery as separate composable
- Only start Bluetooth scanning when in Bluetooth mode
## ViewModel Changes (RNodeWizardViewModel)
- Add RNodeConnectionType enum (BLUETOOTH, TCP_WIFI)
- Add TCP state fields: tcpHost, tcpPort, validation state
- Add methods: setConnectionType(), updateTcpHost(), updateTcpPort(),
validateTcpConnection()
- Update canProceed() to handle TCP mode
- Update saveConfiguration() to create TCP config
- Update loadExistingConfig() to restore TCP settings when editing
## Python Config Generation (reticulum_wrapper.py)
- For TCP mode: write to config file using standard RNodeInterface
format with `port = tcp://host:port`
- For Bluetooth mode: continue using ColumbaRNodeInterface (unchanged)
## Technical Notes
TCP connections can use Reticulum's standard RNodeInterface since TCP
sockets are standard Python (no jnius/Android-specific code needed).
The custom ColumbaRNodeInterface remains necessary only for Bluetooth
connections due to Android Bluetooth API requirements.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add additional unit tests for the transport node feature:
InterfaceConfigManager (3 tests):
- Verify getTransportNodeEnabled() is called during config apply
- Verify enableTransport=true is passed to config when enabled
- Verify enableTransport=false is passed to config when disabled
ServiceReticulumProtocol (3 tests):
- Verify enableTransport defaults to true in ReticulumConfig
- Verify enableTransport can be set to false
- Verify enableTransport can be set to true explicitly
Python get_debug_info (3 tests):
- Verify transport_enabled returns true from RNS when enabled
- Verify transport_enabled returns false from RNS when disabled
- Verify transport_enabled returns false when not initialized
Related to #49🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add a settings toggle to enable/disable Reticulum transport node mode.
When enabled (default), the device forwards traffic for the mesh network.
When disabled, only handles its own traffic without relaying for peers.
Changes:
- Add transportNodeEnabled preference in SettingsRepository
- Add enableTransport field to ReticulumConfig model
- Add state and toggle method in SettingsViewModel
- Pass setting through InterfaceConfigManager and ColumbaApplication
- Update Python wrapper to use config value instead of hardcoded yes
- Fix get_debug_info() to read actual RNS transport status
- Add toggle UI in NetworkCard with Hub icon and description
- Wire up toggle in SettingsScreen with service restart on change
Tests:
- Add 6 SettingsViewModel tests for transport node toggle
- Add 11 Python tests for transport node config generation
- Add 5 ReticulumConfig model tests for enableTransport field
Closes#49🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When messages are sent via propagation (relay), LXMF sets state=SENT
when the relay accepts the message, not state=DELIVERED. Previously,
our callback ignored this distinction and always reported "delivered",
misleading users into thinking the recipient had received the message.
Changes:
- Check lxmf_message.state in _on_message_delivered() callback
- Report "propagated" status when state=SENT (relay accepted)
- Report "delivered" status only when state=DELIVERED (recipient confirmed)
- UI shows single checkmark for propagated (same as sent)
- Added unit tests for both direct and propagated delivery scenarios
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When a selected relay is offline and message propagation fails, the system
now automatically tries alternative relays before marking the message as
permanently failed. Also adds a manual "Retry" option in the message context
menu for failed messages.
Key changes:
- Python: Modified _on_message_failed() to request alternative relays from
Kotlin when propagation fails, with tracking to prevent infinite loops
- Kotlin: Added getAlternativeRelay() to PropagationNodeManager to find the
nearest available relay excluding previously tried ones
- IPC: Added onAlternativeRelayRequested callback and provideAlternativeRelay
method for Python-Kotlin communication
- UI: Added "Retry" menu item for failed messages and retryFailedMessage()
in MessagingViewModel
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add 3 tests for the identity lookup retry mechanism in send_lxmf_message_with_method():
- test_requests_path_when_identity_not_found
- test_path_request_timeout_returns_error
- test_path_request_exception_handled
Uses time.sleep mocking to avoid actual 5-second waits.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When sending to a destination whose identity isn't cached (e.g., peer hasn't
announced since app start), automatically call RNS.Transport.request_path()
and wait up to 5 seconds for the network to respond with the path.
This enables sending messages to known peers without waiting for their
periodic announces, improving UX for users resuming conversations.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When retrying an opportunistic message via propagation, the message was
missing the required propagation stamp (proof-of-work), causing the
propagation node to reject it.
Added:
- Clear propagation_packed and propagation_stamp before retry
- Set defer_propagation_stamp=True to trigger stamp generation
- Message now goes through pending_deferred_stamps → stamp generation →
pending_outbound → delivery
Also added unit tests for:
- Stamp generation flag configuration on retry
- Fresh messages without prior propagation data
- No retry when propagation node not configured
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests were expecting `_announce_handler_ref` (single handler) but
implementation uses `_announce_handlers` dict with aspect-specific
handlers. Tests were never run in CI until recent coverage changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When sending opportunistic messages to offline recipients, the message
would get stuck in "Sent" state forever waiting for a delivery receipt
that never arrives. This adds a 30-second timeout mechanism that
triggers propagation fallback for undelivered opportunistic messages.
Changes:
- Add tracking dict for opportunistic messages with timestamps
- Add background timer thread checking every 10s for timeouts
- After 30s without delivery, trigger _on_message_failed() to retry
via propagation node (using existing fallback logic)
- Update deliveryMethod to "propagated" when retrying via propagation
so UI correctly shows "Delivered to propagation network"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix TooManyFunctions in PropagationNodeManager by removing restartPeriodicSync
- Fix TooManyFunctions in MessagingViewModel by extracting helpers to top-level
- Fix CyclomaticComplexity in sendMessage by refactoring into smaller methods
- Fix DestructuringDeclarationWithTooManyEntries in MessageDetailScreen
- Remove runBlocking antipattern from PropagationNodeManager StateFlow init
- Apply ktlint formatting fixes across codebase
- Update tests for refactored code
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement comprehensive relay (propagation node) support:
- Add PropagationNodeManager for auto-selection of nearest relay by hop count
- Add "Set as My Relay" button on node details for propagation nodes
- Add "MY RELAY" section in contacts screen (separate from pinned)
- Show "(auto)" badge when relay is auto-selected vs manually chosen
- Add "Unset as Your Relay?" confirmation dialog with auto-selection explanation
- Add relay management methods to ContactDao (setAsMyRelay, clearMyRelay, getMyRelay)
- Add isMyRelay field to ContactEntity and EnrichedContact
- Add message delivery settings (default method, retry via relay on fail)
- Add Python layer support for propagation node configuration
- Add instrumented tests for relay DAO operations
The relay system follows Sideband's algorithm: auto-select nearest node,
only switch if new node has fewer or equal hops. Users can manually select
a relay which disables auto-selection until re-enabled.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bug 1 - MAC Rotation:
- Bypass MAC sorting after MAC rotation cleanup to ensure reconnection
- When a peer's MAC address rotates, we must reconnect regardless of
which device has the higher MAC address
- Updated ble-reticulum submodule with fix and tests
Bug 2 - Dual Connection Deduplication:
- Add onAddressChanged callback from Kotlin to Python
- When Kotlin deduplicates a dual connection (central + peripheral),
Python now updates its address mappings so messages continue to flow
- Updates identity_to_address, address_to_identity, and fragmenter keys
Files changed:
- KotlinBLEBridge.kt: Add setOnAddressChanged callback
- bluetooth_driver.py: Add on_address_changed to abstract interface
- android_ble_driver.py: Register and handle address change events
- BLEInterface.py: Add _address_changed_callback to update mappings
- Added unit tests for both bugs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When another Reticulum app (e.g., Sideband) is already using AutoInterface
ports (29716/42671), RNS would call sys.exit(255) and crash the service.
This fix:
- Pre-checks IPv6 UDP port availability before RNS initialization
- Detects conflicts and removes AutoInterface from config proactively
- Allows the rest of RNS (including RNode) to initialize normally
- Adds getFailedInterfaces() AIDL method for future UI warnings
The port check uses IPv6 UDP sockets without SO_REUSEADDR to match how
AutoInterface actually binds (IPv6 multicast for discovery, IPv6 link-local
for data transfer).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Revert LOG_EXTREME hardcoding to use config-based log level
- Remove all diagnostic patches (TX/RX logging, Transport patches, etc.)
- Fix socket.if_nametoindex patch to use netinfo fallback on Windows
and Android/Chaquopy where the socket function is unreliable
- Remove old disabled/commented patch code
The v1.0.4 RNS release has correct AutoInterfacePeer.should_ingress_limit()
behavior (returns False), so runtime patches for ingress limiting and
forward-to-peers are no longer needed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When Columba detects that the shared Reticulum instance (e.g., Sideband)
goes offline while using it, Columba now automatically:
1. Sets preferOwnInstance = true (so toggle shows correct state)
2. Restarts the service with Columba's own network interfaces
3. Shows a "Restarting Service" modal during the restart
4. Displays an informational banner explaining what happened
5. Re-enables Manage Interfaces, BLE Connections, and Service Control
Key changes:
- Add isSharedInstanceAvailable() to AIDL and service protocol
- Add availability monitor that detects shared instance going offline
- Save preferOwnInstance=true on auto-restart (fixes toggle state bug)
- Pass sharedInstanceOnline to NetworkCard, BleConnectionsCard, ServiceControlCard
- Update informational state condition in SharedInstanceBannerCard
- Add comprehensive unit tests for transition flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix BLE cooldown asymmetry: set deduplication cooldown BEFORE deciding
which connection to keep, ensuring cooldown is applied regardless of
whether central or peripheral connection is retained
- Fix InterfaceManagementViewModel test OOM: make STATUS_POLL_INTERVAL_MS
testable (const→var) and add conditional return when interval <= 0
- Fix KISS.unescape() to properly skip invalid escape sequences (0xDB
followed by invalid byte) and trailing FESC bytes instead of corrupting
the data buffer
- Add comprehensive unit tests for all fixes following strict TDD approach
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The Python RNode interface hardcoded a 22 dBm maximum TX power, which
rejected valid regional settings (US allows 30 dBm, NZ allows 36 dBm).
Changes:
- Update Python validation to accept TX power up to 36 dBm (NZ max)
- Add Python tests for regional TX power limits (US 30, EU 27, NZ 36)
- Add Kotlin tests for NZ region validation (36 dBm max)
The Kotlin validator already correctly uses regional limits. The RNode
firmware provides final hardware validation and returns error 0x40 if
TX power exceeds device capability.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add lock around self.online status in _set_online() and process_outgoing()
to prevent frame loss during online/offline transitions
- Add lock around RSSI/SNR updates for consistency with other radio state
- Log warning for malformed KISS escape sequences instead of silent ignore
- Add Python unit tests for thread safety fixes (6 new tests, 68 total)
- Add Python pytest step to CI workflow before Gradle tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 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>
Android BLE is asynchronous - writeCharacteristic() only queues the write.
Without waiting for onCharacteristicWrite() callback, subsequent writes
are silently dropped by the BLE stack.
Changes:
- Add CountDownLatch-based synchronization in KotlinRNodeBridge
- Each BLE write now waits for callback confirmation before proceeding
- Add small delays in Python for framebuffer writes (defensive)
This fixes reliability issues with RNode framebuffer commands on BLE.
Note: T114 still won't show framebuffer due to upstream firmware bug
(bt_ssp_pin set at startup, never cleared).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extract validation logic from RNodeWizardViewModel into RNodeConfigValidator
- Add unit tests for FrequencySlotCalculator, ModemPreset, FrequencyRegion
- Add unit tests for RNodeRegionalPresets, CommunitySlots
- Add unit tests for RNodeConfigValidator (45 tests)
- Add Python tests for KISS protocol and config validation (52 tests)
- Refactor startDeviceScan() into smaller helper methods
- Update detekt baseline for new code structure
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>