Add tests for the new ensure_advertising() method that covers all
code paths in android_ble_driver.py:
- No bridge returns False with warning log
- Bridge returns True when advertising active
- Bridge returns False triggers restart log
- Exception caught and logged, returns False
Tests import and exercise the real AndroidBLEDriver class to provide
actual codecov coverage on the source file.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Android silently stops BLE advertising when the app goes to background,
screen turns off, or device enters Doze mode - without calling any
callback. This leaves the app thinking it's advertising when it's not.
Add a proactive refresh mechanism that restarts advertising every 60
seconds to ensure the device remains discoverable. Also add bridge
methods to allow Python to check/ensure advertising is active.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace submodule with pip install from GitHub. This simplifies
dependency management and avoids namespace collision with RNS.Interfaces.
Changes:
- Remove external/ble-reticulum submodule
- Update build.gradle.kts to install from GitHub branch
- Update reticulum_wrapper.py to use ble_reticulum package
- Remove duplicate BLE files from python/ble_modules/
- Fix KotlinBLEBridge address callback deduplication
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The `import json` statements inside _on_lxmf_delivery() caused Python
to treat `json` as a local variable throughout the function scope.
This resulted in "cannot access local variable 'json'" errors when
json.dumps() was called before those imports ran.
Since json and time are already imported at module level, the local
imports were unnecessary and harmful.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes#155 - Position sharing was being interpreted as "audio messages"
by Sideband because Columba was using the wrong LXMF field and encoding.
Changes:
- Switch from FIELD_AUDIO (0x07) to FIELD_TELEMETRY (0x02)
- Use msgpack-packed Telemeter binary format instead of JSON
- Add FIELD_COLUMBA_META (0x70) for Columba-specific signals (cease)
- Add backwards compatibility for receiving legacy field 7 messages
- Add u-msgpack-python dependency for binary serialization
- Add 36 unit tests for pack/unpack round-trip verification
The telemetry format now matches Sideband's sense.py Location sensor:
- Latitude/longitude as signed int microdegrees
- Altitude/speed/bearing/accuracy in centimeter units
- Unix timestamp in seconds
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add test_wrapper_heartbeat.py with 19 tests for Python heartbeat thread
and global exception handler
- Add DebugViewModelPersistenceTest.kt with tests for new persistence
debug info fields (heartbeatAgeSeconds, healthCheckRunning, etc.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes#40
The shutdown() method was trying to clear self.announce_app_data,
but this attribute was never initialized in __init__. This caused
an AttributeError during shutdown, preventing clean Python wrapper
cleanup.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The reaction detection code checks lxmf_message.fields, so tests
need to mock this attribute to avoid TypeError.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add comprehensive test suite for ReticulumWrapper reaction methods:
- TestSendReaction: Tests send_reaction method including success case,
not initialized, no router, various emojis, identity not found,
cached identity fallback, Reticulum unavailable, and callback registration
- TestReactionReceiveCallback: Tests _on_lxmf_delivery reaction detection,
skipping regular message processing, missing callback handling,
regular messages not treated as reactions, callback error handling,
and various emoji types including ZWJ sequences
- TestReactionReceivePolling: Tests poll_received_messages marking
reaction messages with is_reaction flag and reaction fields
- TestSetReactionReceivedCallback: Tests callback registration
- TestReactionField16Structure: Verifies Field 16 contains required
keys (reaction_to, emoji, sender) with correct values
- TestReactionEdgeCases: Tests jarray conversion, empty emoji,
missing sender, partial Field 16, and exception handling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add send_reaction(dest_hash, target_message_id, emoji, source_identity_private_key)
method for sending emoji reactions via LXMF. The method:
- Creates lightweight LXMF messages with Field 16 containing reaction data
- Uses OPPORTUNISTIC delivery for fast transmission of small reaction messages
- Follows the pattern of send_lxmf_message_with_method for identity handling
- Stores reaction_to, emoji, and sender hash in app extensions dict
Field 16 format: {"reaction_to": "msg_id", "emoji": "👍", "sender": "hash"}
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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>