diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 89adc1eb..00000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,178 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-## [1.4.0] - 2025-10-15
-### Fixed
-- fix: Resolve debug settings bottom sheet crash on some devices (Issue #472)
- - Fixed IllegalFormatConversionException in DebugSettingsSheet.kt when scrolling through debug settings
- - Corrected string formatting for debug_target_fpr_fmt and debug_derived_p_fmt string resources
- - Improved string resource parameter handling for numeric values
-
-## [0.7.2] - 2025-07-20
-### Fixed
-- fix: battery optimization screen content scrollable with fixed buttons
-
-## [0.7.1] - 2025-07-19
-
-### Added
-- feat(battery): add battery optimization management for background reliability
-
-### Fixed
-- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered
-- fix: update sidebar text to use string resources
-- fix(chat): cursor location and enhance message input with slash command styling
-
-### Changed
-- refactor: remove context attribute at ChatViewModel.kt
-- Refactor: Migrate MainViewModel to use StateFlow
-
-### Improved
-- Use HorizontalDivider instead of deprecated Divider
-- Use contentPadding instead of padding so items remain fully visible
-
-
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [0.7]
-
-### Added
-- Location services check during app startup with educational UI
-- Message text selection functionality in chat interface
-- Enhanced RSSI tracking and unread message indicators
-- Major Bluetooth connection architecture refactoring with dedicated managers
-
-### Fixed
-- **Critical**: Android-iOS message fragmentation compatibility issues
- - Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging
- - Ensures Android can properly communicate with iOS devices
-- DirectMessage notifications and text copying functionality
-- Smart routing optimizations (no relay loops, targeted delivery)
-- Build system compilation issues and null pointer exceptions
-
-### Changed
-- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01)
-- Optimized BLE scan intervals for better battery performance
-- Reduced excessive logging output
-
-### Improved
-- Cross-platform compatibility with iOS and Rust implementations
-- Connection stability through architectural improvements
-- Battery performance via scan duty cycling
-- User onboarding with location services education
-
-## [0.6]
-
-### Added
-- Channel password management with `/pass` command for channel owners
-- Monochrome/themed launcher icon for Android 12+ dynamic theming support
-- Unit tests package with initial testing infrastructure
-- Production build optimization with code minification and shrinking
-- Native back gesture/button handling for all app views
-
-### Fixed
-- Favorite peer functionality completely restored and improved
- - Enhanced favorite system with fallback mechanism for peers without key exchange
- - Fixed UI state updates for favorite stars in both header and sidebar
- - Improved favorite persistence across app sessions
-- `/w` command now displays user nicknames instead of peer IDs
-- Button styling and layout improvements across the app
- - Enhanced back button positioning and styling
- - Improved private chat and channel header button layouts
- - Fixed button padding and alignment issues
-- Color scheme consistency updates
- - Updated orange color throughout the app to match iOS version
- - Consistent color usage for private messages and UI elements
-- App startup reliability improvements
- - Better initialization sequence handling
- - Fixed null pointer exceptions during startup
- - Enhanced error handling and logging
-- Input field styling and behavior improvements
-- Sidebar user interaction enhancements
-- Permission explanation screen layout fixes with proper vertical padding
-
-### Changed
-- Updated GitHub organization references in project files
-- Improved README documentation with updated clone URLs
-- Enhanced logging throughout the application for better debugging
-
-## [0.5.1] - 2025-07-10
-
-### Added
-- Bluetooth startup check with user prompt to enable Bluetooth if disabled
-
-### Fixed
-- Improved Bluetooth initialization reliability on first app launch
-
-## [0.5] - 2025-07-10
-
-### Added
-- New user onboarding screen with permission explanations
-- Educational content explaining why each permission is required
-- Privacy assurance messaging (no tracking, no servers, local-only data)
-
-### Fixed
-- Comprehensive permission validation - ensures all required permissions are granted
-- Proper Bluetooth stack initialization on first app load
-- Eliminated need for manual app restart after installation
-- Enhanced permission request coordination and error handling
-
-### Changed
-- Improved first-time user experience with guided setup flow
-
-## [0.4] - 2025-07-10
-
-### Added
-- Push notifications for direct messages
-- Enhanced notification system with proper click handling and grouping
-
-### Improved
-- Direct message (DM) view with better user interface
-- Enhanced private messaging experience
-
-### Known Issues
-- Favorite peer functionality currently broken
-
-## [0.3] - 2025-07-09
-
-### Added
-- Battery-aware scanning policies for improved power management
-- Dynamic scan behavior based on device battery state
-
-### Fixed
-- Android-to-Android Bluetooth Low Energy connections
-- Peer discovery reliability between Android devices
-- Connection stability improvements
-
-## [0.2] - 2025-07-09
-
-### Added
-- Initial Android implementation of bitchat protocol
-- Bluetooth Low Energy mesh networking
-- End-to-end encryption for private messages
-- Channel-based messaging with password protection
-- Store-and-forward message delivery
-- IRC-style commands (/msg, /join, /clear, etc.)
-- RSSI-based signal quality indicators
-
-### Fixed
-- Various Bluetooth handling improvements
-- User interface refinements
-- Connection reliability enhancements
-
-## [0.1] - 2025-07-08
-
-### Added
-- Initial release of bitchat Android client
-- Basic mesh networking functionality
-- Core messaging features
-- Protocol compatibility with iOS bitchat client
-
-[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD
-[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1
-[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5
-[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4
-[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3
-[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2
-[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1
diff --git a/GOOGLE_PLAY.md b/GOOGLE_PLAY.md
deleted file mode 100644
index bdea715e..00000000
--- a/GOOGLE_PLAY.md
+++ /dev/null
@@ -1 +0,0 @@
-I, callebtc, creator of bitchat for android, owner of the copyright claims, and owner of the official bitchat android repository (https://github.com/permissionlesstech/bitchat-android), hereby authorize Verse Communication PBC to publish bitchat (com.bitchat.doid) on the Google Play Store.
diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md
deleted file mode 100644
index a90dd78d..00000000
--- a/PRIVACY_POLICY.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# bitchat Privacy Policy
-
-*Last updated: January 2025*
-
-## Our Commitment
-
-bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
-
-## Summary
-
-**WE DO NOT COLLECT ANY INFORMATION.**
-
-- **No personal data collection** - We don't collect names, emails, or phone numbers
-- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
-- **Hybrid Functionality** - bitchat offers two modes of communication:
- - **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
- - **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
-- **No tracking** - We have no analytics, telemetry, or user tracking
-- **Open source** - You can verify these claims by reading our code
-
-## What Information bitchat Stores
-
-### On Your Device Only
-
-1. **Identity Key**
- - A cryptographic key generated on first launch
- - Stored locally in your device's secure storage
- - Allows you to maintain "favorite" relationships across app restarts
- - Never leaves your device
-
-2. **Nickname**
- - The display name you choose (or auto-generated)
- - Stored only on your device
- - Shared with peers you communicate with
-
-3. **Message History** (if enabled)
- - When room owners enable retention, messages are saved locally
- - Stored encrypted on your device
- - You can delete this at any time
-
-4. **Favorite Peers**
- - Public keys of peers you mark as favorites
- - Stored only on your device
- - Allows you to recognize these peers in future sessions
-
-### Temporary Session Data
-
-During each session, bitchat temporarily maintains:
-- Active peer connections (forgotten when app closes)
-- Routing information for message delivery
-- Cached messages for offline peers (12 hours max)
-
-## What Information is Shared
-
-### With Other bitchat Users
-
-When you use bitchat, nearby peers can see:
-- Your chosen nickname
-- Your ephemeral public key (changes each session)
-- Messages you send to public rooms or directly to them
-- Your approximate Bluetooth signal strength (for connection quality)
-
-### With Room Members
-
-When you join a password-protected room:
-- Your messages are visible to others with the password
-- Your nickname appears in the member list
-- Room owners can see you've joined
-
-## What We DON'T Do
-
-bitchat **never**:
-- Collects personal information
-- Collects location history
-- Transmits any data to us (the developers)
-- Stores data on servers
-- Shares data with third parties
-- Uses analytics or telemetry
-- Creates user profiles
-- Requires registration
-
-## Encryption
-
-All private messages use end-to-end encryption:
-- **X25519** for key exchange
-- **AES-256-GCM** for message encryption
-- **Ed25519** for digital signatures
-- **Argon2id** for password-protected rooms
-
-## Your Rights
-
-You have complete control:
-- **Delete Everything**: Triple-tap the logo to instantly wipe all data
-- **Leave Anytime**: Close the app and your presence disappears
-- **No Account**: Nothing to delete from servers because there are none
-- **Portability**: Your data never leaves your device unless you export it
-
-## Location Data & Permissions
-
-To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
-
-### 1. Bluetooth Low Energy (BLE) Scanning
-- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
-- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
-- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
-
-### 2. Geohash Chat Functionality
-- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
-- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
-- **Privacy protection:**
- - Your precise GPS coordinates are **never** sent to any server or peer.
- - Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
- - You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
-
-**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
-
-## Children's Privacy
-
-bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
-
-## Data Retention
-
-- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
-- **Identity Key**: Persists until you delete the app
-- **Favorites**: Persist until you remove them or delete the app
-- **Everything Else**: Exists only during active sessions
-
-## Security Measures
-
-- All communication is encrypted
-- No data transmitted to servers (there are none)
-- Open source code for public audit
-- Regular security updates
-- Cryptographic signatures prevent tampering
-
-## Changes to This Policy
-
-If we update this policy:
-- The "Last updated" date will change
-- The updated policy will be included in the app
-- No retroactive changes can affect data (since we don't collect any)
-
-## Contact
-
-bitchat is an open source project. For privacy questions:
-- Review our code: https://github.com/yourusername/bitchat
-- Open an issue on GitHub
-- Join the discussion in public rooms
-
-## Philosophy
-
-Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
-
----
-
-*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
\ No newline at end of file
diff --git a/README.md b/README.md
index c9b0091d..93d740c4 100644
--- a/README.md
+++ b/README.md
@@ -1,308 +1,86 @@
-
-
-
+
-> [!WARNING]
-> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress.
+## bitchat for Android
-# bitchat for Android
+A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers.
-A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required for mesh chats, no servers, no phone numbers - just pure encrypted communication. Bitchat also supports geohash channels, which use an internet connection to connect you with others in your geographic area.
+This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication.
-This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication.
+[bitchat.free](http://bitchat.free)
-## Install bitchat
-
-You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
-
-Or you can:
+[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases)
[
](https://play.google.com/store/apps/details?id=com.bitchat.droid)
-**Instructions:**
-
-1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it.
-2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**.
-3. **Install:** Open the downloaded `.apk` file to begin the installation.
-
## License
This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details.
## Features
-- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat
-- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
-- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
-- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection
-- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect
-- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers
-- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
-- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners
-- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data
-- **✅ Modern Android UI**: Jetpack Compose with Material Design 3
-- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version
-- **✅ Battery Optimization**: Adaptive scanning and power management
-
-## Android Setup
-
-### Prerequisites
-
-- **Android Studio**: Arctic Fox (2020.3.1) or newer
-- **Android SDK**: API level 26 (Android 8.0) or higher
-- **Kotlin**: 1.8.0 or newer
-- **Gradle**: 7.0 or newer
-
-### Build Instructions
-
-1. **Clone the repository:**
- ```bash
- git clone https://github.com/permissionlesstech/bitchat-android.git
- cd bitchat-android
- ```
-
-2. **Open in Android Studio:**
- ```bash
- # Open Android Studio and select "Open an Existing Project"
- # Navigate to the bitchat-android directory
- ```
-
-3. **Build the project:**
- ```bash
- ./gradlew build
- ```
-
-4. **Install on device:**
- ```bash
- ./gradlew installDebug
- ```
-
-### Development Build
-
-For development builds with debugging enabled:
-
-```bash
-./gradlew assembleDebug
-adb install -r app/build/outputs/apk/debug/app-debug.apk
-```
-
-### Release Build
-
-For production releases:
-
-```bash
-./gradlew assembleRelease
-```
-
-## Android-Specific Requirements
-
-### Permissions
-
-The app requires the following permissions (automatically requested):
-
-- **Bluetooth**: Core BLE functionality
-- **Location**: Required for BLE scanning on Android
-- **Network**: Expand your mesh through public internet relays
-- **Notifications**: Message alerts and background updates
-
-### Hardware Requirements
-
-- **Bluetooth LE (BLE)**: Required for mesh networking
-- **Android 8.0+**: API level 26 minimum
-- **RAM**: 2GB recommended for optimal performance
-
-## Usage
-
-### Basic Commands
-
-- `/j #channel` - Join or create a channel
-- `/m @name message` - Send a private message
-- `/w` - List online users
-- `/channels` - Show all discovered channels
-- `/block @name` - Block a peer from messaging you
-- `/block` - List all blocked peers
-- `/unblock @name` - Unblock a peer
-- `/clear` - Clear chat messages
-- `/pass [password]` - Set/change channel password (owner only)
-- `/transfer @name` - Transfer channel ownership
-- `/save` - Toggle message retention for channel (owner only)
-
-### Getting Started
-
-1. **Install the app** on your Android device (requires Android 8.0+)
-2. **Grant permissions** for Bluetooth and location when prompted
-3. **Launch bitchat** - it will auto-start mesh networking
-4. **Set your nickname** or use the auto-generated one
-5. **Connect automatically** to nearby iOS and Android bitchat users
-6. **Join a channel** with `/j #general` or start chatting in public
-7. **Messages relay** through the mesh network to reach distant peers
-
-### Android UI Features
-
-- **Jetpack Compose UI**: Modern Material Design 3 interface
-- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS
-- **Haptic Feedback**: Vibrations for interactions and notifications
-- **Adaptive Layout**: Optimized for various Android screen sizes
-- **Message Status**: Real-time delivery and read receipts
-- **RSSI Indicators**: Signal strength colors for each peer
-
-### Channel Features
-
-- **Password Protection**: Channel owners can set passwords with `/pass`
-- **Message Retention**: Owners can enable mandatory message saving with `/save`
-- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
-- **Ownership Transfer**: Pass control to trusted users with `/transfer`
-
-## Security & Privacy
-
-### Encryption
-- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
-- **Channel Messages**: Argon2id password derivation + AES-256-GCM
-- **Digital Signatures**: Ed25519 for message authenticity
-- **Forward Secrecy**: New key pairs generated each session
-
-### Privacy Features
-- **No Registration**: No accounts, emails, or phone numbers required
-- **Ephemeral by Default**: Messages exist only in device memory
-- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis
-- **Emergency Wipe**: Triple-tap logo to instantly clear all data
-- **Bundled Tor Support**: Built-in Tor network integration for enhanced privacy when internet connectivity is available
-
-## Performance & Efficiency
-
-### Message Compression
-- **LZ4 Compression**: Automatic compression for messages >100 bytes
-- **30-70% bandwidth savings** on typical text messages
-- **Smart compression**: Skips already-compressed data
-
-### Battery Optimization
-- **Adaptive Power Modes**: Automatically adjusts based on battery level
- - Performance mode: Full features when charging or >60% battery
- - Balanced mode: Default operation (30-60% battery)
- - Power saver: Reduced scanning when <30% battery
- - Ultra-low power: Emergency mode when <10% battery
-- **Background efficiency**: Automatic power saving when app backgrounded
-- **Configurable scanning**: Duty cycle adapts to battery state
-
-### Network Efficiency
-- **Optimized Bloom filters**: Faster duplicate detection with less memory
-- **Message aggregation**: Batches small messages to reduce transmissions
-- **Adaptive connection limits**: Adjusts peer connections based on power mode
+- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging
+- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays
+- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable
+- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh
+- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops)
+- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices
+- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM)
+- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
+- **Tor Support**: Built-in Tor (Arti) for private internet connectivity
+- **Emergency Wipe**: Triple-tap to instantly clear all data
+- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS
## Technical Architecture
-### Binary Protocol
-bitchat uses an efficient binary protocol optimized for Bluetooth LE:
-- Compact packet format with 1-byte type field
-- TTL-based message routing (max 7 hops)
-- Automatic fragmentation for large messages
-- Message deduplication via unique IDs
+### Bluetooth Mesh Network (Offline)
-### Mesh Networking
-- Each device acts as both client and peripheral
-- Automatic peer discovery and connection management
-- Store-and-forward for offline message delivery
-- Adaptive duty cycling for battery optimization
+- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices
+- Noise Protocol sessions with forward secrecy; peer identities derived from static keys
+- Compact binary packet format with fragmentation, TTL routing, and deduplication
+- Adaptive duty cycling and connection limits for battery efficiency
+- Foreground service keeps the mesh alive within Android background execution limits
-### Android-Specific Optimizations
-- **Coroutine Architecture**: Asynchronous operations for mesh networking
-- **Kotlin Coroutines**: Thread-safe concurrent mesh operations
-- **EncryptedSharedPreferences**: Secure storage for user settings
-- **Lifecycle-Aware**: Proper handling of Android app lifecycle
-- **Battery Optimization**: Foreground service and adaptive scanning
+### Nostr Protocol (Internet)
-## Android Technical Architecture
+- Global reach via public relays, geohash-based location channels
+- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable
+- Ephemeral keys per geohash area
-### Core Components
+### Android Stack
-1. **BitchatApplication.kt**: Application-level initialization and dependency injection
-2. **MainActivity.kt**: Main activity handling permissions and UI hosting
-3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic
-4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles)
-5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle
-6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format
-7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3
+- Kotlin, Jetpack Compose (Material 3), MVVM
+- Coroutines and Flow for all networking and state
+- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry)
-### Dependencies
+## Building
-- **Jetpack Compose**: Modern declarative UI
-- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM)
-- **Nordic BLE Library**: Reliable Bluetooth LE operations
-- **Kotlin Coroutines**: Asynchronous programming
-- **LZ4**: Message compression (when enabled)
-- **EncryptedSharedPreferences**: Secure local storage
+Requires Android Studio and the Android SDK (API 26+).
-### Binary Protocol Compatibility
+```bash
+git clone https://github.com/permissionlesstech/bitchat-android.git
+cd bitchat-android
+./gradlew assembleDebug
+```
-The Android implementation maintains 100% binary protocol compatibility with iOS:
-- **Header Format**: Identical 13-byte header structure
-- **Packet Types**: Same message types and routing logic
-- **Encryption**: Identical cryptographic algorithms and key exchange
-- **UUIDs**: Same Bluetooth service and characteristic identifiers
-- **Fragmentation**: Compatible message fragmentation for large content
+Install on a connected device:
-## Publishing to Google Play
+```bash
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+```
-### Preparation
+The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
-1. **Update version information:**
- ```kotlin
- // In app/build.gradle.kts
- defaultConfig {
- versionCode = 2 // Increment for each release
- versionName = "1.1.0" // User-visible version
- }
- ```
+## Testing
-2. **Create a signed release build:**
- ```bash
- ./gradlew assembleRelease
- ```
+```bash
+# Unit tests
+./gradlew test
-3. **Generate app bundle (recommended for Play Store):**
- ```bash
- ./gradlew bundleRelease
- ```
+# Lint
+./gradlew lint
-### Play Store Requirements
+# Instrumented tests (requires a device or emulator)
+./gradlew connectedAndroidTest
+```
-- **Target API**: Latest Android API (currently 34)
-- **Privacy Policy**: Required for apps requesting sensitive permissions
-- **App Permissions**: Justify Bluetooth and location usage
-- **Content Rating**: Complete questionnaire for age-appropriate content
-
-### Distribution
-
-- **Google Play Store**: Main distribution channel
-- **F-Droid**: For open-source distribution
-- **Direct APK**: For testing and development
-
-## Cross-Platform Communication
-
-This Android port enables seamless communication with the original iOS bitchat app:
-
-- **iPhone ↔ Android**: Full bidirectional messaging
-- **Mixed Groups**: iOS and Android users in same channels
-- **Feature Parity**: All commands and encryption work across platforms
-- **Protocol Sync**: Identical message format and routing behavior
-
-**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat)
-
-## Contributing
-
-Contributions are welcome! Key areas for enhancement:
-
-1. **Performance**: Battery optimization and connection reliability
-2. **UI/UX**: Additional Material Design 3 features
-3. **Security**: Enhanced cryptographic features
-4. **Testing**: Unit and integration test coverage
-5. **Documentation**: API documentation and development guides
-
-## Support & Issues
-
-- **Bug Reports**: [Create an issue](../../issues) with device info and logs
-- **Feature Requests**: [Start a discussion](https://github.com/orgs/permissionlesstech/discussions)
-- **Security Issues**: Email security concerns privately
-- **iOS Compatibility**: Cross-reference with [original iOS repo](https://github.com/jackjackbits/bitchat)
-
-For iOS-specific issues, please refer to the [original iOS bitchat repository](https://github.com/jackjackbits/bitchat).
+Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices.
diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt
deleted file mode 100644
index f2aa1db2..00000000
--- a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.bitchat.android.mesh
-
-/**
- * Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that
- * authentication attempt.
- */
-internal object AuthenticatedBleLinkPolicy {
- data class Claim(val deviceAddress: String, val linkID: String)
-
- fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean =
- claim != null &&
- claim.deviceAddress == authenticatedAddress &&
- claim.linkID == authenticatedLinkID
-}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
index 35a3d9f4..f2bb54c6 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
@@ -67,8 +67,8 @@ class BluetoothConnectionManager(
delegate?.onDeviceConnected(device)
}
- override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) {
- delegate?.onDeviceDisconnected(device, linkID)
+ override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) {
+ delegate?.onDeviceDisconnected(device, linkID, peerID)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@@ -92,8 +92,8 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
- fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
- connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID)
+ fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
+ connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID)
fun getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)
@@ -338,6 +338,16 @@ class BluetoothConnectionManager(
)
}
+ suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean {
+ if (!isActive || !isBleTransportEnabled()) return false
+
+ return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance(
+ routed,
+ serverManager.getGattServer(),
+ serverManager.getCharacteristic()
+ )
+ }
+
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendToPeer(
@@ -473,6 +483,6 @@ interface BluetoothConnectionManagerDelegate {
ingressLinkID: String
)
fun onDeviceConnected(device: BluetoothDevice)
- fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?)
+ fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
index f37d8574..94feb68d 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
@@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
private val firstAnnounceSeen = ConcurrentHashMap()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap()
- private val peerBindingLock = Any()
+ private val connectionStateLock = Any()
/**
* Consolidated device connection information
@@ -77,9 +77,9 @@ class BluetoothConnectionTracker(
*/
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
- synchronized(peerBindingLock) {
+ synchronized(connectionStateLock) {
connectedDevices[deviceAddress] = deviceConn
- // A mapping authenticates a GATT connection, not a reusable Bluetooth address.
+ // A route observation belongs to this GATT generation, not its reusable address.
addressPeerMap.remove(deviceAddress)
}
removePendingConnection(deviceAddress)
@@ -91,7 +91,7 @@ class BluetoothConnectionTracker(
* Update a device connection
*/
fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
- synchronized(peerBindingLock) {
+ synchronized(connectionStateLock) {
connectedDevices[deviceAddress] = deviceConn
}
}
@@ -100,7 +100,7 @@ class BluetoothConnectionTracker(
deviceAddress: String,
linkID: String,
update: (DeviceConnection) -> DeviceConnection
- ): Boolean = synchronized(peerBindingLock) {
+ ): Boolean = synchronized(connectionStateLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != linkID) return@synchronized false
connectedDevices[deviceAddress] = update(current)
@@ -117,10 +117,16 @@ class BluetoothConnectionTracker(
fun getCurrentLinkID(deviceAddress: String): String? =
connectedDevices[deviceAddress]?.linkID
- fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
- synchronized(peerBindingLock) {
+ /**
+ * Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID].
+ *
+ * A peer may be reachable over more than one link, so observing one link must not discard the
+ * other observations. The link generation check prevents a late packet from an old GATT
+ * connection from being applied to a replacement connection that reused the same address.
+ */
+ fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
+ synchronized(connectionStateLock) {
if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false
- addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress }
addressPeerMap[deviceAddress] = peerID
true
}
@@ -265,7 +271,7 @@ class BluetoothConnectionTracker(
* Clean up a specific device connection
*/
fun cleanupDeviceConnection(deviceAddress: String) {
- synchronized(peerBindingLock) {
+ synchronized(connectionStateLock) {
connectedDevices.remove(deviceAddress)
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
@@ -277,7 +283,7 @@ class BluetoothConnectionTracker(
fun cleanupDeviceConnectionIfCurrent(
deviceAddress: String,
expectedLinkID: String
- ): Boolean = synchronized(peerBindingLock) {
+ ): Boolean = synchronized(connectionStateLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != expectedLinkID) {
return@synchronized false
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
index 58bb1f48..498339ce 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
@@ -521,10 +521,12 @@ class BluetoothGattClientManager(
} else {
Log.i(TAG, "Disconnected from $deviceAddress (client)")
}
+ // Capture the observed peer before cleanup drops the address mapping.
+ val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress]
connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID)
// Notify higher layers about device disconnection to update direct flags
- delegate?.onDeviceDisconnected(gatt.device, linkID)
+ delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID)
connectionScope.launch {
delay(500) // CLEANUP_DELAY
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
index 53ae88ef..7a15b963 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
@@ -203,11 +203,13 @@ class BluetoothGattServerManager(
BluetoothProfile.STATE_DISCONNECTED -> {
Log.i(TAG, "Disconnected from ${device.address} (server)")
val linkID = serverLinkIDs.remove(device.address)
+ // Capture the observed peer before cleanup drops the address mapping.
+ val disconnectedPeerID = connectionTracker.addressPeerMap[device.address]
if (linkID != null) {
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
}
// Notify delegate about device disconnection so higher layers can update direct flags
- delegate?.onDeviceDisconnected(device, linkID)
+ delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID)
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index bdd49d8e..f828af9e 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -21,7 +21,6 @@ import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
-import java.util.concurrent.ConcurrentHashMap
import kotlin.math.sign
import kotlin.random.Random
@@ -43,8 +42,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
companion object {
private const val TAG = "BluetoothMeshService"
- private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
+ private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS
}
// Core components - each handling specific responsibilities
@@ -55,6 +54,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private val peerManager = PeerManager()
private val fragmentManager = FragmentManager()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope)
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
private val authenticatedPeerState by lazy {
AuthenticatedPeerStateCoordinator(
@@ -128,8 +128,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Coroutines
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
- private val provisionalBleClaims =
- ConcurrentHashMap()
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
@@ -138,6 +136,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
+ // Flush queued private messages as soon as a BLE Noise session authenticates,
+ // instead of relying on the foreground-only UI poll.
+ encryptionService.onSessionEstablished = { peerID ->
+ Log.d(TAG, "BLE Noise session established with ${peerID.take(8)}")
+ try {
+ com.bitchat.android.services.MessageRouter
+ .tryGetInstance()
+ ?.onSessionEstablished(peerID)
+ } catch (_: Exception) { }
+ }
+
// Initialize sync manager (needs serviceScope)
gossipSyncManager = GossipSyncManager(
myPeerID = myPeerID,
@@ -176,6 +185,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
connectionManager.broadcastPacket(packet)
}
+ override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
+ if (!isBleTransportEnabled()) return false
+ return connectionManager.broadcastControlPacketAndAwaitAcceptance(packet)
+ }
+
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
if (!isBleTransportEnabled()) return
connectionManager.sendPacketToPeer(peerID, packet)
@@ -189,6 +203,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return true
}
+ private suspend fun broadcastRoutedPacketAndReport(routed: RoutedPacket): Boolean {
+ if (!isBleTransportEnabled()) return false
+ val acceptedByBle =
+ connectionManager.broadcastControlPacketAndAwaitAcceptance(routed)
+ val acceptedByBridgedTransport =
+ TransportBridgeService.broadcastAndReport("BLE", routed)
+ return acceptedByBle || acceptedByBridgedTransport
+ }
+
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
@@ -235,7 +258,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
- provisionalBleClaims.remove(peerID)
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
// Remove from mesh graph topology to prevent routing through stale peers
@@ -264,22 +286,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
- val expectedClaim = provisionalBleClaims.remove(peerID)
- if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) {
- val authenticatedClaim = checkNotNull(expectedClaim)
- if (connectionManager.bindPeerIfCurrent(
- authenticatedClaim.deviceAddress,
- authenticatedClaim.linkID,
- peerID
- )
- ) {
- Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID")
- try { peerManager.refreshPeerList() } catch (_: Exception) { }
- try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { }
- } else {
- Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress")
- }
- }
// Send announcement and cached messages after key exchange
serviceScope.launch {
delay(100)
@@ -413,6 +419,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun hasNoiseSession(peerID: String): Boolean {
return encryptionService.hasEstablishedSession(peerID)
}
+
+ override fun removeNoiseSession(peerID: String) {
+ try {
+ encryptionService.removePeer(peerID)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}")
+ }
+ }
override fun initiateNoiseHandshake(peerID: String) {
try {
@@ -485,7 +499,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delegate?.didReceiveMessage(message)
// If no UI delegate attached (app closed), show DM notification via service manager
- if (delegate == null && message.isPrivate) {
+ if (delegate == null && message.isPrivate && message.sender != "system") {
try {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
@@ -503,10 +517,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
+ // Status events can arrive while MainActivity has detached the UI delegate.
+ // Persist first so the next UI collector observes the advancement.
+ try {
+ com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
+ messageID,
+ com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date())
+ )
+ } catch (_: Exception) { }
delegate?.didReceiveDeliveryAck(messageID, peerID)
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
+ try {
+ com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
+ messageID,
+ com.bitchat.android.model.DeliveryStatus.Read(peerID, Date())
+ )
+ } catch (_: Exception) { }
delegate?.didReceiveReadReceipt(messageID, peerID)
}
@@ -546,48 +574,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return runBlocking { securityManager.handleNoiseHandshake(routed) }
}
- override fun handleNoiseEncrypted(routed: RoutedPacket) {
- serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
+ override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
+ return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
}
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
- val deviceAddress = routed.relayAddress
- val pid = routed.peerID
- val linkID = routed.ingressLinkID
- val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
- val alreadyAuthenticated = deviceAddress != null &&
- pid != null &&
- connectionManager.addressPeerMap[deviceAddress] == pid
- if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) {
- try {
- val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)
- registerProvisionalBleClaim(pid, claim)
- val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true)
- if (handshakeData != null) {
- val handshake = signPacketBeforeBroadcast(
- BitchatPacket(
- version = 1u,
- type = MessageType.NOISE_HANDSHAKE.value,
- senderID = hexStringToByteArray(myPeerID),
- recipientID = hexStringToByteArray(pid),
- timestamp = System.currentTimeMillis().toULong(),
- payload = handshakeData,
- ttl = MAX_TTL
- )
- )
- if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) {
- provisionalBleClaims.remove(pid, claim)
- Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress")
- }
- } else {
- provisionalBleClaims.remove(pid, claim)
- }
- } catch (e: Exception) {
- provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID))
- Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}")
+ DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation ->
+ if (connectionManager.observePeerIfCurrent(
+ observation.relayAddress,
+ observation.ingressLinkID,
+ observation.peerID
+ )
+ ) {
+ Log.d(
+ TAG,
+ "Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}"
+ )
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ try {
+ gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000)
+ } catch (_: Exception) { }
+ } else {
+ Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}")
}
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
@@ -689,18 +700,41 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onDeviceDisconnected(
device: android.bluetooth.BluetoothDevice,
- linkID: String?
+ linkID: String?,
+ peerID: String?
) {
- Log.i(TAG, "Device disconnected: ${device.address}")
- val addr = device.address
- clearProvisionalBleClaimsForLink(addr, linkID)
+ Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)")
- // refresh peer list on disconnect.
+ // refresh peer list on disconnect.
try { peerManager.refreshPeerList() } catch (_: Exception) { }
- // ConnectionTracker already removes an authenticated mapping only when this exact
+ // ConnectionTracker already removes an observed mapping only when this exact
// link is still current. Do not remove by reusable address here: this may be a late
// disconnect callback from a replaced GATT connection.
+
+ // If the peer that used this link does not come back within a short grace
+ // period (no other link, no traffic), tear down their Noise session instead of
+ // waiting for the 3-minute stale-peer sweep.
+ if (peerID != null) {
+ val deviceAddress = device.address
+ val disconnectedAt = System.currentTimeMillis()
+ serviceScope.launch {
+ delay(PEER_DISCONNECT_GRACE_MS)
+ try {
+ val linkBack =
+ connectionManager.addressPeerMap.containsKey(deviceAddress) ||
+ connectionManager.addressPeerMap.containsValue(peerID)
+ val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L
+ val seenAfterDisconnect = lastSeen > disconnectedAt
+ if (!linkBack && !seenAfterDisconnect) {
+ Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session")
+ peerManager.removePeer(peerID)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}")
+ }
+ }
+ }
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@@ -712,24 +746,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
- private fun registerProvisionalBleClaim(
- peerID: String,
- claim: AuthenticatedBleLinkPolicy.Claim
- ) {
- provisionalBleClaims[peerID] = claim
- serviceScope.launch {
- delay(BLE_AUTHENTICATION_TIMEOUT_MS)
- provisionalBleClaims.remove(peerID, claim)
- }
- }
-
- private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) {
- if (linkID == null) return
- provisionalBleClaims.entries.removeIf { (_, claim) ->
- claim.deviceAddress == deviceAddress && claim.linkID == linkID
- }
- }
-
/**
* Start the mesh service
*/
@@ -999,7 +1015,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty()) return
- if (recipientNickname.isEmpty()) return
+ // Nicknames are presentation metadata. Routing and encryption are bound to the peer ID,
+ // so a temporarily unresolved nickname must never suppress a private message.
serviceScope.launch {
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
@@ -1076,12 +1093,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
try {
- // Avoid duplicate read receipts: check persistent store first
- val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
- if (seenStore?.hasRead(messageID) == true) {
- return@launch
- }
-
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -1105,10 +1116,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- broadcastRoutedPacket(RoutedPacket(signedPacket))
-
- // Persist as read after successful send
- try { seenStore?.markRead(messageID) } catch (_: Exception) { }
+ val retryKey = "$recipientPeerID:$messageID"
+ readReceiptRetrySender.enqueue(
+ key = retryKey,
+ sendAttempt = { attempt ->
+ // Keep the addressed packet on the normal broadcaster actor so receipt
+ // attempts are ordered with other BLE traffic and can use mesh routing.
+ val accepted =
+ broadcastRoutedPacketAndReport(RoutedPacket(signedPacket))
+ Log.d(
+ TAG,
+ "Read receipt attempt $attempt accepted=$accepted " +
+ "peer=${recipientPeerID.take(8)} message=${messageID.take(8)}"
+ )
+ accepted
+ },
+ onComplete = { accepted ->
+ if (accepted) {
+ try {
+ com.bitchat.android.services.SeenMessageStore
+ .getInstance(context.applicationContext)
+ .markReadReceiptSent(messageID)
+ } catch (_: Exception) { }
+ }
+ }
+ )
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
index 0e9b0c76..1ec1baea 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
@@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster(
private data class BroadcastRequest(
val routed: RoutedPacket,
val gattServer: BluetoothGattServer?,
- val characteristic: BluetoothGattCharacteristic?
+ val characteristic: BluetoothGattCharacteristic?,
+ val accepted: CompletableDeferred? = null
)
// Actor scope for the broadcaster
@@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster(
capacity = Channel.UNLIMITED
) {
for (request in channel) {
- broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic)
+ val accepted = try {
+ broadcastSinglePacketInternal(
+ request.routed,
+ request.gattServer,
+ request.characteristic
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Broadcast request failed: ${e.message}")
+ false
+ }
+ request.accepted?.complete(accepted)
}
}
@@ -242,6 +255,29 @@ class BluetoothPacketBroadcaster(
}
}
+ /**
+ * Serializes a small control packet with normal BLE traffic and waits for the platform write
+ * API to accept at least one notification/write.
+ */
+ suspend fun broadcastControlPacketAndAwaitAcceptance(
+ routed: RoutedPacket,
+ gattServer: BluetoothGattServer?,
+ characteristic: BluetoothGattCharacteristic?
+ ): Boolean {
+ val accepted = CompletableDeferred()
+ return try {
+ broadcasterActor.send(
+ BroadcastRequest(routed, gattServer, characteristic, accepted)
+ )
+ accepted.await()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to queue control packet: ${e.message}")
+ broadcastSinglePacketInternal(routed, gattServer, characteristic)
+ }
+ }
+
/**
* Targeted send to a specific peer (by peerID) if directly connected.
* Returns true if sent to at least one matching connection.
@@ -274,11 +310,11 @@ class BluetoothPacketBroadcaster(
routed: RoutedPacket,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
- ) {
+ ): Boolean {
val packet = routed.packet
// iOS-compatible: Use selective padding policy for BLE
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
- val data = packet.toBinaryData(padding = padForBLE) ?: return
+ val data = packet.toBinaryData(padding = padForBLE) ?: return false
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
@@ -320,7 +356,7 @@ class BluetoothPacketBroadcaster(
}
}
- if (sent) return
+ if (sent) return true
Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
}
@@ -337,7 +373,7 @@ class BluetoothPacketBroadcaster(
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
- return // Sent, no need to continue
+ return true
}
}
@@ -350,7 +386,7 @@ class BluetoothPacketBroadcaster(
if (writeToDeviceConn(targetDeviceConn, data)) {
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
- return // Sent, no need to continue
+ return true
}
}
}
@@ -360,6 +396,7 @@ class BluetoothPacketBroadcaster(
val connectedDevices = connectionTracker.getConnectedDevices()
val senderID = packet.senderID.toHexString()
+ var accepted = false
// Send to server connections (devices connected to our GATT server)
subscribedDevices.forEach { device ->
@@ -371,6 +408,7 @@ class BluetoothPacketBroadcaster(
}
val sent = notifyDevice(device, data, gattServer, characteristic)
if (sent) {
+ accepted = true
val toPeer = connectionTracker.addressPeerMap[device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
}
@@ -387,11 +425,13 @@ class BluetoothPacketBroadcaster(
}
val sent = writeToDeviceConn(deviceConn, data)
if (sent) {
+ accepted = true
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
}
}
}
+ return accepted
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt
new file mode 100644
index 00000000..e0be2835
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt
@@ -0,0 +1,26 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.RoutedPacket
+
+/**
+ * Describes transport reachability learned from an already-validated ANNOUNCE.
+ *
+ * This is deliberately only a routing observation. Noise authenticates the peer independently and
+ * must not be restarted merely to associate the current transport link with that peer.
+ */
+internal object DirectLinkAnnouncementPolicy {
+ data class Observation(
+ val peerID: String,
+ val relayAddress: String,
+ val ingressLinkID: String
+ )
+
+ fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? {
+ if (routed.packet.ttl != maxTtl) return null
+ return Observation(
+ peerID = routed.peerID ?: return null,
+ relayAddress = routed.relayAddress ?: return null,
+ ingressLinkID = routed.ingressLinkID ?: return null
+ )
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
index 0c963cc1..ae1f535a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
@@ -20,7 +20,6 @@ import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -44,7 +43,6 @@ class MeshCore(
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
- val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
val announcementNicknameProvider: (() -> String?)? = null,
@@ -53,6 +51,7 @@ class MeshCore(
private val peerManager = PeerManager()
val fragmentManager = FragmentManager()
+ private val readReceiptRetrySender = RetryingControlPacketSender(scope)
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
private val authenticatedPeerState by lazy {
AuthenticatedPeerStateCoordinator(
@@ -152,12 +151,14 @@ class MeshCore(
fun stopCore() {
if (!isActive) return
isActive = false
+ directPeers.clear()
if (ownsGossipManager) {
gossipSyncManager.stop()
}
}
fun shutdown() {
+ directPeers.clear()
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -186,11 +187,22 @@ class MeshCore(
transport.broadcastPacket(packet)
}
+ fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean {
+ return transport.broadcastPacket(packet)
+ }
+
private fun dispatchGlobal(routed: RoutedPacket) {
transport.broadcastPacket(routed)
TransportBridgeService.broadcast(transport.id, routed)
}
+ private suspend fun dispatchGlobalAndReport(routed: RoutedPacket): Boolean {
+ val acceptedByLocalTransport = transport.broadcastPacket(routed)
+ val acceptedByBridgedTransport =
+ TransportBridgeService.broadcastAndReport(transport.id, routed)
+ return acceptedByLocalTransport || acceptedByBridgedTransport
+ }
+
private fun setupDelegates() {
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List) {
@@ -199,6 +211,7 @@ class MeshCore(
}
override fun onPeerRemoved(peerID: String) {
+ directPeers.remove(peerID)
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
@@ -219,14 +232,6 @@ class MeshCore(
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
- if (directRelayAddress != null && ingressLinkID != null) {
- hooks.onDirectNoiseAuthenticated?.invoke(
- peerID,
- directRelayAddress,
- ingressLinkID,
- authenticatedRemoteStaticKey
- )
- }
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@@ -354,6 +359,14 @@ class MeshCore(
return encryptionService.hasEstablishedSession(peerID)
}
+ override fun removeNoiseSession(peerID: String) {
+ try {
+ encryptionService.removePeer(peerID)
+ } catch (e: Exception) {
+ Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}")
+ }
+ }
+
override fun initiateNoiseHandshake(peerID: String) {
this@MeshCore.initiateNoiseHandshake(peerID)
}
@@ -388,10 +401,22 @@ class MeshCore(
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
+ try {
+ com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
+ messageID,
+ com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date())
+ )
+ } catch (_: Exception) { }
delegate?.didReceiveDeliveryAck(messageID, peerID)
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
+ try {
+ com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
+ messageID,
+ com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date())
+ )
+ } catch (_: Exception) { }
delegate?.didReceiveReadReceipt(messageID, peerID)
}
@@ -429,8 +454,8 @@ class MeshCore(
return runBlocking { securityManager.handleNoiseHandshake(routed) }
}
- override fun handleNoiseEncrypted(routed: RoutedPacket) {
- scope.launch { messageHandler.handleNoiseEncrypted(routed) }
+ override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
+ return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
}
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
@@ -681,8 +706,24 @@ class MeshCore(
signature = null,
ttl = maxTtl
)
- dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
- hooks.onReadReceiptSent?.invoke(messageID)
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ val retryKey = "$recipientPeerID:$messageID"
+ readReceiptRetrySender.enqueue(
+ key = retryKey,
+ sendAttempt = {
+ dispatchGlobalAndReport(RoutedPacket(signedPacket))
+ },
+ onComplete = { accepted ->
+ if (accepted) {
+ try {
+ com.bitchat.android.services.SeenMessageStore
+ .getInstance(context.applicationContext)
+ .markReadReceiptSent(messageID)
+ } catch (_: Exception) { }
+ hooks.onReadReceiptSent?.invoke(messageID)
+ }
+ }
+ )
} catch (e: Exception) {
Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
}
@@ -829,6 +870,7 @@ class MeshCore(
}
fun removePeer(peerID: String) {
+ directPeers.remove(peerID)
peerManager.removePeer(peerID)
}
@@ -882,44 +924,6 @@ class MeshCore(
}
}
- /**
- * Starts a fresh replacement handshake on one exact direct transport generation.
- * This authenticates provisional transport claims without broadcasting the challenge or
- * accidentally sending it through a socket that later reused the same alias.
- */
- fun initiateNoiseHandshakeOnLink(
- peerID: String,
- relayAddress: String,
- ingressLinkID: String
- ): Boolean {
- return try {
- val handshakeData = encryptionService.initiateHandshake(
- peerID,
- replaceEstablished = true
- ) ?: return false
- val packet = BitchatPacket(
- version = 1u,
- type = MessageType.NOISE_HANDSHAKE.value,
- senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
- recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
- timestamp = System.currentTimeMillis().toULong(),
- payload = handshakeData,
- ttl = maxTtl
- )
- transport.sendPacketToLink(
- relayAddress,
- ingressLinkID,
- signPacketBeforeBroadcast(packet)
- )
- } catch (e: Exception) {
- Log.e(
- "MeshCore",
- "Failed to initiate link-bound Noise handshake with $peerID: ${e.message}"
- )
- false
- }
- }
-
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
@@ -973,6 +977,7 @@ class MeshCore(
}
fun clearAllInternalData() {
+ directPeers.clear()
fragmentManager.clearAllFragments()
storeForwardManager.clearAllCache()
securityManager.clearAllData()
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
index b3f2fcc4..354fcd7e 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
@@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket
interface MeshTransport {
val id: String
- fun broadcastPacket(routed: RoutedPacket)
+ /**
+ * Broadcasts a packet and reports whether at least one concrete transport write was accepted.
+ */
+ fun broadcastPacket(routed: RoutedPacket): Boolean
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
index d3356e85..05a5bf00 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
@@ -27,52 +27,62 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
companion object {
private const val TAG = "MessageHandler"
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
+ private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3
}
-
+
// Delegate for callbacks
var delegate: MessageHandlerDelegate? = null
-
+
// Reference to PacketProcessor for recursive packet handling
var packetProcessor: PacketProcessor? = null
-
+
// Coroutines
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
+
+ // Consecutive decrypt failures per peer; only signature-verified packets reach this path,
+ // so repeated failures mean the established session is stale (peer re-handshaked elsewhere).
+ private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap()
+
/**
* Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
+ *
+ * Returns false when the payload could not be decrypted, so callers can treat the
+ * packet as not liveness-proving (no lastSeen refresh, no relay).
*/
- suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
+ suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
-
+
// Skip our own messages
- if (peerID == myPeerID) return
-
+ if (peerID == myPeerID) return true
+
// Check if this message is for us
val recipientID = packet.recipientID?.toHexString()
if (recipientID != myPeerID) {
- return
+ return true
}
-
+
try {
// Decrypt the message using the Noise service
val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryption == null) {
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
- return
+ registerDecryptFailure(peerID)
+ return false
}
+ consecutiveDecryptFailures.remove(peerID)
val decryptedData = decryption.plaintext
-
+
if (decryptedData.isEmpty()) {
Log.w(TAG, "Decrypted data is empty from $peerID")
- return
+ return true
}
-
+
val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
if (noisePayload == null) {
Log.w(TAG, "Failed to parse NoisePayload from $peerID")
- return
+ return true
}
when (noisePayload.type) {
@@ -86,7 +96,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
handleFavoriteNotificationFromMesh(pmContent, peerID)
// Acknowledge delivery for UX parity
sendDeliveryAck(privateMessage.messageID, peerID)
- return
+ return true
}
// Create BitchatMessage - preserve source packet timestamp
@@ -180,6 +190,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
} catch (e: Exception) {
Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}")
}
+ return true
+ }
+
+ /**
+ * Count consecutive decrypt failures from a signature-verified peer that we still hold an
+ * established session for. After repeated failures the session is stale (the peer completed
+ * a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side
+ * will finish via the responder-candidate path and evict its own stale session.
+ */
+ private fun registerDecryptFailure(peerID: String) {
+ if (peerID == "unknown" || peerID == myPeerID) return
+ if (delegate?.hasNoiseSession(peerID) != true) return
+ val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1
+ if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
+ consecutiveDecryptFailures.remove(peerID)
+ Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking")
+ try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) {
+ Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}")
+ }
+ try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) {
+ Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}")
+ }
+ } else {
+ consecutiveDecryptFailures[peerID] = failures
+ }
}
/**
@@ -575,13 +610,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
val action = if (control.isFavorite) "favorited" else "unfavorited"
+ val notice = "${peerInfo.nickname} $action you$guidance"
val sys = com.bitchat.android.model.BitchatMessage(
sender = "system",
- content = "${peerInfo.nickname} $action you$guidance",
+ content = notice,
timestamp = java.util.Date(),
isRelay = false
)
delegate?.onMessageReceived(sys)
+
+ // Mirror the notice into the private conversation so it's visible while chatting
+ try {
+ val conversationID = com.bitchat.android.services.ContactDirectory
+ .canonicalConversationId(fromPeerID)
+ val sysPrivate = com.bitchat.android.model.BitchatMessage(
+ sender = "system",
+ content = notice,
+ timestamp = java.util.Date(),
+ isRelay = false,
+ isPrivate = true,
+ senderPeerID = conversationID
+ )
+ delegate?.onMessageReceived(sysPrivate)
+ } catch (_: Exception) {
+ // Best-effort; public notice already delivered
+ }
}
} catch (_: Exception) {
// Best-effort; ignore errors
@@ -628,6 +681,7 @@ interface MessageHandlerDelegate {
// Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String)
+ fun removeNoiseSession(peerID: String) {}
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun onAuthenticatedPeerStateReceived(
peerID: String,
diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
index a5c59901..b00c6a31 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
@@ -144,7 +144,7 @@ class PacketProcessor(private val myPeerID: String) {
if (packetRelayManager.isPacketAddressedToMe(packet)) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
- MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
+ MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
validPacket = false
@@ -175,9 +175,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle Noise encrypted transport message
+ * Returns false when decryption fails so undecryptable packets do not prove liveness.
*/
- private suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
- delegate?.handleNoiseEncrypted(routed)
+ private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
+ return delegate?.handleNoiseEncrypted(routed) ?: false
}
/**
@@ -292,7 +293,7 @@ interface PacketProcessorDelegate {
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
- fun handleNoiseEncrypted(routed: RoutedPacket)
+ fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket)
diff --git a/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt
new file mode 100644
index 00000000..ba81800c
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt
@@ -0,0 +1,82 @@
+package com.bitchat.android.mesh
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * Sends small idempotent control packets redundantly while serializing the attempts submitted
+ * through this sender. Android BLE only reports that a GATT write/notification was accepted;
+ * it does not prove that the remote application processed the packet. Reusing the exact encoded
+ * packet makes retries safe: the receiver's packet/replay protection drops copies it already saw.
+ */
+internal class RetryingControlPacketSender(
+ private val scope: CoroutineScope,
+ private val maxAttempts: Int = 3,
+ private val retryDelayMs: Long = 750L,
+ private val interSendDelayMs: Long = 75L
+) {
+ private val sendMutex = Mutex()
+ private val jobsLock = Any()
+ private val activeJobs = mutableMapOf()
+
+ init {
+ require(maxAttempts > 0)
+ require(retryDelayMs >= 0)
+ require(interSendDelayMs >= 0)
+ }
+
+ /**
+ * Coalesces concurrent requests for the same logical packet. Once the retry window finishes,
+ * a later user action may enqueue the packet again; duplicate receipt processing is idempotent.
+ */
+ fun enqueue(
+ key: String,
+ sendAttempt: suspend (attempt: Int) -> Boolean,
+ onComplete: (acceptedAtLeastOnce: Boolean) -> Unit = {}
+ ) {
+ val job = synchronized(jobsLock) {
+ if (activeJobs[key]?.isActive == true) return
+
+ scope.launch(start = CoroutineStart.LAZY) {
+ var acceptedAtLeastOnce = false
+ var completed = false
+ try {
+ repeat(maxAttempts) { index ->
+ if (!isActive) return@launch
+ val attempt = index + 1
+ sendMutex.withLock {
+ try {
+ val accepted = try {
+ sendAttempt(attempt)
+ } catch (_: Exception) {
+ false
+ }
+ acceptedAtLeastOnce = accepted || acceptedAtLeastOnce
+ } finally {
+ if (interSendDelayMs > 0) delay(interSendDelayMs)
+ }
+ }
+ if (attempt < maxAttempts && retryDelayMs > 0) {
+ delay(retryDelayMs)
+ }
+ }
+ completed = true
+ } finally {
+ synchronized(jobsLock) {
+ activeJobs.remove(key)
+ }
+ if (completed) {
+ try { onComplete(acceptedAtLeastOnce) } catch (_: Exception) { }
+ }
+ }
+ }.also { activeJobs[key] = it }
+ }
+ job.start()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
index 5922ff5e..e3c0388a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
@@ -25,12 +25,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes
private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES
private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES
+ private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS
}
// Security tracking
private val processedMessages = Collections.synchronizedSet(mutableSetOf())
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf())
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf())
+ private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf())
// Delegate for callbacks
var delegate: SecurityManagerDelegate? = null
@@ -76,7 +78,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
- // This ensures we catch the "first announce" on a new connection for binding,
+ // This ensures we observe the same peer on a new direct transport connection,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
@@ -134,6 +136,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// from ambient session state: a rejected replacement may leave the old session active.
val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID)
processedKeyExchanges.add(exchangeKey)
+ keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis()
if (result.response != null) {
// Send handshake response through delegate
@@ -393,8 +396,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
/**
* Clean up old processed messages and timestamps
*/
- private fun cleanupOldData() {
- val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT
+ internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) {
+ val cutoffTime = nowMs - MESSAGE_TIMEOUT
// Clean up old message timestamps and corresponding processed messages
val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) ->
@@ -413,12 +416,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private
processedMessages.removeAll(toRemove.toSet())
removeFromMessageTimestamps(toRemove)
}
-
+
+ // Expire handshake dedup entries by time so a delayed same-ephemeral delivery
+ // (e.g. a re-handshake retry after a failed attempt) is not blocked forever.
+ val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT
+ val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) ->
+ timestamp < keyExchangeCutoff
+ }.map { it.key }
+
+ keyExchangesToRemove.forEach { exchangeKey ->
+ keyExchangeTimestamps.remove(exchangeKey)
+ processedKeyExchanges.remove(exchangeKey)
+ }
+
// Limit the size of processed key exchanges set
if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) {
val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES
val toRemove = processedKeyExchanges.take(excess)
processedKeyExchanges.removeAll(toRemove.toSet())
+ toRemove.forEach { keyExchangeTimestamps.remove(it) }
}
}
@@ -438,6 +454,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
processedMessages.clear()
processedKeyExchanges.clear()
messageTimestamps.clear()
+ keyExchangeTimestamps.clear()
}
/**
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
index c089255f..dd39eb91 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
@@ -2,6 +2,8 @@ package com.bitchat.android.noise
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
data class NoiseHandshakeProcessingResult(
val response: ByteArray?,
@@ -50,15 +52,30 @@ class NoiseSessionManager(
companion object {
private const val TAG = "NoiseSessionManager"
- private const val HANDSHAKE_TIMEOUT_MS = 20_000L
+ private const val HANDSHAKE_TIMEOUT_MS = 10_000L
+ private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
private const val SESSION_TOKEN_SIZE = 32
}
-
+
private val sessions = ConcurrentHashMap()
// An inbound replacement handshake must prove its authenticated static-key binding before it
// can evict a working transport session. Keep responder candidates outside the active map.
private val responderCandidates = ConcurrentHashMap()
+
+ private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable ->
+ Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true }
+ }
+
+ init {
+ sweepScheduler.scheduleWithFixedDelay({
+ try {
+ cleanupStaleHandshakes(System.currentTimeMillis())
+ } catch (e: Exception) {
+ Log.w(TAG, "Handshake sweep failed: ${e.message}")
+ }
+ }, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS)
+ }
// Callbacks
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
@@ -289,6 +306,31 @@ class NoiseSessionManager(
if (lastActivity == null) return false
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
}
+
+ /**
+ * Actively expire handshakes that stopped progressing (lost response, abandoned
+ * responder candidates). Established sessions are never touched here.
+ */
+ @Synchronized
+ fun cleanupStaleHandshakes(nowMs: Long) {
+ sessions.entries.toList().forEach { (peerID, session) ->
+ if (session.isHandshaking() && isHandshakeStale(session, nowMs)) {
+ Log.d(TAG, "Expiring stale handshake with $peerID")
+ if (sessions.remove(peerID, session)) {
+ session.destroy()
+ runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) }
+ }
+ }
+ }
+ responderCandidates.entries.toList().forEach { (peerID, session) ->
+ if (session.isHandshaking() && isHandshakeStale(session, nowMs)) {
+ Log.d(TAG, "Expiring stale responder candidate for $peerID")
+ if (responderCandidates.remove(peerID, session)) {
+ session.destroy()
+ }
+ }
+ }
+ }
/**
* SIMPLIFIED: Encrypt data
@@ -427,6 +469,7 @@ class NoiseSessionManager(
*/
@Synchronized
fun shutdown() {
+ sweepScheduler.shutdownNow()
sessions.values.forEach { it.destroy() }
responderCandidates.values.forEach { it.destroy() }
sessions.clear()
@@ -443,6 +486,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
object SessionNotEstablished : NoiseSessionError("Session not established")
object InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed")
+ object HandshakeTimeout : NoiseSessionError("Handshake timed out")
object AlreadyEstablished : NoiseSessionError("Session already established")
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
index a11e5158..2fdc3180 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
@@ -155,7 +155,7 @@ class NostrDirectMessageHandler(
)
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
- val suppressUnread = seenStore.hasRead(pm.messageID)
+ val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
@@ -174,7 +174,8 @@ class NostrDirectMessageHandler(
if (isViewing && !suppressUnread) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
- seenStore.markRead(pm.messageID)
+ seenStore.markReadLocally(pm.messageID)
+ seenStore.markReadReceiptSent(pm.messageID)
}
}
NoisePayloadType.DELIVERED -> {
diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
index a952d5fa..47ee8d75 100644
--- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
+++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
@@ -183,7 +183,6 @@ object BinaryProtocol {
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
-
object Flags {
const val HAS_RECIPIENT: UByte = 0x01u
const val HAS_SIGNATURE: UByte = 0x02u
@@ -200,6 +199,15 @@ object BinaryProtocol {
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
try {
+ val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+ if (packet.payload.size > maxPayloadLength) {
+ Log.w(
+ "BinaryProtocol",
+ "Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength"
+ )
+ return null
+ }
+
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: Int? = null
@@ -324,21 +332,36 @@ object BinaryProtocol {
}
}
- fun decode(data: ByteArray): BitchatPacket? {
+ fun decode(data: ByteArray): BitchatPacket? =
+ decode(data, CompressionUtil::decompressWithResourcesReserved)
+
+ /** Test seam used to prove rejected expansion sizes never reach inflation. */
+ internal fun decodeForTesting(
+ data: ByteArray,
+ decompress: (ByteArray, Int) -> ByteArray?
+ ): BitchatPacket? = decode(data, decompress)
+
+ private fun decode(
+ data: ByteArray,
+ decompress: (ByteArray, Int) -> ByteArray?
+ ): BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied) - iOS fix
- decodeCore(data)?.let { return it }
+ decodeCore(data, decompress)?.let { return it }
// If that fails, try after removing padding
val unpadded = MessagePadding.unpad(data)
if (unpadded.contentEquals(data)) return null // No padding was removed, already failed
- return decodeCore(unpadded)
+ return decodeCore(unpadded, decompress)
}
/**
* Core decoding implementation used by decode() with and without padding removal - iOS fix
*/
- private fun decodeCore(raw: ByteArray): BitchatPacket? {
+ private fun decodeCore(
+ raw: ByteArray,
+ decompress: (ByteArray, Int) -> ByteArray?
+ ): BitchatPacket? {
try {
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
@@ -435,23 +458,45 @@ object BinaryProtocol {
} else {
buffer.getShort().toUShort().toInt()
}
+
+ val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+ if (originalSize <= 0 || originalSize > maxExpandedSize) {
+ Log.w(
+ "BinaryProtocol",
+ "Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize"
+ )
+ return null
+ }
// Compressed payload
val compressedSize = payloadLength.toInt() - lengthFieldBytes
- val compressedPayload = ByteArray(compressedSize)
- buffer.get(compressedPayload)
-
+ if (compressedSize == 0) {
+ Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
+ return null
+ }
// Security check: Compression bomb protection
- if (compressedSize > 0) {
- val ratio = originalSize.toDouble() / compressedSize.toDouble()
- if (ratio > 50_000.0) {
- Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
- return null
- }
+ val ratio = originalSize.toDouble() / compressedSize.toDouble()
+ if (ratio > 50_000.0) {
+ Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
+ return null
}
- // Decompress
- CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
+ // Reserve the compressed copy plus expanded output before either allocation.
+ // Small packets share the memory pool; packets wait only while its budget is full.
+ val resourceBytes = compressedSize.toLong() + originalSize.toLong()
+ val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
+ val compressedPayload = ByteArray(compressedSize)
+ buffer.get(compressedPayload)
+ decompress(compressedPayload, originalSize)
+ } ?: return null
+ if (expandedPayload.size != originalSize) {
+ Log.w(
+ "BinaryProtocol",
+ "Expanded payload size ${expandedPayload.size} did not match declared size $originalSize"
+ )
+ return null
+ }
+ expandedPayload
} else {
val payloadBytes = ByteArray(payloadLength.toInt())
buffer.get(payloadBytes)
@@ -477,7 +522,7 @@ object BinaryProtocol {
route = route
)
- } catch (e: Throwable) {
+ } catch (e: Exception) {
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
return null
}
diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt
index e8b59254..5f8e9d5a 100644
--- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt
+++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt
@@ -2,6 +2,7 @@ package com.bitchat.android.protocol
import android.util.Log
import java.io.ByteArrayOutputStream
+import java.util.zip.DataFormatException
import java.util.zip.Deflater
import java.util.zip.Inflater
@@ -11,6 +12,8 @@ import java.util.zip.Inflater
*/
object CompressionUtil {
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
+
+ private val decompressionPool = DecompressionResourcePool.forRuntime()
/**
* Helper to check if compression is worth it - exact same logic as iOS
@@ -73,47 +76,124 @@ object CompressionUtil {
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
*/
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
- // iOS COMPRESSION_ZLIB produces raw deflate format (no headers)
- try {
- val inflater = Inflater(true) // true = raw deflate, no headers
- inflater.setInput(compressedData)
-
- val decompressedBuffer = ByteArray(originalSize)
- val actualSize = inflater.inflate(decompressedBuffer)
- inflater.end()
-
- // Verify decompressed size matches expected (same validation as iOS)
- return if (actualSize == originalSize) {
- decompressedBuffer
- } else if (actualSize > 0) {
- // Handle case where actual size is different
- decompressedBuffer.copyOfRange(0, actualSize)
- } else {
+ if (!isValidRequest(compressedData, originalSize)) return null
+ return withDecompressionResources(originalSize.toLong()) {
+ decompressWithResourcesReserved(compressedData, originalSize)
+ }
+ }
+
+ internal fun withDecompressionResources(bytes: Long, block: () -> T): T? =
+ decompressionPool.withReservation(bytes, block)
+
+ /**
+ * Inflate after the caller has reserved all packet-specific allocations.
+ * This avoids nested acquisition when BinaryProtocol reserves both its input copy and output.
+ */
+ internal fun decompressWithResourcesReserved(
+ compressedData: ByteArray,
+ originalSize: Int
+ ): ByteArray? {
+ if (!isValidRequest(compressedData, originalSize)) return null
+ return decompressExact(compressedData, originalSize)
+ }
+
+ private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean {
+ val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+ if (compressedData.isEmpty()) {
+ Log.w("CompressionUtil", "Refusing an empty compressed payload")
+ return false
+ }
+ if (originalSize <= 0 || originalSize > maxExpandedSize) {
+ Log.w(
+ "CompressionUtil",
+ "Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
+ )
+ return false
+ }
+ return true
+ }
+
+ private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? {
+ return if (looksLikeZlib(compressedData)) {
+ // A raw stream can coincidentally begin with a valid-looking zlib header. The
+ // header therefore only determines which format to try first; any non-exact zlib
+ // result must still fall back to raw under the same size/completion bounds.
+ val zlibResult = try {
+ inflateExact(compressedData, originalSize, nowrap = false)
+ } catch (zlibException: DataFormatException) {
null
}
- } catch (e: Exception) {
- Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
-
- // Fallback: try with zlib headers in case of mixed usage
- try {
- val inflater = Inflater(false) // false = expect zlib headers
- inflater.setInput(compressedData)
-
- val decompressedBuffer = ByteArray(originalSize)
- val actualSize = inflater.inflate(decompressedBuffer)
- inflater.end()
-
- return if (actualSize == originalSize) {
- decompressedBuffer
- } else if (actualSize > 0) {
- decompressedBuffer.copyOfRange(0, actualSize)
- } else {
+ if (zlibResult != null) {
+ zlibResult
+ } else {
+ try {
+ inflateExact(compressedData, originalSize, nowrap = true)
+ } catch (rawException: DataFormatException) {
+ Log.d("CompressionUtil", "Invalid zlib/raw deflate stream")
null
}
- } catch (fallbackException: Exception) {
- Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
- return null
}
+ } else {
+ try {
+ inflateExact(compressedData, originalSize, nowrap = true)
+ } catch (rawException: DataFormatException) {
+ Log.d("CompressionUtil", "Invalid raw deflate stream")
+ null
+ }
+ }
+ }
+
+ /** RFC 1950 header check used to avoid speculative double inflation. */
+ private fun looksLikeZlib(data: ByteArray): Boolean {
+ if (data.size < 2) return false
+ val cmf = data[0].toInt() and 0xFF
+ val flg = data[1].toInt() and 0xFF
+ return (cmf and 0x0F) == 8 &&
+ (cmf ushr 4) <= 7 &&
+ ((cmf shl 8) or flg) % 31 == 0
+ }
+
+ /**
+ * Inflate one complete stream into exactly [originalSize] bytes.
+ *
+ * A full output buffer alone is not success: an attacker can under-declare a larger stream so
+ * the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely,
+ * a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected,
+ * as are trailing bytes after the compressed stream.
+ *
+ * [DataFormatException] is deliberately allowed to escape so the caller can try the legacy
+ * zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the
+ * same bytes are a complete, exact-sized zlib stream before they can be accepted.
+ */
+ @Throws(DataFormatException::class)
+ private fun inflateExact(
+ compressedData: ByteArray,
+ originalSize: Int,
+ nowrap: Boolean
+ ): ByteArray? {
+ val inflater = Inflater(nowrap)
+ return try {
+ inflater.setInput(compressedData)
+ val output = ByteArray(originalSize)
+ var written = 0
+
+ while (written < originalSize) {
+ val count = inflater.inflate(output, written, originalSize - written)
+ if (count == 0) break
+ written += count
+ }
+
+ if (written != originalSize) return null
+
+ // Give Inflater one byte of room to consume the end marker. Any produced byte proves
+ // the declared size was smaller than the actual expansion.
+ val overflowProbe = ByteArray(1)
+ if (inflater.inflate(overflowProbe) != 0) return null
+
+ if (!inflater.finished() || inflater.remaining != 0) return null
+ output
+ } finally {
+ inflater.end()
}
}
diff --git a/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt
new file mode 100644
index 00000000..a72c8070
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt
@@ -0,0 +1,73 @@
+package com.bitchat.android.protocol
+
+import java.util.concurrent.Semaphore
+import java.util.concurrent.TimeUnit
+import kotlin.math.ceil
+
+/**
+ * Fair, weighted admission control for decompression allocations.
+ *
+ * Permits represent memory rather than workers: small packets can proceed concurrently while
+ * near-limit packets consume most of the budget. Callers must reserve before allocating any
+ * packet-specific compressed copy or expanded output.
+ */
+internal class DecompressionResourcePool(
+ budgetBytes: Long,
+ private val unitBytes: Int,
+ private val waitTimeoutMs: Long
+) {
+ private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1)
+ private val permits = Semaphore(totalPermits, true)
+
+ fun withReservation(bytes: Long, block: () -> T): T? {
+ val requiredPermits = permitsFor(bytes)
+ val acquired = try {
+ permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ false
+ }
+ if (!acquired) return null
+
+ return try {
+ block()
+ } finally {
+ permits.release(requiredPermits)
+ }
+ }
+
+ internal fun permitsFor(bytes: Long): Int =
+ ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble())
+ .toInt()
+ .coerceAtMost(totalPermits)
+
+ internal val availablePermits: Int
+ get() = permits.availablePermits()
+
+ companion object {
+ private const val DEFAULT_UNIT_BYTES = 256 * 1024
+ private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L
+ private const val HEAP_BUDGET_DIVISOR = 8L
+ private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024
+
+ fun forRuntime(
+ maxHeapBytes: Long = Runtime.getRuntime().maxMemory(),
+ maxPacketResourceBytes: Long =
+ 2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+ ): DecompressionResourcePool {
+ val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes)
+ return DecompressionResourcePool(
+ budgetBytes = budget,
+ unitBytes = DEFAULT_UNIT_BYTES,
+ waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS
+ )
+ }
+
+ internal fun recommendedBudgetBytes(
+ maxHeapBytes: Long,
+ maxPacketResourceBytes: Long
+ ): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR)
+ .coerceAtLeast(maxPacketResourceBytes)
+ .coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes))
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
index 5443808a..2f70bf84 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -156,6 +156,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
+ try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
index f8b10766..67a7a1d8 100644
--- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
+++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
@@ -4,6 +4,7 @@ import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.toHexString
+import kotlinx.coroutines.CancellationException
import java.security.MessageDigest
import java.util.Collections
import java.util.LinkedHashMap
@@ -31,6 +32,14 @@ object TransportBridgeService {
*/
fun send(packet: RoutedPacket)
+ /**
+ * Send a packet and report whether at least one concrete transport write was accepted.
+ *
+ * Receipt retries use this path so a registered-but-disconnected transport cannot be
+ * mistaken for a successful send.
+ */
+ suspend fun sendAndReport(packet: RoutedPacket): Boolean = false
+
/**
* Send a packet to a specific peer via this transport (optional).
*/
@@ -45,6 +54,11 @@ object TransportBridgeService {
}
}
)
+ private data class PreparedForward(
+ val packet: BitchatPacket,
+ val seenKey: String,
+ val reservedAtMs: Long
+ )
/**
* Register a transport layer to receive bridged packets.
@@ -74,7 +88,8 @@ object TransportBridgeService {
fun broadcast(sourceId: String, packet: RoutedPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
- val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
+ val prepared = prepareForwardedPacket("broadcast", packet.packet) ?: return
+ val forwardedPacket = prepared.packet
// Prepared private-media fragments must remain the admitted plan when
// crossing transports, but relay TTL still has to advance on every
// hop. TTL is excluded from the signature and does not affect size.
@@ -96,13 +111,51 @@ object TransportBridgeService {
}
}
+ /**
+ * Broadcasts through every other active transport and reports whether any concrete write was
+ * accepted. Failed attempts release their duplicate-suppression reservation so a later retry
+ * can use a transport that reconnects during the retry window.
+ */
+ suspend fun broadcastAndReport(sourceId: String, packet: RoutedPacket): Boolean {
+ val targets = transports.filterKeys { it != sourceId }
+ if (targets.isEmpty()) return false
+ val kind = "broadcast"
+ val prepared = prepareForwardedPacket(kind, packet.packet) ?: return false
+ val forwardedPacket = prepared.packet
+ val forwarded = packet.copy(
+ packet = forwardedPacket,
+ preparedPackets = packet.preparedPackets?.map { prepared ->
+ prepared.copy(ttl = forwardedPacket.ttl)
+ }
+ )
+
+ var accepted = false
+ targets.forEach { (id, layer) ->
+ val targetAccepted = try {
+ layer.sendAndReport(forwarded)
+ } catch (e: CancellationException) {
+ releaseSeenPacket(prepared)
+ throw e
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
+ false
+ }
+ accepted = targetAccepted || accepted
+ }
+ if (!accepted) {
+ releaseSeenPacket(prepared)
+ }
+ return accepted
+ }
+
/**
* Send a packet to a specific peer across all other transports.
*/
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
- val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
+ val forwardedPacket =
+ prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return
targets.forEach { (id, layer) ->
try {
@@ -147,7 +200,7 @@ object TransportBridgeService {
}
}
- private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
+ private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
return null
@@ -165,7 +218,19 @@ object TransportBridgeService {
seenPackets[key] = now
}
- return packet.copy(ttl = (packet.ttl - 1u).toUByte())
+ return PreparedForward(
+ packet = packet.copy(ttl = (packet.ttl - 1u).toUByte()),
+ seenKey = key,
+ reservedAtMs = now
+ )
+ }
+
+ private fun releaseSeenPacket(prepared: PreparedForward) {
+ synchronized(seenPackets) {
+ if (seenPackets[prepared.seenKey] == prepared.reservedAtMs) {
+ seenPackets.remove(prepared.seenKey)
+ }
+ }
}
private fun pruneSeen(now: Long) {
diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt
index 5301df6d..b3ffc429 100644
--- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt
+++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt
@@ -26,6 +26,10 @@ object ContactDirectory {
@Volatile
private var meshProvider: (() -> MeshService?)? = null
+ @Volatile
+ internal var identityManagerProvider: (Context) -> SecureIdentityStateManager =
+ { SecureIdentityStateManager(it) }
+
fun initialize(context: Context, meshProvider: () -> MeshService?) {
appContext = context.applicationContext
this.meshProvider = meshProvider
@@ -79,7 +83,8 @@ object ContactDirectory {
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
nostrPubkey = favorite?.peerNostrPublicKey,
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
- ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname },
+ ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
+ ?: contactFingerprint?.let { cachedFingerprintNickname(it) },
isMutualFavorite = favorite?.isMutual == true
)
}
@@ -132,7 +137,7 @@ object ContactDirectory {
private fun cachedNoiseKey(peerID: String): ByteArray? {
val context = appContext ?: return null
return try {
- SecureIdentityStateManager(context)
+ identityManagerProvider(context)
.getCachedNoiseKey(peerID)
?.let { ContactIdentityResolver.bytesFromHex(it) }
} catch (_: Exception) {
@@ -140,6 +145,17 @@ object ContactDirectory {
}
}
+ private fun cachedFingerprintNickname(fingerprint: String): String? {
+ val context = appContext ?: return null
+ return try {
+ identityManagerProvider(context)
+ .getCachedFingerprintNickname(fingerprint)
+ ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
index 652e0c93..a09fa31e 100644
--- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
+++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
@@ -6,6 +6,15 @@ import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
+import com.bitchat.android.util.AppConstants
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.concurrent.ConcurrentHashMap
/**
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
@@ -22,9 +31,27 @@ class MessageRouter private constructor(
DROPPED
}
+ private data class QueuedMessage(
+ val content: String,
+ val nickname: String,
+ val messageID: String,
+ val enqueuedAtMs: Long
+ )
+
+ private data class ConversationRetry(
+ val handshakeAttempts: Int,
+ val nextHandshakeAttemptAtMs: Long
+ )
+
companion object {
private const val TAG = "MessageRouter"
+ private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
+ private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS
+ private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER
+ private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
+
@Volatile private var INSTANCE: MessageRouter? = null
+ internal var disableSchedulerForTesting = false
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
@@ -39,15 +66,38 @@ class MessageRouter private constructor(
}
}
}
- // Always update mesh reference and sync peer ID
+ // Always update mesh reference and sync peer ID, and make sure the retry
+ // scheduler is running (it is stopped together with MeshForegroundService).
instance.mesh = mesh
instance.nostr.senderPeerID = mesh.myPeerID
+ instance.startOutboxScheduler()
return instance
}
+
+ internal fun resetForTesting() {
+ INSTANCE?.schedulerScope?.cancel()
+ INSTANCE = null
+ }
}
- // Outbox: peerID -> queued (content, nickname, messageID)
- private val outbox = mutableMapOf>>()
+ // Outbox: conversationID -> queued messages, oldest first
+ private val outbox = ConcurrentHashMap>()
+
+ // Per-conversation handshake retry state for queued messages
+ private val retryState = ConcurrentHashMap()
+
+ private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+ private var schedulerJob: kotlinx.coroutines.Job? = null
+
+ // Injectable clock for tests
+ internal var clock: () -> Long = { System.currentTimeMillis() }
+
+ // Called with the messageID of queued messages that expired or were evicted
+ var onMessageExpired: ((String) -> Unit)? = null
+
+ init {
+ startOutboxScheduler()
+ }
// Listener for favorites changes to flush outbox when npub mapping appears/changes
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
@@ -88,10 +138,9 @@ class MessageRouter private constructor(
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
- val q = outbox.getOrPut(conversationID) { mutableListOf() }
- q.add(Triple(content, recipientNickname, messageID))
+ enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock()))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
- if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
+ if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
return RouteResult.QUEUED
}
}
@@ -139,7 +188,10 @@ class MessageRouter private constructor(
}
}
- // Flush any queued messages for a specific peerID
+ // Flush any queued messages for a specific peerID.
+ // All outbox mutations happen under the router monitor so a concurrent enqueue cannot
+ // be lost between the empty check and the map removal.
+ @Synchronized
fun flushOutboxFor(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
@@ -147,21 +199,23 @@ class MessageRouter private constructor(
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
- val (content, nickname, messageID) = iterator.next()
+ val entry = iterator.next()
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
- mesh.sendPrivateMessage(content, meshTarget, nickname, messageID)
+ mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
iterator.remove()
} else if (canSendViaNostr(nostrTarget)) {
- nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
+ nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
iterator.remove()
}
}
if (queued.isEmpty()) {
- outbox.remove(conversationID)
- outbox.remove(peerID)
+ outbox.remove(conversationID, queued)
+ outbox.remove(peerID, queued)
+ retryState.remove(conversationID)
+ retryState.remove(peerID)
}
}
@@ -170,6 +224,116 @@ class MessageRouter private constructor(
outbox.keys.toList().forEach { flushOutboxFor(it) }
}
+ @Synchronized
+ private fun enqueue(conversationID: String, entry: QueuedMessage) {
+ val queue = outbox.getOrPut(conversationID) { mutableListOf() }
+ queue.add(entry)
+ while (queue.size > OUTBOX_MAX_PER_PEER) {
+ val evicted = queue.removeAt(0)
+ Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…")
+ notifyExpired(evicted.messageID)
+ }
+ }
+
+ private fun notifyExpired(messageID: String) {
+ try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { }
+ }
+
+ /**
+ * Initiate a Noise handshake for a conversation with queued messages, applying
+ * exponential backoff between attempts. [immediate] resets the backoff (peer just
+ * appeared or a new message was queued). Kicks are suppressed while a previous
+ * attempt is still inside its backoff window, so alias duplicates and frequent
+ * peer-list updates cannot spam handshakes.
+ */
+ @Synchronized
+ private fun kickHandshake(conversationID: String, meshTarget: String, immediate: Boolean) {
+ val now = clock()
+ val current = retryState[conversationID]
+ if (current != null && now < current.nextHandshakeAttemptAtMs) return
+ val attempts = if (immediate) 0 else (current?.handshakeAttempts ?: 0)
+ try { mesh.initiateNoiseHandshake(meshTarget) } catch (_: Exception) { }
+ val backoff = HANDSHAKE_RETRY_BACKOFF_MS[attempts.coerceAtMost(HANDSHAKE_RETRY_BACKOFF_MS.size - 1)]
+ retryState[conversationID] = ConversationRetry(
+ handshakeAttempts = attempts + 1,
+ nextHandshakeAttemptAtMs = now + backoff
+ )
+ Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms")
+ }
+
+ @Synchronized
+ private fun startOutboxScheduler() {
+ if (disableSchedulerForTesting) return
+ if (schedulerJob?.isActive == true) return
+ schedulerJob = schedulerScope.launch {
+ while (isActive) {
+ delay(OUTBOX_TICK_MS)
+ try { tickOutbox() } catch (e: Exception) {
+ Log.w(TAG, "Outbox scheduler tick failed: ${e.message}")
+ }
+ }
+ }
+ }
+
+ /**
+ * Stop retrying while the mesh transports are down. Persistent network work must
+ * follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
+ * and rebinds the mesh reference when the service comes back.
+ */
+ fun stopOutboxScheduler() {
+ schedulerJob?.cancel()
+ schedulerJob = null
+ }
+
+ internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true
+
+ /**
+ * One scheduler pass over the outbox: expire old entries, flush what can be sent,
+ * and re-initiate handshakes (with backoff) for peers that are connected but have
+ * no established session yet.
+ */
+ @Synchronized
+ internal fun tickOutbox(nowMs: Long = clock()) {
+ outbox.keys.toList().forEach { conversationID ->
+ expireOldEntries(conversationID, nowMs)
+ val queued = outbox[conversationID] ?: return@forEach
+ if (queued.isEmpty()) return@forEach
+
+ val resolution = ContactDirectory.resolve(conversationID)
+ val meshTarget = resolution.meshPeerID
+
+ if (meshTarget != null && isReady(mesh, meshTarget)) {
+ flushOutboxFor(conversationID)
+ return@forEach
+ }
+ if (canSendViaNostr(resolution.noiseKeyHex ?: conversationID)) {
+ flushOutboxFor(conversationID)
+ return@forEach
+ }
+ // Peer visible but no session: retry the handshake with backoff.
+ if (meshTarget != null && isConnected(mesh, meshTarget)) {
+ kickHandshake(conversationID, meshTarget, immediate = false)
+ }
+ }
+ }
+
+ private fun expireOldEntries(conversationID: String, nowMs: Long) {
+ val queued = outbox[conversationID] ?: return
+ val iterator = queued.iterator()
+ while (iterator.hasNext()) {
+ val entry = iterator.next()
+ if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) {
+ Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…")
+ iterator.remove()
+ notifyExpired(entry.messageID)
+ }
+ }
+ if (queued.isEmpty()) {
+ outbox.remove(conversationID, queued)
+ retryState.remove(conversationID)
+ }
+ }
+
private fun canSendViaNostr(peerID: String): Boolean {
return try {
val resolution = ContactDirectory.resolve(peerID)
@@ -208,20 +372,51 @@ class MessageRouter private constructor(
// Called when mesh peer list changes; attempt to flush any matching outbox entries
fun onPeersUpdated(peers: List) {
peers.forEach { pid ->
+ kickHandshakeIfPending(pid)
flushOutboxFor(pid)
val noiseHex = try {
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
- noiseHex?.let { flushOutboxFor(it) }
+ noiseHex?.let {
+ kickHandshakeIfPending(it)
+ flushOutboxFor(it)
+ }
}
}
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
fun onSessionEstablished(peerID: String) {
+ resetRetry(peerID)
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
- noiseHex?.let { flushOutboxFor(it) }
+ noiseHex?.let {
+ resetRetry(it)
+ flushOutboxFor(it)
+ }
+ }
+
+ /** Reset handshake backoff for a conversation whose session just came up. */
+ private fun resetRetry(peerID: String) {
+ retryState.remove(ContactDirectory.canonicalConversationId(peerID))
+ retryState.remove(peerID)
+ }
+
+ /**
+ * A peer (re)appeared: if we still owe them queued messages and there is no working
+ * session yet, restart the handshake immediately instead of waiting for the backoff.
+ */
+ @Synchronized
+ private fun kickHandshakeIfPending(peerID: String) {
+ val conversationID = ContactDirectory.canonicalConversationId(peerID)
+ val queued = outbox[conversationID] ?: outbox[peerID] ?: return
+ if (queued.isEmpty()) return
+ val resolution = ContactDirectory.resolve(conversationID)
+ val meshTarget = resolution.meshPeerID ?: return
+ if (isReady(mesh, meshTarget)) return
+ if (!isConnected(mesh, meshTarget)) return
+ Log.d(TAG, "Peer ${meshTarget.take(8)}… reappeared with ${queued.size} queued PM(s); re-initiating handshake")
+ kickHandshake(conversationID, meshTarget, immediate = true)
}
}
diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
index 15e1c7ca..3b531846 100644
--- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
@@ -4,9 +4,14 @@ import android.content.Context
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
/**
- * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ.
+ * Persistent store for message IDs we've already acknowledged as delivered, read locally, or
+ * admitted to a completed read-receipt send window.
+ *
+ * Local read state must not be used as proof that a read-receipt packet reached the sender.
+ * Transport delivery is best-effort and retryable, while local read state drives unread UI.
* Limits to last MAX_IDS entries per set to avoid memory bloat.
*/
class SeenMessageStore private constructor(private val context: Context) {
@@ -27,12 +32,14 @@ class SeenMessageStore private constructor(private val context: Context) {
private val secure = SecureIdentityStateManager(context)
private val delivered = LinkedHashSet(MAX_IDS)
- private val read = LinkedHashSet(MAX_IDS)
+ private val locallyRead = LinkedHashSet(MAX_IDS)
+ private val readReceiptsSent = LinkedHashSet(MAX_IDS)
init { load() }
@Synchronized fun hasDelivered(id: String) = delivered.contains(id)
- @Synchronized fun hasRead(id: String) = read.contains(id)
+ @Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id)
+ @Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id)
@Synchronized fun markDelivered(id: String) {
if (delivered.remove(id)) delivered.add(id) else {
@@ -42,17 +49,26 @@ class SeenMessageStore private constructor(private val context: Context) {
persist()
}
- @Synchronized fun markRead(id: String) {
- if (read.remove(id)) read.add(id) else {
- read.add(id)
- trim(read)
+ @Synchronized fun markReadLocally(id: String) {
+ if (locallyRead.remove(id)) locallyRead.add(id) else {
+ locallyRead.add(id)
+ trim(locallyRead)
+ }
+ persist()
+ }
+
+ @Synchronized fun markReadReceiptSent(id: String) {
+ if (readReceiptsSent.remove(id)) readReceiptsSent.add(id) else {
+ readReceiptsSent.add(id)
+ trim(readReceiptsSent)
}
persist()
}
@Synchronized fun clear() {
delivered.clear()
- read.clear()
+ locallyRead.clear()
+ readReceiptsSent.clear()
persist()
}
@@ -68,10 +84,19 @@ class SeenMessageStore private constructor(private val context: Context) {
try {
val json = secure.getSecureValue(STORAGE_KEY) ?: return
val data = gson.fromJson(json, StorePayload::class.java) ?: return
- delivered.clear(); read.clear()
+ delivered.clear(); locallyRead.clear(); readReceiptsSent.clear()
data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) }
- data.read.takeLast(MAX_IDS).forEach { read.add(it) }
- Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}")
+ data.locallyRead.takeLast(MAX_IDS).forEach { locallyRead.add(it) }
+ // Older payloads used the local-read set to suppress receipt sends. Seed the new
+ // explicit set once during migration to avoid replaying an entire chat history.
+ (data.readReceiptsSent ?: data.locallyRead)
+ .takeLast(MAX_IDS)
+ .forEach { readReceiptsSent.add(it) }
+ Log.d(
+ TAG,
+ "Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " +
+ "readReceiptsSent=${readReceiptsSent.size}"
+ )
} catch (e: Exception) {
Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}")
}
@@ -79,7 +104,11 @@ class SeenMessageStore private constructor(private val context: Context) {
@Synchronized private fun persist() {
try {
- val payload = StorePayload(delivered.toList(), read.toList())
+ val payload = StorePayload(
+ delivered = delivered.toList(),
+ locallyRead = locallyRead.toList(),
+ readReceiptsSent = readReceiptsSent.toList()
+ )
val json = gson.toJson(payload)
secure.storeSecureValue(STORAGE_KEY, json)
} catch (e: Exception) {
@@ -89,6 +118,10 @@ class SeenMessageStore private constructor(private val context: Context) {
private data class StorePayload(
val delivered: List = emptyList(),
- val read: List = emptyList()
+ // Keep the existing JSON field name for backward-compatible secure-store migration.
+ @SerializedName("read")
+ val locallyRead: List = emptyList(),
+ @SerializedName("read_receipts_sent")
+ val readReceiptsSent: List? = null
)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
index 8d4fdfa1..f8820e91 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
+import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
@@ -294,10 +295,10 @@ internal fun TorAwareHeaderIcon(
/**
* Noise session status for private-chat headers.
*
- * Same visual language as the main header's Tor-aware globe: one lock glyph throughout, tint
- * cross-fades between states, and a soft radial glow pulse while the handshake is in flight.
- * The old sync/recycle glyph is gone — progress is carried by colour and motion, not by swapping
- * icons.
+ * Same visual language as the main header's Tor-aware globe: tint cross-fades between states,
+ * and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open
+ * lock until a session is established (or fails), then the closed lock — both share the same
+ * baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap.
*/
@Composable
fun NoiseSessionIcon(
@@ -324,28 +325,42 @@ fun NoiseSessionIcon(
stringResource(R.string.cd_handshake_failed)
)
else -> Triple(
- // Not yet started — quiet grey lock, same glyph as every other state.
+ // Not yet started — quiet grey open lock.
colorScheme.onSurfaceVariant,
false,
stringResource(R.string.cd_ready_for_handshake)
)
}
- // Longer than the usual chrome tint so grey → orange → green reads as a continuous wash,
- // not a snap between discrete states.
+ // Closed once the handshake resolves (success or failure); open while idle or in flight.
+ val lockIconRes = when {
+ sessionState == "established" || sessionState?.startsWith("failed") == true ->
+ R.drawable.ic_spec_lock
+ else -> R.drawable.ic_spec_lock_open
+ }
+
+ // Match the tint wash so open → closed and grey → orange → green land together.
+ val lockTransitionMs = 480
+
val animatedTint by animateColorAsState(
targetValue = targetTint,
- animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing),
+ animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
label = "noiseSessionTint"
)
- TorAwareHeaderIcon(
- painter = painterResource(R.drawable.ic_spec_lock),
- tint = animatedTint,
- isProgress = isProgress,
- contentDescription = contentDescription,
- modifier = modifier
- )
+ Crossfade(
+ targetState = lockIconRes,
+ animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
+ modifier = modifier,
+ label = "noiseLockGlyph"
+ ) { iconRes ->
+ TorAwareHeaderIcon(
+ painter = painterResource(iconRes),
+ tint = animatedTint,
+ isProgress = isProgress,
+ contentDescription = contentDescription,
+ )
+ }
}
/**
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt
index 40593970..f4504cc2 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt
@@ -94,6 +94,10 @@ class ChatState(
// Favorites
private val _favoritePeers = MutableStateFlow>(emptySet())
val favoritePeers: StateFlow> = _favoritePeers.asStateFlow()
+
+ // Fingerprints of peers who favorited us (drives "favorited you" UI celebrations)
+ private val _peerFavoritedUs = MutableStateFlow>(emptySet())
+ val peerFavoritedUs: StateFlow> = _peerFavoritedUs.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableStateFlow