Merge remote-tracking branch 'origin/main' into codex/background-power-optimization

# Conflicts:
#	app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
#	app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt
#	app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
This commit is contained in:
callebtc 2026-07-27 23:29:56 +02:00
commit c54fdcd9fb
73 changed files with 3820 additions and 1401 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.*

328
README.md
View File

@ -1,308 +1,86 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/188c42f8-d249-4a72-b27a-e2b4f10a00a8" alt="Bitchat Android Logo" width="480">
</p>
<img width="256" height="256" alt="icon_128x128@2x" src="https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47" />
> [!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)
[<img alt="Get it on Google Play" height="60" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/>](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.

View File

@ -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
}

View File

@ -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)
}

View File

@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
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

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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<String, AuthenticatedBleLinkPolicy.Claim>()
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}")

View File

@ -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<Boolean>? = 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<Boolean>()
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
}
/**

View File

@ -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
)
}
}

View File

@ -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<String>) {
@ -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()

View File

@ -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

View File

@ -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<String, Int>()
/**
* 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,

View File

@ -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)

View File

@ -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<String, Job>()
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()
}
}

View File

@ -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<String>())
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf<String>())
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
// 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()
}
/**

View File

@ -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<String, NoiseSession>()
// 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<String, NoiseSession>()
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(

View File

@ -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 -> {

View File

@ -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
}

View File

@ -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 <T> 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()
}
}

View File

@ -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 <T> 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))
}
}

View File

@ -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) { }

View File

@ -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) {

View File

@ -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)

View File

@ -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<String, MutableList<Triple<String, String, String>>>()
// Outbox: conversationID -> queued messages, oldest first
private val outbox = ConcurrentHashMap<String, MutableList<QueuedMessage>>()
// Per-conversation handshake retry state for queued messages
private val retryState = ConcurrentHashMap<String, ConversationRetry>()
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<String>) {
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)
}
}

View File

@ -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<String>(MAX_IDS)
private val read = LinkedHashSet<String>(MAX_IDS)
private val locallyRead = LinkedHashSet<String>(MAX_IDS)
private val readReceiptsSent = LinkedHashSet<String>(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<String> = emptyList(),
val read: List<String> = emptyList()
// Keep the existing JSON field name for backward-compatible secure-store migration.
@SerializedName("read")
val locallyRead: List<String> = emptyList(),
@SerializedName("read_receipts_sent")
val readReceiptsSent: List<String>? = null
)
}

View File

@ -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,
)
}
}
/**

View File

@ -94,6 +94,10 @@ class ChatState(
// Favorites
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Fingerprints of peers who favorited us (drives "favorited you" UI celebrations)
private val _peerFavoritedUs = MutableStateFlow<Set<String>>(emptySet())
val peerFavoritedUs: StateFlow<Set<String>> = _peerFavoritedUs.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
@ -174,6 +178,7 @@ class ChatState(
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
fun getJoinedChannelsValue() = _joinedChannels.value
fun getPeerFavoritedUsValue() = _peerFavoritedUs.value
fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
@ -287,6 +292,10 @@ class ChatState(
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
}
fun setPeerFavoritedUs(fingerprints: Set<String>) {
_peerFavoritedUs.value = fingerprints
}
fun setPeerSessionStates(states: Map<String, String>) {
_peerSessionStates.value = states

View File

@ -6,9 +6,14 @@ import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
@ -16,10 +21,12 @@ import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
import java.util.Date
@ -99,6 +106,9 @@ class ChatViewModel(
// Specialized managers
private val dataManager = DataManager(application.applicationContext)
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
private val seenMessageStore by lazy {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
}
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@ -109,7 +119,18 @@ class ChatViewModel(
override fun getMyPeerID(): String = mesh.myPeerID
}
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
val privateChatManager = PrivateChatManager(
state,
messageManager,
dataManager,
noiseSessionDelegate,
hasReadReceiptBeenSent = { messageID ->
seenMessageStore.hasReadReceiptBeenSent(messageID)
},
markMessageReadLocally = { messageID ->
seenMessageStore.markReadLocally(messageID)
}
)
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val notificationManager = NotificationManager(
application.applicationContext,
@ -146,7 +167,10 @@ class ChatViewModel(
coroutineScope = viewModelScope,
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
getMyPeerID = { mesh.myPeerID },
getMeshService = { mesh }
getMeshService = { mesh },
markMessageReadLocally = { messageID ->
seenMessageStore.markReadLocally(messageID)
}
)
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
@ -169,6 +193,57 @@ class ChatViewModel(
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
state.unreadPrivateMessages,
state.privateChats,
state.nickname,
state.connectedPeers
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
val seenStore = seenMessageStore
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
buildUnreadConversationSummaries(
unreadConversationIDs = unreadConversationIDs,
privateChats = chats,
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
canonicalize = ContactDirectory::canonicalConversationId,
isMessageRead = { message -> seenStore.hasBeenReadLocally(message.id) }
).map { summary ->
val resolution = ContactDirectory.resolve(summary.conversationID)
val resolvedNostrPubkey = summary.nostrPubkey
?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex)
val aliases = buildSet {
addAll(summary.identityAliases)
add(summary.conversationID)
add(resolution.conversationID)
resolution.meshPeerID?.let(::add)
resolution.noiseKeyHex?.let(::add)
resolvedNostrPubkey
?.let(ContactIdentityResolver::nostrAliasForPubkey)
?.let(::add)
}.mapTo(mutableSetOf()) { it.lowercase() }
summary.copy(
displayName = resolution.displayName
?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
?: summary.displayName,
nostrPubkey = resolvedNostrPubkey,
identityAliases = aliases,
isConnected = aliases.any(connectedPeerIDSet::contains),
sourceGeohash = aliases
.asSequence()
.mapNotNull(GeohashConversationRegistry::get)
.firstOrNull()
)
}
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
@ -183,6 +258,7 @@ class ChatViewModel(
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerFavoritedUs: StateFlow<Set<String>> = state.peerFavoritedUs
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
@ -218,6 +294,15 @@ class ChatViewModel(
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
// Mark queued private messages as failed when the router gives up on them
try {
com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID ->
messageManager.updateMessageDeliveryStatus(
messageID,
com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery")
)
}
} catch (_: Exception) { }
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
@ -233,18 +318,27 @@ class ChatViewModel(
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer)
val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) {
val canonical = ContactDirectory.canonicalizePrivateChats(byPeer)
val unread = try {
val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID }
canonical
.filterValues { messages ->
messages.any { message ->
message.sender != myNick &&
message.sender != "system" &&
!seenMessageStore.hasBeenReadLocally(message.id)
}
}
.keys
} catch (_: Exception) {
state.getUnreadPrivateMessagesValue()
}
canonical to unread
}
state.setPrivateChats(canonicalChats)
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
canonicalChats.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
}
state.setUnreadPrivateMessages(unread)
} catch (_: Exception) { }
state.setUnreadPrivateMessages(unreadConversationIDs)
} } catch (_: Exception) { }
}
viewModelScope.launch {
@ -322,6 +416,17 @@ class ChatViewModel(
// Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Reflect "they favorited us" changes into reactive UI state (drives star celebrations)
refreshPeerFavoritedUs()
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(
object : com.bitchat.android.favorites.FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs()
override fun onAllCleared() = refreshPeerFavoritedUs()
}
)
} catch (_: Exception) { }
// Load verified fingerprints from secure storage
verificationHandler.loadVerifiedFingerprints()
@ -376,30 +481,30 @@ class ChatViewModel(
// MARK: - Private Chat Management (delegated)
fun startPrivateChat(peerID: String) {
suspend fun startPrivateChat(peerID: String) {
// For geohash conversation keys, ensure DM subscription is active
if (peerID.startsWith("nostr_")) {
ensureGeohashDMSubscriptionIfNeeded(peerID)
}
val success = privateChatManager.startPrivateChat(peerID, mesh)
val (conversationID, success) = withContext(Dispatchers.IO) {
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
val unreadAliases = matchingUnreadAliases(
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
canonicalConversationID = canonicalID,
canonicalize = ContactDirectory::canonicalConversationId
)
canonicalID to privateChatManager.startPrivateChat(
peerID = canonicalID,
meshService = mesh,
unreadAliases = unreadAliases
)
}
if (success) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
// Notify notification manager about current private chat
setCurrentPrivateChatPeer(conversationID)
// Clear notifications for this sender since user is now viewing the chat
clearNotificationsForSender(conversationID)
// Persistently mark all messages in this conversation as read so Nostr fetches
// after app restarts won't re-mark them as unread.
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val chats = state.getPrivateChatsValue()
val messages = chats[conversationID] ?: emptyList()
messages.forEach { msg ->
try { seen.markRead(msg.id) } catch (_: Exception) { }
}
} catch (_: Exception) { }
}
}
@ -630,8 +735,22 @@ class ChatViewModel(
logCurrentFavoriteState()
}
private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
private fun refreshPeerFavoritedUs() {
try {
val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared
.getAllRelationships()
.filter { it.theyFavoritedUs }
.mapNotNull { relationship ->
runCatching {
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
}.getOrNull()
}
.toSet()
state.setPeerFavoritedUs(fingerprints)
} catch (_: Exception) { }
}
private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
@ -664,8 +783,11 @@ class ChatViewModel(
}
private fun nicknameForPeer(peerID: String): String? {
return state.peerNicknames.value[peerID]
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
val contact = ContactDirectory.resolve(peerID)
val meshPeerID = contact.meshPeerID ?: peerID
return contact.displayName
?: state.peerNicknames.value[meshPeerID]
?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null }
}
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {

View File

@ -36,7 +36,8 @@ data class GeoPerson(
fun GeohashPeopleList(
viewModel: ChatViewModel,
onTapPerson: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
excludedIdentityAliases: Set<String> = emptySet()
) {
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
@ -77,9 +78,15 @@ fun GeohashPeopleList(
geohashPeople
}
}
val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) {
val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) {
peopleIncludingSelf.filterNot { person ->
val alias = "nostr_${person.id.take(16)}".lowercase()
alias in excludedIdentityAliases
}
}
val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) {
sectionGeohashPeople(
people = peopleIncludingSelf,
people = visiblePeople,
myId = myHex,
selfIsTeleported = isTeleported,
teleportedIds = teleportedGeo

View File

@ -399,6 +399,18 @@ fun LocationChannelsSheet(
AboutSectionLabel(
text = stringResource(R.string.location_channels_nearby)
)
if (!appLocationEnabled) {
SheetDestructiveButton(
text = stringResource(R.string.enable_location_services),
isDestructive = false,
onClick = { locationManager.enableLocationServices() },
modifier = Modifier.padding(
start = AboutHorizontalPadding,
end = AboutHorizontalPadding,
bottom = 10.dp
)
)
}
Surface(
modifier = Modifier
.fillMaxWidth()
@ -561,27 +573,19 @@ fun LocationChannelsSheet(
}
}
item(key = "location_toggle") {
SheetDestructiveButton(
text = if (appLocationEnabled) {
stringResource(R.string.disable_location_services)
} else {
stringResource(R.string.enable_location_services)
},
isDestructive = appLocationEnabled,
onClick = {
if (appLocationEnabled) {
locationManager.disableLocationServices()
} else {
locationManager.enableLocationServices()
}
},
modifier = Modifier.padding(
start = AboutHorizontalPadding,
end = AboutHorizontalPadding,
top = 24.dp
if (appLocationEnabled) {
item(key = "location_toggle") {
SheetDestructiveButton(
text = stringResource(R.string.disable_location_services),
isDestructive = true,
onClick = { locationManager.disableLocationServices() },
modifier = Modifier.padding(
start = AboutHorizontalPadding,
end = AboutHorizontalPadding,
top = 24.dp
)
)
)
}
}
}

View File

@ -43,7 +43,7 @@ class MediaSendingManager(
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
}
@ -81,6 +81,46 @@ class MediaSendingManager(
private var automaticRetryRequestedFor: String? = null
private var pendingAutomaticTimeoutRequestId: String? = null
/**
* Enforce the send-size cap with a user-visible failure posted to the
* conversation the user is sending from. Returns true if the file is
* oversized and the send was aborted.
*/
private fun rejectIfOversized(
file: java.io.File,
toPeerIDOrNull: String?,
channelOrNull: String?
): Boolean {
val size = file.length()
if (size <= MAX_FILE_SIZE) return false
Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)")
val sizeMb = size / (1024 * 1024)
val maxMb = MAX_FILE_SIZE / (1024 * 1024)
val text = "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)"
when {
toPeerIDOrNull != null -> {
val sys = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false
)
messageManager.addPrivateMessageNoUnread(toPeerIDOrNull, sys)
}
channelOrNull != null -> {
val sys = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false
)
messageManager.addChannelMessage(channelOrNull, sys)
}
else -> messageManager.addSystemMessage(text)
}
return true
}
/**
* Send a voice note (audio file)
*/
@ -103,8 +143,7 @@ class MediaSendingManager(
return@withContext null
}
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
return@withContext null
}
@ -148,8 +187,7 @@ class MediaSendingManager(
return@withContext null
}
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
return@withContext null
}
@ -193,8 +231,7 @@ class MediaSendingManager(
return@withContext null
}
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
return@withContext null
}

View File

@ -22,7 +22,8 @@ class MeshDelegateHandler(
private val coroutineScope: CoroutineScope,
private val onHapticFeedback: () -> Unit,
private val getMyPeerID: () -> String,
private val getMeshService: () -> MeshService
private val getMeshService: () -> MeshService,
private val markMessageReadLocally: (messageID: String) -> Unit = {}
) : BluetoothMeshDelegate {
override fun didReceiveMessage(message: BitchatMessage) {
@ -44,24 +45,29 @@ class MeshDelegateHandler(
onHapticFeedback()
if (message.isPrivate) {
// Private message
privateChatManager.handleIncomingPrivateMessage(message)
if (message.sender == "system") {
// System notices (e.g. "x favorited you"): no unread badge, read receipt or push
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = true)
} else {
// Private message
privateChatManager.handleIncomingPrivateMessage(message)
// Reactive read receipts: if chat is focused, send immediately for this message
message.senderPeerID?.let { senderPeerID ->
sendReadReceiptIfFocused(message)
}
// Show notification with enhanced information - now includes senderPeerID
message.senderPeerID?.let { senderPeerID ->
// Use nickname if available, fall back to sender or senderPeerID
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
notificationManager.showPrivateMessageNotification(
senderPeerID = senderPeerID,
senderNickname = senderNickname,
messageContent = preview
)
// Reactive read receipts: if chat is focused, send immediately for this message
message.senderPeerID?.let { senderPeerID ->
sendReadReceiptIfFocused(message)
}
// Show notification with enhanced information - now includes senderPeerID
message.senderPeerID?.let { senderPeerID ->
// Use nickname if available, fall back to sender or senderPeerID
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
notificationManager.showPrivateMessageNotification(
senderPeerID = senderPeerID,
senderNickname = senderNickname,
messageContent = preview
)
}
}
} else if (message.channel != null) {
// Channel message: AppStateStore is the source of truth for list; only manage unread
@ -242,46 +248,44 @@ class MeshDelegateHandler(
val shouldSendReadReceipt = !isAppInBackground &&
senderConversationID != null &&
focusedConversationID == senderConversationID
if (shouldSendReadReceipt) {
android.util.Log.d(
"MeshDelegateHandler",
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
)
val nickname = state.getNicknameValue() ?: "unknown"
val mesh = getMeshService()
val sent = try {
val meshPeerID = senderConversationID
?.let { ContactDirectory.resolve(it).meshPeerID }
?: senderPeerID?.takeIf {
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
}
if (meshPeerID != null &&
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
mesh.hasEstablishedSession(meshPeerID)
) {
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
true
} else {
false
if (shouldSendReadReceipt) {
android.util.Log.d(
"MeshDelegateHandler",
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
)
// UI focus is the source of truth for local read state. Transport acceptance is a
// separate fact and may remain retryable when the peer disconnects.
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
val nickname = state.getNicknameValue().ifBlank { "unknown" }
val mesh = getMeshService()
try {
val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID
?: senderPeerID.takeIf {
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
}
} catch (_: Exception) {
false
if (meshPeerID != null &&
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
mesh.hasEstablishedSession(meshPeerID)
) {
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
}
if (sent) {
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
if (changed) {
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
} catch (_: Exception) { }
// Ensure unread badge is cleared for this peer immediately.
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
if (changed) {
state.setUnreadPrivateMessages(current)
}
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
} catch (_: Exception) { }
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
}
/**
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
*/

View File

@ -8,8 +8,12 @@ import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -27,6 +31,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -54,6 +59,7 @@ import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.launch
/**
@ -82,9 +88,17 @@ fun MeshPeerListSheet(
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle()
val geohashPeopleCount = geohashPeople.size
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
val unreadIdentityAliases = remember(unreadConversations) {
unreadConversations
.flatMapTo(mutableSetOf()) { it.identityAliases }
}
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
peerID.lowercase() in unreadIdentityAliases
}
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
@ -117,7 +131,21 @@ fun MeshPeerListSheet(
) {
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeopleCount
else -> connectedPeers.count { it != viewModel.myPeerID }
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
}
if (unreadConversations.isNotEmpty()) {
item(key = "unread_private_messages_section") {
UnreadDirectMessagesSection(
conversations = unreadConversations,
viewModel = viewModel,
onPrivateChatStart = { conversationID ->
viewModel.showPrivateChatSheet(conversationID)
onDismiss()
},
modifier = Modifier.padding(top = 8.dp)
)
}
}
// Channels section
@ -127,7 +155,9 @@ fun MeshPeerListSheet(
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_chat_bubbles,
title = stringResource(R.string.channels),
modifier = Modifier.padding(top = 8.dp)
modifier = Modifier.padding(
top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp
)
)
Surface(
modifier = Modifier
@ -179,8 +209,12 @@ fun MeshPeerListSheet(
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss,
excludedIdentityAliases = unreadIdentityAliases,
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
top = if (
joinedChannels.isNotEmpty() ||
unreadConversations.isNotEmpty()
) 20.dp else 8.dp
)
)
}
@ -188,9 +222,12 @@ fun MeshPeerListSheet(
else -> {
PeopleSection(
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
top = if (
joinedChannels.isNotEmpty() ||
unreadConversations.isNotEmpty()
) 20.dp else 8.dp
),
connectedPeers = connectedPeers,
connectedPeers = visibleConnectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
@ -198,6 +235,7 @@ fun MeshPeerListSheet(
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
excludedIdentityAliases = unreadIdentityAliases,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@ -312,6 +350,7 @@ fun PeopleSection(
selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set<String> = emptySet(),
peopleCount: Int = 0,
excludedIdentityAliases: Set<String> = emptySet(),
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
@ -353,6 +392,7 @@ fun PeopleSection(
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
@ -364,6 +404,22 @@ fun PeopleSection(
}
}
// Same "they favorited us" signal the private-chat header uses for orange outline stars.
val peerTheyFavoritedUsStates = remember(peerFavoritedUs, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
val fingerprint = peerFingerprints[peerID]
if (fingerprint != null && peerFavoritedUs.contains(fingerprint)) {
true
} else {
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.theyFavoritedUs == true
} catch (_: Exception) {
false
}
}
}
}
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
viewModel.isPeerVerified(peerID, verifiedFingerprints)
@ -410,8 +466,6 @@ fun PeopleSection(
)
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
// Helper to compute display name used for a given key
fun computeDisplayNameForPeerId(key: String): String {
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
@ -430,33 +484,28 @@ fun PeopleSection(
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (!isFavoriteMappedToConnected(fav)) {
if (
favPeerID.lowercase() !in excludedIdentityAliases &&
!isFavoriteMappedToConnected(fav)
) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
}
// Nostr-only conversations
val connectedIds = sortedPeers.toSet()
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
!connectedIds.contains(key) &&
!connectedNoiseHexes.contains(key.lowercase())
}
.forEach { convKey ->
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
// Every row this card will show, in final order, so the animated list can key on identity
// and animate reordering. Offline favourites are appended after the connected peers.
// Collected once for the whole card rather than once per row.
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) }
val offlineFavoriteRows = offlineFavorites.filterNot { favorite ->
val favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex(
favorite.peerNoisePublicKey
)
favoriteNoiseKey.lowercase() in excludedIdentityAliases ||
isFavoriteMappedToConnected(favorite)
}
val rowKeys: List<String> = sortedPeers +
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
@ -468,6 +517,7 @@ fun PeopleSection(
val peerID = connectedPeerForRow
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val isFavorite = peerFavoriteStates[peerID] ?: false
val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
@ -493,6 +543,7 @@ fun PeopleSection(
isWifiAware = peerID in wifiAwarePeerIDs,
isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer,
isFavorite = isFavorite,
theyFavoritedUs = theyFavoritedUs,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme,
@ -542,6 +593,7 @@ fun PeopleSection(
isDirect = false,
isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
theyFavoritedUs = fav.theyFavoritedUs,
isVerified = isVerified,
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
@ -563,6 +615,126 @@ fun PeopleSection(
}
}
@Composable
private fun UnreadDirectMessagesSection(
conversations: List<UnreadConversationSummary>,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
Column(modifier = modifier) {
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_envelope,
title = stringResource(R.string.cd_unread_private_messages)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = colorScheme.surface,
shape = AboutCardShape
) {
AnimatedRowColumn(
items = conversations,
key = { it.conversationID }
) { index, conversation ->
Column {
if (index > 0) SheetCardDivider()
val subtitle = when {
conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}"
conversation.transport == DirectMessageTransport.NOSTR ->
stringResource(R.string.cd_reachable_via_nostr)
!conversation.isConnected ->
stringResource(R.string.cd_offline_mesh_chat)
else -> null
}
val peerIdentity = conversation.nostrPubkey
?.let(viewModel::peerIdentityForNostrPubkey)
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
val assignedColor = colorForPeer(peerIdentity, palette)
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onPrivateChatStart(conversation.conversationID)
}
.padding(
horizontal = SheetRowHorizontal,
vertical = SheetRowVertical
),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.ic_spec_envelope),
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentOrange
)
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = truncateNickname(baseNameRaw),
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = assignedColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
)
}
}
if (subtitle != null) {
Text(
text = subtitle,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = palette.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
UnreadBadge(
count = conversation.unreadCount,
colorScheme = colorScheme
)
}
}
}
}
}
}
@Composable
private fun PeerItem(
peerID: String,
@ -571,6 +743,7 @@ private fun PeerItem(
isWifiAware: Boolean = false,
isSelected: Boolean,
isFavorite: Boolean,
theyFavoritedUs: Boolean = false,
isVerified: Boolean,
hasUnreadDM: Boolean,
colorScheme: ColorScheme,
@ -697,13 +870,15 @@ private fun PeerItem(
.clickable(onClick = onToggleFavorite),
contentAlignment = Alignment.Center
) {
// Three-state star (matches private-chat header): grey outline (no relation),
// orange outline (they favorited us), filled orange (we favorited them).
Icon(
painter = painterResource(
if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star
),
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(PeerRowIconSize),
tint = if (isFavorite) palette.accentOrange else palette.textTertiary
tint = if (isFavorite || theyFavoritedUs) palette.accentOrange else palette.textTertiary
)
}
}
@ -799,6 +974,7 @@ fun PrivateChatSheet(
val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
@ -815,7 +991,7 @@ fun PrivateChatSheet(
}
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
val favoriteRelationship = remember(peerID, favoritePeers) {
val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) {
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
@ -859,12 +1035,55 @@ fun PrivateChatSheet(
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) {
(fingerprint != null && peerFavoritedUs.contains(fingerprint)) ||
favoriteRelationship?.theyFavoritedUs == true
}
// Celebrate being favorited: a springy wobble of the header star. Springs rather than
// keyframed tweens, matching the app's press feedback, so the settle overshoots slightly.
val starWobbleRotation = remember { Animatable(0f) }
val starWobbleScale = remember { Animatable(1f) }
var previousTheyFavoritedUs by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(theyFavoritedUs) {
val wasFavoritedUs = previousTheyFavoritedUs
previousTheyFavoritedUs = theyFavoritedUs
if (theyFavoritedUs && wasFavoritedUs == false) {
starWobbleRotation.snapTo(-16f)
starWobbleScale.snapTo(1.35f)
launch {
starWobbleRotation.animateTo(
targetValue = 0f,
animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessMedium)
)
}
launch {
starWobbleScale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
)
)
}
}
}
val isVerified = remember(peerID, verifiedFingerprints) {
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
val palette = LocalBitchatPalette.current
// Three-state star: grey outline (no relation), orange outline (they favorited us),
// filled orange (we favorited them, mutual or not).
val favoriteStarTint by animateColorAsState(
targetValue = when {
isFavorite || theyFavoritedUs -> palette.accentOrange
else -> colorScheme.onSurfaceVariant
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "favoriteStarTint"
)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@ -984,12 +1203,14 @@ fun PrivateChatSheet(
}
),
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = if (isFavorite) {
palette.accentOrange
} else {
colorScheme.onSurfaceVariant
}
modifier = Modifier
.size(HeaderIconSize)
.graphicsLayer {
rotationZ = starWobbleRotation.value
scaleX = starWobbleScale.value
scaleY = starWobbleScale.value
},
tint = favoriteStarTint
)
}

View File

@ -153,11 +153,17 @@ class MessageManager(private val state: ChatState) {
state.setPrivateChats(updatedChats)
}
fun clearPrivateUnreadMessages(peerID: String) {
fun clearPrivateUnreadMessages(
peerID: String,
aliases: Set<String> = emptySet()
) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
updatedUnread.remove(peerID)
updatedUnread.remove(conversationID)
val normalizedAliases = (aliases + peerID + conversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
updatedUnread.removeAll { unreadID ->
unreadID.lowercase() in normalizedAliases
}
state.setUnreadPrivateMessages(updatedUnread)
}

View File

@ -34,7 +34,9 @@ class PrivateChatManager(
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val noiseSessionDelegate: NoiseSessionDelegate,
private val trackUnreadMessages: Boolean = true
private val trackUnreadMessages: Boolean = true,
private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false },
private val markMessageReadLocally: (messageID: String) -> Unit = {}
) {
companion object {
@ -48,7 +50,11 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
fun startPrivateChat(
peerID: String,
meshService: MeshService,
unreadAliases: Set<String> = emptySet()
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val route = ContactDirectory.resolve(conversationID)
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
@ -76,7 +82,7 @@ class PrivateChatManager(
state.setSelectedPrivateChatPeer(conversationID)
// Clear unread
messageManager.clearPrivateUnreadMessages(conversationID)
messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases)
// Initialize chat if needed
messageManager.initializePrivateChat(conversationID)
@ -402,7 +408,14 @@ class PrivateChatManager(
senderPeerID == meshPeerID ||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
)
if (isFromTarget && meshPeerID != null) {
if (isFromTarget) {
try {
markMessageReadLocally(msg.id)
} catch (e: Exception) {
Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}")
}
}
if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) {
try {
if (hasMesh) {
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)

View File

@ -0,0 +1,105 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
internal enum class DirectMessageTransport {
MESH,
NOSTR
}
/**
* Presence-independent presentation state for an unread private conversation.
*
* A conversation remains in this model until it is read, even when none of its identities are in
* the current mesh or geohash participant lists.
*/
internal data class UnreadConversationSummary(
val conversationID: String,
val displayName: String,
val unreadCount: Int,
val latestMessageAt: Long,
val transport: DirectMessageTransport,
val nostrPubkey: String?,
val identityAliases: Set<String>,
val isConnected: Boolean = false,
val sourceGeohash: String? = null
)
internal fun buildUnreadConversationSummaries(
unreadConversationIDs: Set<String>,
privateChats: Map<String, List<BitchatMessage>>,
currentUserIdentifiers: Set<String>,
canonicalize: (String) -> String,
isMessageRead: (BitchatMessage) -> Boolean
): List<UnreadConversationSummary> {
if (unreadConversationIDs.isEmpty()) return emptyList()
val normalizedCurrentUserIdentifiers = currentUserIdentifiers.filterTo(mutableSetOf()) {
it.isNotBlank()
}
val canonicalUnreadIDs = unreadConversationIDs
.mapTo(linkedSetOf()) { canonicalize(it) }
val unreadAliasesByCanonicalID = unreadConversationIDs.groupBy(canonicalize)
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
privateChats.forEach { (conversationID, messages) ->
val canonicalID = canonicalize(conversationID)
messagesByCanonicalID.getOrPut(canonicalID) { mutableListOf() }.addAll(messages)
}
return canonicalUnreadIDs.map { conversationID ->
val messages = messagesByCanonicalID[conversationID]
.orEmpty()
.distinctBy { it.id }
val incomingMessages = messages.filterNot {
it.sender in normalizedCurrentUserIdentifiers
}
val unreadIncomingMessages = incomingMessages.filterNot(isMessageRead)
val latestMessage = (unreadIncomingMessages.ifEmpty { incomingMessages })
.maxWithOrNull(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
val aliases = unreadAliasesByCanonicalID[conversationID].orEmpty()
val nostrPubkey = latestMessage?.senderNostrPubkey
val isNostrConversation = nostrPubkey != null ||
aliases.any(::isNostrConversationID) ||
isNostrConversationID(conversationID)
UnreadConversationSummary(
conversationID = conversationID,
displayName = latestMessage
?.sender
?.takeIf { it.isNotBlank() }
?: conversationID.take(12),
unreadCount = unreadIncomingMessages.size.coerceAtLeast(1),
latestMessageAt = latestMessage?.timestamp?.time ?: Long.MIN_VALUE,
transport = if (isNostrConversation) {
DirectMessageTransport.NOSTR
} else {
DirectMessageTransport.MESH
},
nostrPubkey = nostrPubkey,
identityAliases = (aliases + conversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
)
}.sortedWith(
compareByDescending<UnreadConversationSummary> { it.latestMessageAt }
.thenBy { it.displayName.lowercase() }
.thenBy { it.conversationID }
)
}
private fun isNostrConversationID(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
internal fun matchingUnreadAliases(
unreadConversationIDs: Set<String>,
canonicalConversationID: String,
canonicalize: (String) -> String
): Set<String> {
val normalizedCanonicalID = canonicalConversationID.lowercase()
return unreadConversationIDs
.filterTo(mutableSetOf()) { unreadID ->
canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true)
}
.plus(canonicalConversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
}

View File

@ -8,6 +8,7 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.VerificationService
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
@ -185,6 +186,9 @@ class VerificationHandler(
val hexRegex = Regex("^[0-9a-fA-F]+$")
return try {
when {
ContactIdentityResolver.isContactConversationId(peerID) -> {
ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
}
peerID.length == 64 && peerID.matches(hexRegex) -> {
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }

View File

@ -41,10 +41,11 @@ data class BitchatPalette(
val accentPurple: Color,
// MARK: - Deterministic peer colors
/** Chroma applied after deriving a peer's stable hue. */
val peerColorSaturation: Float,
/** Brightness applied after deriving a peer's stable hue. */
val peerColorValue: Float,
/**
* Saturation/value applied after deriving a peer's stable hue. Swap this when adding a
* new theme see [PeerColorStyle] for contrast guidelines.
*/
val peerColors: PeerColorStyle,
)
val DarkBitchatPalette = BitchatPalette(
@ -56,8 +57,7 @@ val DarkBitchatPalette = BitchatPalette(
textTertiary = Color(0xFF6B776B),
accentOrange = Color(0xFFFF9F0A),
accentPurple = Color(0xFFBF5AF2),
peerColorSaturation = 1f,
peerColorValue = 1f,
peerColors = PeerColorStyle.Dark,
)
val LightBitchatPalette = BitchatPalette(
@ -69,8 +69,7 @@ val LightBitchatPalette = BitchatPalette(
textTertiary = Color(0xFF757F75),
accentOrange = Color(0xFFFF9500),
accentPurple = Color(0xFFAF52DE),
peerColorSaturation = 0.85f,
peerColorValue = 0.45f,
peerColors = PeerColorStyle.Light,
)
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }

View File

@ -1,9 +1,36 @@
package com.bitchat.android.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.bitchat.android.ui.PeerIdentity
import kotlin.math.abs
/**
* Theme-specific chroma applied after a peer's stable hue is derived.
*
* Hue stays identity-stable across themes (and byte-identical to iOS). Only saturation
* and value change so peer labels remain readable on each background.
*
* Guidelines when adding a future theme:
* - Dim / dark backgrounds: keep [value] high so colors are not lost against the surface;
* prefer muted [saturation] over neon.
* - Light backgrounds: keep [value] moderate-low so colors are not blinding; avoid
* near-full saturation.
*/
@Immutable
data class PeerColorStyle(
val saturation: Float,
val value: Float,
) {
companion object {
/** Soft pastels that stay bright enough on near-black chat surfaces. */
val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f)
/** Deeper, less saturated tones that stay readable on near-white surfaces. */
val Light = PeerColorStyle(saturation = 0.70f, value = 0.42f)
}
}
/**
* The single identity-to-color boundary used by chat, people sheets, and mentions.
*
@ -22,9 +49,10 @@ fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
hue = (hue + 0.12) % 1.0
}
val style = palette.peerColors
return Color.hsv(
hue = (hue * 360).toFloat(),
saturation = palette.peerColorSaturation,
value = palette.peerColorValue
saturation = style.saturation,
value = style.value
)
}

View File

@ -14,6 +14,7 @@ object AppConstants {
// Peer lifecycle
const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes
const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L
const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L
// BLE connection tracking
const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L
@ -52,6 +53,7 @@ object AppConstants {
const val CLEANUP_INTERVAL_MS: Long = 300_000L
const val MAX_PROCESSED_MESSAGES: Int = 10_000
const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000
const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L
}
object Noise {
@ -129,7 +131,16 @@ object AppConstants {
}
object Media {
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
// A file is currently encoded into one protocol payload before BLE fragmentation.
// Reserve room for maximum filename/MIME TLVs and encryption envelope overhead.
const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024)
}
object Router {
const val OUTBOX_TICK_MS: Long = 2_000L
const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours
const val OUTBOX_MAX_PER_PEER: Int = 100
val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L)
}
object Services {

View File

@ -1,39 +0,0 @@
package com.bitchat.android.wifiaware
/**
* Resolves an authenticated callback to the exact still-active ingress link that completed Noise.
* A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
*/
internal object AuthenticatedIngressLinkPolicy {
data class Claim(
val relayAddress: String,
val linkID: String
)
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun matches(
expected: Claim?,
authenticatedRelayAddress: String?,
authenticatedLinkID: String?
): Boolean =
expected != null &&
expected.relayAddress == authenticatedRelayAddress &&
expected.linkID == authenticatedLinkID
fun <T : Any> resolve(
authenticatedLinkID: String?,
authenticatedRelayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = authenticatedLinkID ?: return null
val relayAddress = authenticatedRelayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.wifiaware
/** Resolves a packet to the exact still-active ingress link that delivered it. */
internal object IngressLinkPolicy {
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun <T : Any> resolve(
ingressLinkID: String?,
relayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = ingressLinkID ?: return null
val relayAddress = relayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -100,9 +100,9 @@ class WifiAwareConnectionTracker(
}
/**
* Atomically require that [expectedSocket] is still the active provisional transport and, only
* then, promote it. This closes the gap where a replacement socket could land after validation
* but before mutation and the stale authenticated socket would become canonical.
* Atomically require that [expectedSocket] is still the active provisional transport before
* rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE
* validation but before mutation and the stale socket would become canonical.
*/
fun rebindPeerIdIfCurrent(
previousPeerId: String,

View File

@ -13,6 +13,7 @@ import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
import com.bitchat.android.mesh.FragmentingPacketSender
import com.bitchat.android.mesh.MeshCore
import com.bitchat.android.mesh.MeshService
@ -75,7 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L
private const val CLIENT_SOCKET_ATTEMPTS = 3
private const val CLIENT_ROLE_REVERSAL_FAILURES = 3
private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L
private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:"
}
@ -122,12 +122,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
IngressLinkPolicy.Link<SyncedSocket>
>()
private val provisionalWifiClaims =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val authenticatedWifiLinks =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained
@ -174,38 +170,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
onAnnounceProcessed = { routed, _ ->
publishControllerDebugSnapshot()
routed.peerID?.let { pid ->
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
// Discovery IDs from older clients can be provisional. A verified direct
// announce is enough to start a handshake for the canonical ID, but not to
// rebind the socket. A fresh challenge is sent through the exact transport
// generation, and only its same-link completion may promote that alias.
val relay = routed.relayAddress
val linkID = routed.ingressLinkID
if (
routed.packet.ttl == MAX_TTL &&
relay != null &&
linkID != null
) {
val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID)
if (!AuthenticatedIngressLinkPolicy.matches(
authenticatedWifiLinks[pid],
relay,
linkID
)
) {
registerProvisionalWifiClaim(pid, claim)
if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
provisionalWifiClaims.remove(pid, claim)
Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}")
}
}
}
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
?.let(::observeDirectIngressLink)
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
} catch (_: Exception) { }
}
},
onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
},
announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
},
@ -249,14 +220,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/**
* Broadcasts raw bytes to currently connected peer.
*/
private fun broadcastRaw(bytes: ByteArray) {
private fun broadcastRaw(bytes: ByteArray): Boolean {
var accepted = false
connectionTracker.peerSockets.forEach { (pid, sock) ->
try {
sock.write(bytes)
accepted = true
} catch (e: IOException) {
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
}
}
return accepted
}
// TransportLayer implementation
@ -266,6 +240,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFromBridge(packet)
}
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
return meshCore.sendFromBridgeAndReport(packet)
}
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
sendPacketToPeer(peerID, packet)
}
@ -273,23 +251,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/**
* Broadcasts routed packet to currently connected peers.
*/
private fun broadcastPacket(routed: RoutedPacket) {
private fun broadcastPacket(routed: RoutedPacket): Boolean {
val packet = routed.packet
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
val firstHop = packet.route!![0].toHexString()
if (sendRoutedPacketToPeer(firstHop, routed)) {
return
return true
}
}
val recipientId = packet.recipientID?.toHexString()
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
if (sendRoutedPacketToPeer(recipientId, routed)) {
return
return true
}
}
fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
return fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
broadcastSinglePacket(single)
}
}
@ -317,8 +295,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun broadcastSinglePacket(routed: RoutedPacket): Boolean {
val data = routed.packet.toBinaryData() ?: return false
broadcastRaw(data)
return true
return broadcastRaw(data)
}
private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
@ -585,8 +562,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
meshCore.shutdown()
@ -632,8 +607,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
}
} finally {
recoveryInProgress = false
@ -904,7 +877,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// presence makes hasOpenServerSocket() true for the life of the process)
// and so we free the fd/port promptly.
connectionTracker.closeServerSocket(peerId)
try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle)
@ -1138,7 +1110,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
connectionTracker.onClientConnected(peerId, synced)
publishControllerDebugSnapshot()
clientSocketFailures.remove(peerId)
try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleServerKeepAlive(synced, peerId, peerHandle)
@ -1239,71 +1210,78 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
/**
* Promote a provisional discovery alias only when the exact, still-active socket delivered the
* Noise frame that completed authentication for the canonical peer ID.
* Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale
* socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is
* not restarted or coupled to this routing observation.
*/
private fun promoteAuthenticatedIngressLink(
canonicalPeerId: String,
relayAddress: String,
ingressLinkID: String
private fun observeDirectIngressLink(
observation: DirectLinkAnnouncementPolicy.Observation
) {
val expectedClaim = provisionalWifiClaims[canonicalPeerId]
if (!AuthenticatedIngressLinkPolicy.matches(
expectedClaim,
relayAddress,
ingressLinkID
)
) {
Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}")
return
}
provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
val link = IngressLinkPolicy.resolve(
ingressLinkID = observation.ingressLinkID,
relayAddress = observation.relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: run {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
Log.d(
TAG,
"Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale"
)
return
}
val provisionalPeerId = link.relayAddress
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
if (existingCanonical == canonicalPeerId) {
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
if (existingCanonical == observation.peerID) {
try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
return
}
if (existingCanonical != provisionalPeerId) {
Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias")
Log.w(
TAG,
"Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias"
)
return
}
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed")
if (!connectionTracker.rebindPeerIdIfCurrent(
provisionalPeerId,
observation.peerID,
link.transport
)
) {
Log.d(
TAG,
"Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed"
)
return
}
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID
}
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it }
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
discoveredTimestamps[canonicalPeerId] = discoveredAt
discoveredTimestamps[observation.peerID] = discoveredAt
try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { }
try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { }
try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { }
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { }
try {
meshCore.addOrUpdatePeer(
observation.peerID,
meshCore.getPeerNickname(observation.peerID) ?: observation.peerID
)
} catch (_: Exception) { }
try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000)
} catch (_: Exception) { }
publishControllerDebugSnapshot()
Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}")
Log.i(
TAG,
"Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}"
)
}
/**
@ -1316,7 +1294,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
val logicalPeerId = initialLogicalPeerId
val ingressLinkID = UUID.randomUUID().toString()
val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket)
ingressLinks[ingressLinkID] = ingressLink
while (isActive) {
val raw = socket.read() ?: break
@ -1331,10 +1309,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue
if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) {
// The socket's discovery identity remains provisional until Noise proves possession
// of the claimed static key on this link. A canonical self-signed announcement is
// only TOFU and cannot safely rebind/remove transport state on its own.
Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof")
// Rebinding happens only after MeshCore validates and accepts this ANNOUNCE.
Log.d(
TAG,
"RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation"
)
}
// Route the packet:
@ -1344,7 +1323,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
ingressLinks.remove(ingressLinkID, ingressLink)
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)")
@ -1352,27 +1330,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
socket.close()
}
private fun registerProvisionalWifiClaim(
peerID: String,
claim: AuthenticatedIngressLinkPolicy.Claim
) {
provisionalWifiClaims[peerID] = claim
serviceScope.launch {
delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
if (provisionalWifiClaims.remove(peerID, claim)) {
Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
} }
}
private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
provisionalWifiClaims.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
authenticatedWifiLinks.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
}
private fun handleNetworkFailure(peerId: String) {
serviceScope.launch {
if (!connectionTracker.isConnected(peerId)) {
@ -1660,9 +1617,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private inner class WifiAwareTransport : MeshTransport {
override val id: String = "WIFI"
override fun broadcastPacket(routed: RoutedPacket) {
override fun broadcastPacket(routed: RoutedPacket): Boolean =
this@WifiAwareMeshService.broadcastPacket(routed)
}
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
@ -1671,9 +1627,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
ingressLinkID: String,
packet: BitchatPacket
): Boolean {
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
val link = IngressLinkPolicy.resolve(
ingressLinkID = ingressLinkID,
relayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: return false

View File

@ -1,5 +1,6 @@
package com.bitchat.android.protocol
import com.bitchat.android.model.BitchatFilePacket
import org.junit.Assert.assertEquals
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
@ -7,9 +8,11 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Random
import java.util.zip.Deflater
class BinaryProtocolTest {
@ -987,6 +990,315 @@ class BinaryProtocolTest {
assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result)
}
@Test
fun `v2 expanded payload at exact maximum passes bound without allocating output`() {
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
val raw = compressedPacket(
version = 2u,
originalSize = max,
compressedData = ByteArray(256) { it.toByte() }
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
calls += 1
assertEquals(max, requestedSize)
null // Prove the boundary reached this seam without allocating a 10 MiB result.
}
assertNull("The test decompressor deliberately returns no payload", result)
assertEquals(1, calls)
}
@Test
fun `v2 expanded payload above maximum never reaches decompressor`() {
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
val raw = compressedPacket(
version = 2u,
originalSize = max + 1,
compressedData = ByteArray(256) { it.toByte() }
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
byteArrayOf(0x42)
}
assertNull("An oversized expansion must be rejected before inflation", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `v2 negative expanded payload never reaches decompressor`() {
val raw = compressedPacket(
version = 2u,
originalSize = -1,
compressedData = byteArrayOf(0x03)
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
byteArrayOf(0x42)
}
assertNull("A negative expansion must be rejected before inflation", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `zero expanded payload never reaches decompressor`() {
val raw = compressedPacket(
version = 2u,
originalSize = 0,
compressedData = rawDeflate(ByteArray(0))
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
ByteArray(0)
}
assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `empty compressed body never reaches decompressor`() {
val raw = compressedPacket(
version = 2u,
originalSize = 128,
compressedData = ByteArray(0)
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
ByteArray(128)
}
assertNull("A compressed payload must contain deflate bytes", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `v1 unsigned maximum expanded size reaches decompressor`() {
val originalSize = 0xFFFF
val raw = compressedPacket(
version = 1u,
originalSize = originalSize,
compressedData = byteArrayOf(0x01, 0x02)
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
calls += 1
assertEquals(originalSize, requestedSize)
null
}
assertNull("The test decompressor deliberately returns no payload", result)
assertEquals(1, calls)
}
@Test
fun `compression utility rejects invalid expansion sizes directly`() {
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
assertNull(CompressionUtil.decompress(ByteArray(0), 1))
assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0))
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1))
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1))
}
@Test
fun `raw deflate expands only when size and stream completion are exact`() {
val payload = ByteArray(4_096) { index -> (index % 17).toByte() }
val compressed = rawDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
)
assertNotNull(decoded)
assertArrayEquals(payload, decoded!!.payload)
}
@Test
fun `zlib wrapped payload remains compatible when size and stream completion are exact`() {
val payload = ByteArray(4_096) { index -> (index % 23).toByte() }
val compressed = zlibDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
)
assertNotNull(decoded)
assertArrayEquals(payload, decoded!!.payload)
}
@Test
fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() {
val payload = ByteArray(29) { index -> (index + 1).toByte() }
val compressed = byteArrayOf(
0x08, // non-final raw stored block; also zlib CMF
0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check
0xe2.toByte(), 0xff.toByte() // one's complement of LEN
) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block
assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size))
}
@Test
fun `under-declared zlib expansion is rejected by fallback`() {
val payload = ByteArray(4_096) { 0x51 }
val compressed = zlibDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
)
assertNull("Zlib fallback must reject output beyond the declaration", decoded)
}
@Test
fun `over-declared zlib expansion is rejected by fallback`() {
val payload = ByteArray(128) { 0x52 }
val compressed = zlibDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
)
assertNull("Zlib fallback must reject output shorter than the declaration", decoded)
}
@Test
fun `truncated zlib stream is rejected by fallback`() {
val payload = ByteArray(4_096) { index -> (index % 29).toByte() }
val compressed = zlibDeflate(payload)
val truncated = compressed.copyOf(compressed.size - 1)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
)
assertNull("Zlib fallback must require the stream end marker and checksum", decoded)
}
@Test
fun `zlib stream with trailing bytes is rejected by fallback`() {
val payload = ByteArray(4_096) { index -> (index % 13).toByte() }
val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00)
val decoded = BinaryProtocol.decode(
compressedPacket(
version = 2u,
originalSize = payload.size,
compressedData = compressedWithTrailingByte
)
)
assertNull("Zlib fallback must consume the complete input and nothing more", decoded)
}
@Test
fun `under-declared raw expansion is rejected even when output buffer fills`() {
val payload = ByteArray(4_096) { 0x41 }
val compressed = rawDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
)
assertNull("Inflater must be finished, not merely fill the declared buffer", decoded)
}
@Test
fun `over-declared raw expansion is rejected instead of returning a prefix`() {
val payload = ByteArray(128) { 0x42 }
val compressed = rawDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
)
assertNull("The expanded byte count must equal the declaration", decoded)
}
@Test
fun `truncated raw stream is rejected even if all declared bytes were emitted`() {
val payload = ByteArray(4_096) { index -> (index % 31).toByte() }
val compressed = rawDeflate(payload)
val truncated = compressed.copyOf(compressed.size - 1)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
)
assertNull("A stream without its end marker must not be accepted", decoded)
}
@Test
fun `raw stream with trailing bytes is rejected`() {
val payload = ByteArray(4_096) { index -> (index % 19).toByte() }
val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00)
val decoded = BinaryProtocol.decode(
compressedPacket(
version = 2u,
originalSize = payload.size,
compressedData = compressedWithTrailingByte
)
)
assertNull("Trailing bytes after a complete stream must not be accepted", decoded)
}
@Test
fun `decoder rejects a decompressor result shorter than its declaration`() {
val raw = compressedPacket(
version = 2u,
originalSize = 128,
compressedData = ByteArray(16) { it.toByte() }
)
val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) }
assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded)
}
@Test
fun `new sender refuses legacy 11 MiB public file transfer before transmission`() {
val content = ByteArray(11 * 1024 * 1024) { 0x41 }
val filePayload = BitchatFilePacket(
fileName = "legacy-11m.bin",
fileSize = content.size.toLong(),
mimeType = "application/octet-stream",
content = content
).encode()
assertNotNull(filePayload)
val encoded = BinaryProtocol.encode(
BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = hexToBytes(senderHex),
recipientID = SpecialRecipients.BROADCAST,
timestamp = fixedTimestamp,
payload = filePayload!!,
ttl = 5u
),
padding = false
)
assertNull(
"Sender and receiver must enforce the same expanded-payload ceiling",
encoded
)
}
/**
* Compression bomb is rejected
*
@ -1163,6 +1475,59 @@ class BinaryProtocolTest {
return result
}
private fun compressedPacket(
version: UByte,
originalSize: Int,
compressedData: ByteArray,
type: UByte = MessageType.MESSAGE.value
): ByteArray {
val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2
val payloadLength = originalSizeFieldBytes + compressedData.size
val headerSize = if (version >= 2u.toUByte()) 16 else 14
val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply {
order(ByteOrder.BIG_ENDIAN)
put(version.toByte())
put(type.toByte())
put(5.toByte())
putLong(fixedTimestamp.toLong())
put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
if (version >= 2u.toUByte()) {
putInt(payloadLength)
} else {
putShort(payloadLength.toShort())
}
put(hexToBytes(senderHex))
if (version >= 2u.toUByte()) {
putInt(originalSize)
} else {
putShort(originalSize.toShort())
}
put(compressedData)
}
return buffer.array()
}
private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true)
private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false)
private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray {
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)
return try {
deflater.setInput(data)
deflater.finish()
val output = ByteArrayOutputStream()
val buffer = ByteArray(1_024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
output.write(buffer, 0, count)
}
output.toByteArray()
} finally {
deflater.end()
}
}
private fun makePacket(
version: UByte = 1u,
type: UByte = MessageType.MESSAGE.value,

View File

@ -0,0 +1,117 @@
package com.bitchat.android.protocol
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class DecompressionResourcePoolTest {
@Test
fun `small reservations run concurrently and next waits only when budget is full`() {
val pool = DecompressionResourcePool(
budgetBytes = 2_000,
unitBytes = 1_000,
waitTimeoutMs = 2_000
)
val executor = Executors.newFixedThreadPool(3)
val entered = CountDownLatch(2)
val release = CountDownLatch(1)
val thirdEntered = CountDownLatch(1)
try {
repeat(2) {
executor.submit {
pool.withReservation(1_000) {
entered.countDown()
release.await()
}
}
}
assertTrue(entered.await(1, TimeUnit.SECONDS))
executor.submit {
pool.withReservation(1_000) {
thirdEntered.countDown()
}
}
assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS))
release.countDown()
assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS))
} finally {
release.countDown()
executor.shutdownNow()
}
}
@Test
fun `timed admission drops work instead of waiting indefinitely`() {
val pool = DecompressionResourcePool(
budgetBytes = 1_000,
unitBytes = 1_000,
waitTimeoutMs = 50
)
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val executor = Executors.newSingleThreadExecutor()
try {
executor.submit {
pool.withReservation(1_000) {
entered.countDown()
release.await()
}
}
assertTrue(entered.await(1, TimeUnit.SECONDS))
assertNull(pool.withReservation(1_000) { "unexpected" })
} finally {
release.countDown()
executor.shutdownNow()
}
}
@Test
fun `permits are released when decode throws`() {
val pool = DecompressionResourcePool(2_000, 1_000, 50)
try {
pool.withReservation(2_000) { error("boom") }
} catch (_: IllegalStateException) {
// Expected.
}
assertEquals(2, pool.availablePermits)
assertEquals("ok", pool.withReservation(2_000) { "ok" })
}
@Test
fun `runtime budget is based on heap memory and always admits one maximum packet`() {
val maxPacketResources = 20L * 1024 * 1024
assertEquals(
maxPacketResources,
DecompressionResourcePool.recommendedBudgetBytes(
maxHeapBytes = 64L * 1024 * 1024,
maxPacketResourceBytes = maxPacketResources
)
)
assertEquals(
32L * 1024 * 1024,
DecompressionResourcePool.recommendedBudgetBytes(
maxHeapBytes = 256L * 1024 * 1024,
maxPacketResourceBytes = maxPacketResources
)
)
assertEquals(
64L * 1024 * 1024,
DecompressionResourcePool.recommendedBudgetBytes(
maxHeapBytes = 2L * 1024 * 1024 * 1024,
maxPacketResourceBytes = maxPacketResources
)
)
}
}

View File

@ -14,6 +14,7 @@ import com.bitchat.android.ui.theme.LightBitchatColorScheme
import com.bitchat.android.ui.theme.LightBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.PeerColorStyle
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.Date
@ -444,8 +445,8 @@ class ChatUIUtilsTest {
@Test
fun `peer color hue is stable across light and dark, only chroma differs`() {
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for
// the redesigned neutral message body.
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per
// theme so dark mode stays muted-but-bright and light mode stays deep-but-readable.
val identity = PeerIdentity.mesh("abc")
val dark = colorForPeer(identity, DarkBitchatPalette)
val light = colorForPeer(identity, LightBitchatPalette)
@ -456,10 +457,16 @@ class ChatUIUtilsTest {
rgbToHsv(light.red, light.green, light.blue, lightHsv)
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
assertEquals(1.0, darkHsv[1].toDouble(), 0.01)
assertEquals(1.0, darkHsv[2].toDouble(), 0.01)
assertEquals(0.85, lightHsv[1].toDouble(), 0.01)
assertEquals(0.45, lightHsv[2].toDouble(), 0.01)
assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01)
assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01)
assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01)
assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01)
// Dark theme: muted chroma, never dark (readable on near-black).
assertTrue(darkHsv[1] < 0.75f)
assertTrue(darkHsv[2] >= 0.75f)
// Light theme: avoid neon / near-white peer labels.
assertTrue(lightHsv[1] < 0.85f)
assertTrue(lightHsv[2] <= 0.55f)
}
@Test
@ -485,7 +492,7 @@ class ChatUIUtilsTest {
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
assertTrue(
LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue
LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
)
}

View File

@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -14,6 +15,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import java.util.Date
@ -95,4 +97,43 @@ class CommandProcessorTest() {
assertEquals(result, true)
}
@Test
fun `msg command persists incoming messages as locally read through shared chat opening`() {
val peerID = "0102030405060708"
val message = BitchatMessage(
id = "message-opened-by-command",
sender = "alice",
content = "hello",
timestamp = Date(1),
isPrivate = true,
senderPeerID = peerID
)
val locallyRead = mutableListOf<String>()
chatState.setPrivateChats(mapOf(peerID to listOf(message)))
whenever(meshService.getPeerNicknames()).thenReturn(mapOf(peerID to "alice"))
commandProcessor = CommandProcessor(
state = chatState,
messageManager = messageManager,
channelManager = channelManager,
privateChatManager = PrivateChatManager(
state = chatState,
messageManager = messageManager,
dataManager = DataManager(context = context),
noiseSessionDelegate = mock<NoiseSessionDelegate>(),
markMessageReadLocally = locallyRead::add
)
)
commandProcessor.processCommand(
command = "/msg alice",
meshService = meshService,
myPeerID = "self",
onSendMessage = { _, _, _ -> },
viewModel = null
)
assertTrue(locallyRead.contains(message.id))
}
}

View File

@ -237,7 +237,7 @@ class FileTransferTest {
// Given: Large file size (simulated)
val largeFileSize = 100L * 1024 * 1024 // 100MB
val maxAllowedSize = 50L * 1024 * 1024 // 50MB
val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
// When: Checking if file can be transferred
val isAllowed = largeFileSize <= maxAllowedSize

View File

@ -1,40 +0,0 @@
package com.bitchat.android.mesh
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedBleLinkPolicyTest {
private val claim = AuthenticatedBleLinkPolicy.Claim(
deviceAddress = "AA:BB:CC:DD:EE:FF",
linkID = "connection-a"
)
@Test
fun `accepts completion from exact claimed connection`() {
assertTrue(
AuthenticatedBleLinkPolicy.matches(
claim,
authenticatedAddress = claim.deviceAddress,
authenticatedLinkID = claim.linkID
)
)
}
@Test
fun `rejects replacement connection reusing device address`() {
assertFalse(
AuthenticatedBleLinkPolicy.matches(
claim,
authenticatedAddress = claim.deviceAddress,
authenticatedLinkID = "connection-b"
)
)
}
@Test
fun `rejects completion on another address or without a claim`() {
assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID))
assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID))
}
}

View File

@ -14,7 +14,7 @@ import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class BluetoothConnectionTrackerLinkIdentityTest {
class BluetoothConnectionTrackerLinkObservationTest {
private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
@ -46,8 +46,40 @@ class BluetoothConnectionTrackerLinkIdentityTest {
assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a"))
assertEquals("link-b", tracker.getCurrentLinkID(address))
assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677"))
assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677"))
assertEquals("0011223344556677", tracker.addressPeerMap[address])
assertSame(device, tracker.getDeviceConnection(address)?.device)
}
@Test
fun `one peer can remain directly observed over multiple current links`() {
val firstAddress = "AA:BB:CC:DD:EE:01"
val secondAddress = "AA:BB:CC:DD:EE:02"
val firstDevice = mock<BluetoothDevice>()
val secondDevice = mock<BluetoothDevice>()
whenever(firstDevice.address).thenReturn(firstAddress)
whenever(secondDevice.address).thenReturn(secondAddress)
tracker.addDeviceConnection(
firstAddress,
BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a")
)
tracker.addDeviceConnection(
secondAddress,
BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b")
)
assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID })
assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a"))
assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress])
assertTrue(tracker.addressPeerMap.containsValue(PEER_ID))
}
private companion object {
const val PEER_ID = "0011223344556677"
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DirectLinkAnnouncementPolicyTest {
@Test
fun `accepted max ttl announce is a direct routing observation`() {
val routed = announce(ttl = MAX_TTL)
assertEquals(
DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID),
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
)
}
@Test
fun `relayed announce is not a direct routing observation`() {
assertNull(
DirectLinkAnnouncementPolicy.observationFor(
announce(ttl = (MAX_TTL - 1u).toUByte()),
MAX_TTL
)
)
}
@Test
fun `repeated announce remains the same observation without transport authentication state`() {
val routed = announce(ttl = MAX_TTL)
val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
assertEquals(first, second)
}
private fun announce(ttl: UByte) = RoutedPacket(
packet = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = PEER_ID.hexToBytes(),
timestamp = 1u,
payload = byteArrayOf(1),
ttl = ttl
),
peerID = PEER_ID,
relayAddress = RELAY_ADDRESS,
ingressLinkID = LINK_ID
)
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private companion object {
const val PEER_ID = "0011223344556677"
const val RELAY_ADDRESS = "transport-neighbor"
const val LINK_ID = "current-link"
val MAX_TTL: UByte = 7u
}
}

View File

@ -374,6 +374,77 @@ class MessageHandlerTest {
Unit
}
@Test
fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking {
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null)
whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
val packet = encryptedPacket()
repeat(2) {
assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
}
verify(delegate, never()).removeNoiseSession(any())
verify(delegate, never()).initiateNoiseHandshake(any())
assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
verify(delegate).removeNoiseSession(peerID)
verify(delegate).initiateNoiseHandshake(peerID)
}
@Test
fun `successful decrypt resets the failure counter`() = runBlocking {
whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode()
whenever(delegate.decryptFromPeer(any(), eq(peerID)))
.thenReturn(null)
.thenReturn(null)
.thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession))
.thenReturn(null)
.thenReturn(null)
val packet = encryptedPacket()
repeat(5) {
handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))
}
verify(delegate, never()).removeNoiseSession(any())
verify(delegate, never()).initiateNoiseHandshake(any())
}
@Test
fun `decrypt failures without an established session never reset`() = runBlocking {
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null)
whenever(delegate.hasNoiseSession(peerID)).thenReturn(false)
val packet = encryptedPacket()
repeat(4) {
assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
}
verify(delegate, never()).removeNoiseSession(any())
verify(delegate, never()).initiateNoiseHandshake(any())
}
@Test
fun `successful decrypt reports the packet as valid`() = runBlocking {
val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode()
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(
NoiseDecryptionResult(plaintext, authenticatedSession)
)
assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link")))
}
private fun encryptedPacket(): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = byteArrayOf(0x41, 0x42, 0x43),
ttl = 7u
)
private fun announcePacket(
ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),

View File

@ -116,7 +116,7 @@ class PacketProcessorAnnounceSideEffectTest {
handshakeHandled.complete(Unit)
return acceptHandshake
}
override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit
override fun handleNoiseEncrypted(routed: RoutedPacket) = true
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
handled.complete(Unit)
return acceptAnnounce

View File

@ -0,0 +1,121 @@
package com.bitchat.android.mesh
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RetryingControlPacketSenderTest {
@Test
fun `control packet is sent for the full redundant retry window`() = runTest {
val attempts = mutableListOf<Int>()
val sender = RetryingControlPacketSender(
scope = this,
maxAttempts = 3,
retryDelayMs = 10,
interSendDelayMs = 1
)
sender.enqueue(
key = "peer:message",
sendAttempt = { attempt ->
attempts += attempt
true
}
)
advanceUntilIdle()
assertEquals(listOf(1, 2, 3), attempts)
}
@Test
fun `duplicate enqueue is coalesced while receipt retry is active`() = runTest {
var firstRequestAttempts = 0
var duplicateRequestAttempts = 0
val sender = RetryingControlPacketSender(
scope = this,
maxAttempts = 3,
retryDelayMs = 10,
interSendDelayMs = 1
)
sender.enqueue(
key = "peer:message",
sendAttempt = {
firstRequestAttempts += 1
true
}
)
sender.enqueue(
key = "peer:message",
sendAttempt = {
duplicateRequestAttempts += 1
true
}
)
advanceUntilIdle()
assertEquals(3, firstRequestAttempts)
assertEquals(0, duplicateRequestAttempts)
}
@Test
fun `transport writes for different receipts are serialized`() = runTest {
var activeWrites = 0
var maximumActiveWrites = 0
val sender = RetryingControlPacketSender(
scope = this,
maxAttempts = 1,
retryDelayMs = 0,
interSendDelayMs = 0
)
fun enqueue(key: String) {
sender.enqueue(
key = key,
sendAttempt = {
activeWrites += 1
maximumActiveWrites = maxOf(maximumActiveWrites, activeWrites)
delay(10)
activeWrites -= 1
true
}
)
}
enqueue("peer:first")
enqueue("peer:second")
advanceUntilIdle()
assertEquals(1, maximumActiveWrites)
}
@Test
fun `completion reports whether transport accepted any attempt`() = runTest {
val completions = mutableListOf<Boolean>()
val sender = RetryingControlPacketSender(
scope = this,
maxAttempts = 3,
retryDelayMs = 1,
interSendDelayMs = 0
)
sender.enqueue(
key = "peer:rejected",
sendAttempt = { false },
onComplete = completions::add
)
sender.enqueue(
key = "peer:eventually-accepted",
sendAttempt = { attempt -> attempt == 2 },
onComplete = completions::add
)
advanceUntilIdle()
assertEquals(2, completions.size)
assertEquals(setOf(false, true), completions.toSet())
}
}

View File

@ -542,6 +542,28 @@ class SecurityManagerTest {
)
}
@Test
fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking {
val payload = byteArrayOf(0x71, 0x72, 0x73)
val routed = handshakePacket(payload)
assertTrue(securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.handshakeCalls == 1)
assertFalse("Identical frame inside the dedup window must be dropped",
securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.handshakeCalls == 1)
securityManager.cleanupOldData(
System.currentTimeMillis() +
com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000
)
assertTrue("Expired dedup entries must not block a delayed retry",
securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.handshakeCalls == 2)
}
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
val info = PeerInfo(
id = peerID,

View File

@ -0,0 +1,153 @@
package com.bitchat.android.noise
import com.bitchat.android.noise.southernstorm.protocol.Noise
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.atomic.AtomicReference
class NoiseSessionManagerHandshakeTimeoutTest {
private data class TestIdentity(
val privateKey: ByteArray,
val publicKey: ByteArray,
val peerID: String
)
private val managers = mutableListOf<NoiseSessionManager>()
@After
fun tearDown() {
managers.forEach(NoiseSessionManager::shutdown)
}
@Test
fun `stale initiator handshake is expired and reported as timeout`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val failure = AtomicReference<Throwable?>()
aliceManager.onSessionFailed = { peerID, error ->
if (peerID == bob.peerID) failure.set(error)
}
assertNotNull(aliceManager.initiateHandshake(bob.peerID))
assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
assertNull(aliceManager.getSession(bob.peerID))
assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout)
}
@Test
fun `fresh handshake is not expired`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
assertNotNull(aliceManager.initiateHandshake(bob.peerID))
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000)
assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
}
@Test
fun `expired handshake can be re-initiated immediately`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
assertNotNull(aliceManager.initiateHandshake(bob.peerID))
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID))
assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
}
@Test
fun `established session is never expired by the sweep`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
val aliceSession = aliceManager.getSession(bob.peerID)
val bobSession = bobManager.getSession(alice.peerID)
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000)
bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000)
assertSame(aliceSession, aliceManager.getSession(bob.peerID))
assertSame(bobSession, bobManager.getSession(alice.peerID))
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
assertTrue(bobManager.hasEstablishedSession(alice.peerID))
val plaintext = "still alive".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
}
@Test
fun `stale responder candidate expires while established session survives`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
val established = aliceManager.getSession(bob.peerID)
// Bob comes back and starts a replacement handshake: alice keeps the established
// session and parks the new handshake as a responder candidate.
val replacementManager = manager(bob)
val message1 = replacementManager.initiateHandshake(alice.peerID)!!
assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1))
assertSame(established, aliceManager.getSession(bob.peerID))
// Bob never finishes (message 2 lost): the candidate must expire without touching
// the working session.
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
assertSame(established, aliceManager.getSession(bob.peerID))
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
val plaintext = "unharmed".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
}
private fun completeHandshake(
initiator: NoiseSessionManager,
initiatorPeerID: String,
responder: NoiseSessionManager,
responderPeerID: String
) {
val message1 = initiator.initiateHandshake(responderPeerID)!!
val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!!
val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!!
assertNull(responder.processHandshakeMessage(initiatorPeerID, message3))
}
private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager(
localStaticPrivateKey = identity.privateKey,
localStaticPublicKey = identity.publicKey,
localPeerID = identity.peerID
).also { managers += it }
private fun identity(): TestIdentity {
val dh = Noise.createDH("25519")
return try {
dh.generateKeyPair()
val privateKey = ByteArray(32)
val publicKey = ByteArray(32)
dh.getPrivateKey(privateKey, 0)
dh.getPublicKey(publicKey, 0)
TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
} finally {
dh.destroy()
}
}
}

View File

@ -66,7 +66,7 @@ class NostrDirectMessageHandlerTest {
)
val seenStore = mock<SeenMessageStore>()
whenever(seenStore.hasDelivered(any())).thenReturn(true)
whenever(seenStore.hasRead(any())).thenReturn(false)
whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false)
val handler = NostrDirectMessageHandler(
application = application,
state = state,

View File

@ -4,8 +4,10 @@ import android.os.Build
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -33,6 +35,11 @@ class TransportBridgeServiceTest {
override fun send(packet: RoutedPacket) {
captured = packet
}
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
captured = packet
return true
}
}
)
val packet = BitchatPacket(
@ -65,4 +72,41 @@ class TransportBridgeServiceTest {
assertEquals(original.type, actual.type)
}
}
@Test
fun `rejected bridge send remains eligible after transport reconnects`() = runTest {
var transportConnected = false
var attempts = 0
TransportBridgeService.register(
targetId,
object : TransportBridgeService.TransportLayer {
override fun send(packet: RoutedPacket) = Unit
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
attempts += 1
return transportConnected
}
}
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = ByteArray(8) { 1 },
recipientID = ByteArray(8) { 2 },
timestamp = System.nanoTime().toULong(),
payload = byteArrayOf(3, 4, 5),
signature = ByteArray(64) { 6 },
ttl = 7u
)
val sourceId = "source-${UUID.randomUUID()}"
assertFalse(
TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
)
transportConnected = true
assertTrue(
TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
)
assertEquals(2, attempts)
}
}

View File

@ -1,8 +1,10 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
@ -141,4 +143,37 @@ class AppStateStoreTest {
assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID])
}
@Test
fun `background receipt status persists and cannot be downgraded`() {
val message = BitchatMessage(
id = "outgoing-message",
sender = "bob",
content = "hello",
timestamp = Date(1),
isPrivate = true,
deliveryStatus = DeliveryStatus.Sending
)
AppStateStore.addPrivateMessage("peer-a", message)
AppStateStore.updatePrivateMessageStatus(
message.id,
DeliveryStatus.Delivered("peer-a", Date(2))
)
AppStateStore.updatePrivateMessageStatus(
message.id,
DeliveryStatus.Read("peer-a", Date(3))
)
AppStateStore.updatePrivateMessageStatus(
message.id,
DeliveryStatus.Delivered("peer-a", Date(4))
)
val status = AppStateStore.privateMessages.value
.values
.flatten()
.single()
.deliveryStatus
assertTrue(status is DeliveryStatus.Read)
}
}

View File

@ -0,0 +1,59 @@
package com.bitchat.android.services
import android.content.Context
import android.os.Build
import com.bitchat.android.identity.SecureIdentityStateManager
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class ContactDirectoryTest {
private lateinit var identityManager: SecureIdentityStateManager
@Before
fun setup() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences(
"contact-directory-test-${UUID.randomUUID()}",
Context.MODE_PRIVATE
)
identityManager = SecureIdentityStateManager(prefs, testOnly = true)
ContactDirectory.initialize(context) { null }
ContactDirectory.identityManagerProvider = { identityManager }
}
@After
fun tearDown() {
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
}
@Test
fun `offline contact resolves display name from cached fingerprint nickname`() {
val fingerprint = "ab".repeat(32)
identityManager.cacheFingerprintNickname(fingerprint, "Alice")
val resolution = ContactDirectory.resolve("contact_$fingerprint")
assertEquals("Alice", resolution.displayName)
assertNull(resolution.meshPeerID)
}
@Test
fun `offline contact without cached nickname has no display name`() {
val fingerprint = "cd".repeat(32)
val resolution = ContactDirectory.resolve("contact_$fingerprint")
assertNull(resolution.displayName)
}
}

View File

@ -0,0 +1,217 @@
package com.bitchat.android.services
import android.content.Context
import android.os.Build
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PeerInfo
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class MessageRouterTest {
private val myPeerID = "1111222233334444"
private val peerID = "aaaabbbbccccdddd"
private val noiseKey = ByteArray(32) { 0x0B }
private lateinit var mesh: MeshService
private lateinit var router: MessageRouter
private var fakeTime = 1_000_000L
private val expired = mutableListOf<String>()
@Before
fun setup() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences(
"message-router-test-${UUID.randomUUID()}",
Context.MODE_PRIVATE
)
val identityManager = SecureIdentityStateManager(prefs, testOnly = true)
ContactDirectory.identityManagerProvider = { identityManager }
mesh = mock()
whenever(mesh.myPeerID).thenReturn(myPeerID)
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer"))
ContactDirectory.initialize(context) { mesh }
MessageRouter.disableSchedulerForTesting = true
MessageRouter.resetForTesting()
fakeTime = 1_000_000L
expired.clear()
router = MessageRouter.getInstance(context, mesh)
router.clock = { fakeTime }
router.onMessageExpired = { expired.add(it) }
}
@After
fun tearDown() {
MessageRouter.resetForTesting()
MessageRouter.disableSchedulerForTesting = false
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
}
@Test
fun `queued message flushes after peer returns and session establishes`() {
peerOffline()
val result = router.sendPrivate("hello", peerID, "peer", "msg-1")
assertEquals(MessageRouter.RouteResult.QUEUED, result)
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
verify(mesh, never()).initiateNoiseHandshake(any())
// Peer reappears without a session: handshake kicked immediately
peerConnectedNoSession()
router.onPeersUpdated(listOf(peerID))
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
// Session established: queued message is sent
peerReady()
router.onSessionEstablished(peerID)
verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-1")
}
@Test
fun `scheduler retries handshake with capped backoff`() {
peerConnectedNoSession()
val result = router.sendPrivate("hello", peerID, "peer", "msg-1")
assertEquals(MessageRouter.RouteResult.QUEUED, result)
verify(mesh, times(1)).initiateNoiseHandshake(peerID) // immediate kick at enqueue
clearInvocations(mesh)
router.tickOutbox() // backoff (5s) not yet elapsed
verify(mesh, never()).initiateNoiseHandshake(any())
fakeTime += 6_000
router.tickOutbox() // attempt 2, next in 15s
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
fakeTime += 7_000
router.tickOutbox() // too early
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
fakeTime += 9_000
router.tickOutbox() // attempt 3, next in 30s
verify(mesh, times(2)).initiateNoiseHandshake(peerID)
fakeTime += 31_000
router.tickOutbox() // attempt 4, next in 60s
verify(mesh, times(3)).initiateNoiseHandshake(peerID)
fakeTime += 61_000
router.tickOutbox() // attempt 5, capped at 60s
verify(mesh, times(4)).initiateNoiseHandshake(peerID)
}
@Test
fun `expired entries are dropped and reported`() {
peerOffline()
router.sendPrivate("old message", peerID, "peer", "msg-old")
fakeTime += 86_400_001L
router.tickOutbox()
assertEquals(listOf("msg-old"), expired)
// Nothing left to flush even when the peer becomes reachable
peerReady()
router.tickOutbox()
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
}
@Test
fun `outbox cap evicts oldest and preserves order`() {
peerOffline()
repeat(101) { i ->
router.sendPrivate("content-$i", peerID, "peer", "msg-$i")
}
assertEquals(listOf("msg-0"), expired)
peerReady()
router.onSessionEstablished(peerID)
verify(mesh, times(100)).sendPrivateMessage(any(), eq(peerID), any(), any())
verify(mesh, times(1)).sendPrivateMessage("content-1", peerID, "peer", "msg-1")
verify(mesh, times(1)).sendPrivateMessage("content-100", peerID, "peer", "msg-100")
verify(mesh, never()).sendPrivateMessage(eq("content-0"), any(), any(), anyOrNull())
}
@Test
fun `peer reappearance without pending messages does not kick handshake`() {
peerConnectedNoSession()
router.onPeersUpdated(listOf(peerID))
verify(mesh, never()).initiateNoiseHandshake(any())
}
@Test
fun `established session flushes directly without handshake retry state`() {
peerReady()
val result = router.sendPrivate("direct", peerID, "peer", "msg-direct")
assertEquals(MessageRouter.RouteResult.MESH, result)
verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct")
verify(mesh, never()).initiateNoiseHandshake(any())
}
@Test
fun `scheduler stops with the mesh service and restarts on rebind`() {
MessageRouter.disableSchedulerForTesting = false
MessageRouter.resetForTesting()
val context = RuntimeEnvironment.getApplication()
val running = MessageRouter.getInstance(context, mesh)
assertTrue(running.isSchedulerRunning)
running.stopOutboxScheduler()
assertFalse(running.isSchedulerRunning)
val rebound = MessageRouter.getInstance(context, mesh)
assertTrue(rebound.isSchedulerRunning)
}
private fun peerOffline() {
whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = false))
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false)
}
private fun peerConnectedNoSession() {
whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true))
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false)
}
private fun peerReady() {
whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true))
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true)
}
private fun peerInfo(isConnected: Boolean) = PeerInfo(
id = peerID,
nickname = "peer",
isConnected = isConnected,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = ByteArray(32) { 0x0A },
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
}

View File

@ -287,6 +287,43 @@ class MediaSendingManagerMigrationTest {
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
}
@Test
fun `oversized file failure is posted to the private conversation and nothing is sent`() {
val bigFile = kotlin.io.path.createTempFile("oversized-private", ".jpg").toFile()
try {
bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
manager.sendImageNote(peerID, null, bigFile.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("too large"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
} finally {
bigFile.delete()
}
}
@Test
fun `oversized file failure is posted to the channel and nothing is sent`() {
val bigFile = kotlin.io.path.createTempFile("oversized-channel", ".jpg").toFile()
try {
bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
manager.sendImageNote(null, "#test", bigFile.absolutePath)
val channelMessages = state.getChannelMessagesValue()["#test"].orEmpty()
assertEquals(1, channelMessages.size)
assertTrue(channelMessages.single().content.contains("too large"))
assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
} finally {
bigFile.delete()
}
}
@Test
fun `cancelled consent cannot later send or echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))

View File

@ -1,6 +1,7 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PeerInfo
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -14,8 +15,10 @@ import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.util.Date
import java.util.concurrent.atomic.AtomicInteger
@ -29,6 +32,7 @@ class MeshDelegateHandlerStateContractTest {
private lateinit var mesh: MeshService
private lateinit var handler: MeshDelegateHandler
private lateinit var haptics: AtomicInteger
private lateinit var locallyReadMessageIDs: MutableList<String>
@Before
fun setUp() {
@ -41,6 +45,7 @@ class MeshDelegateHandlerStateContractTest {
notifications = mock()
mesh = mock()
haptics = AtomicInteger()
locallyReadMessageIDs = mutableListOf()
handler = MeshDelegateHandler(
state = state,
messageManager = messages,
@ -50,7 +55,8 @@ class MeshDelegateHandlerStateContractTest {
coroutineScope = scope,
onHapticFeedback = { haptics.incrementAndGet() },
getMyPeerID = { "self" },
getMeshService = { mesh }
getMeshService = { mesh },
markMessageReadLocally = locallyReadMessageIDs::add
)
}
@ -89,6 +95,60 @@ class MeshDelegateHandlerStateContractTest {
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
}
@Test
fun `focused private message schedules receipt and records local read independently`() {
val peerID = "1122334455667788"
val incoming = BitchatMessage(
id = "focused-private-message",
sender = "alice",
content = "hello",
timestamp = Date(1),
isPrivate = true,
senderPeerID = peerID
)
whenever(notifications.getAppBackgroundState()).thenReturn(false)
whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
whenever(mesh.getPeerInfo(peerID)).thenReturn(
PeerInfo(
id = peerID,
nickname = "alice",
isConnected = true,
isDirectConnection = true,
noisePublicKey = ByteArray(32) { 1 },
signingPublicKey = null,
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
)
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true)
handler.didReceiveMessage(incoming)
verify(mesh).sendReadReceipt(incoming.id, peerID, "Résumé")
assertEquals(listOf(incoming.id), locallyReadMessageIDs)
}
@Test
fun `focused private message remains locally read when transport is disconnected`() {
val peerID = "1122334455667788"
val incoming = BitchatMessage(
id = "focused-private-message-offline",
sender = "alice",
content = "hello",
timestamp = Date(1),
isPrivate = true,
senderPeerID = peerID
)
whenever(notifications.getAppBackgroundState()).thenReturn(false)
whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
whenever(mesh.getPeerInfo(peerID)).thenReturn(null)
handler.didReceiveMessage(incoming)
verify(mesh, never()).sendReadReceipt(any(), any(), any())
assertEquals(listOf(incoming.id), locallyReadMessageIDs)
}
@Test
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
val incoming = message(

View File

@ -15,6 +15,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
@ -133,4 +134,101 @@ class PrivateChatManagerTest {
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
}
@Test
fun `opening canonical unread conversation clears all source aliases`() {
val canonicalID = "contact_alice"
val nostrAlias = "nostr_0123456789abcdef"
val meshAlias = "0123456789abcdef"
val unrelatedConversation = "other-contact"
val meshService = mock<MeshService>()
state.setUnreadPrivateMessages(
setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation)
)
manager.startPrivateChat(
peerID = canonicalID,
meshService = meshService,
unreadAliases = setOf(canonicalID, nostrAlias, meshAlias)
)
assertEquals(
setOf(unrelatedConversation),
state.getUnreadPrivateMessagesValue()
)
}
@Test
fun `opening chat skips messages whose receipt send already completed`() {
val noiseKey = ByteArray(32) { 8 }
val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey)
val oldMessage = BitchatMessage(
id = "already-read",
sender = "alice",
content = "old",
timestamp = Date(1),
isPrivate = true,
senderPeerID = meshPeerID
)
val unreadMessage = oldMessage.copy(
id = "still-unread",
content = "new",
timestamp = Date(2)
)
val meshService = mock<MeshService>()
manager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = DataManager(RuntimeEnvironment.getApplication()),
noiseSessionDelegate = mock(),
hasReadReceiptBeenSent = { it == oldMessage.id }
)
state.setNickname("bob")
state.setPrivateChats(mapOf(conversationID to listOf(oldMessage, unreadMessage)))
whenever(meshService.getPeerInfo(meshPeerID)).thenReturn(
PeerInfo(
id = meshPeerID,
nickname = "alice",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = null,
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
)
whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true)
manager.sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
verify(meshService, never()).sendReadReceipt(oldMessage.id, meshPeerID, "bob")
verify(meshService).sendReadReceipt(unreadMessage.id, meshPeerID, "bob")
}
@Test
fun `canonical conversation send does not require resolved nickname`() {
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 })
var callbackInvoked = false
manager.sendPrivateMessage(
content = "hello",
peerID = conversationID,
recipientNickname = null,
senderNickname = "bob",
myPeerID = "self"
) { content, recipientID, nickname, _ ->
callbackInvoked = true
assertEquals("hello", content)
assertEquals(conversationID, recipientID)
assertEquals("", nickname)
}
assertTrue(callbackInvoked)
assertEquals(
"hello",
state.getPrivateChatsValue()[conversationID]?.single()?.content
)
}
}

View File

@ -0,0 +1,137 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Date
class UnreadConversationSummaryTest {
@Test
fun `unread conversations survive missing presence and sort by latest unread`() {
val older = incoming(
id = "older",
sender = "alice",
timestamp = 100
)
val newer = incoming(
id = "newer",
sender = "bob",
timestamp = 200
)
val rows = buildUnreadConversationSummaries(
unreadConversationIDs = setOf("alice-peer", "bob-peer"),
privateChats = mapOf(
"alice-peer" to listOf(older),
"bob-peer" to listOf(newer)
),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { false }
)
assertEquals(listOf("bob-peer", "alice-peer"), rows.map { it.conversationID })
assertEquals(listOf("bob", "alice"), rows.map { it.displayName })
}
@Test
fun `canonical aliases produce one unread conversation row`() {
val message = incoming(
id = "message",
sender = "alice",
timestamp = 100
)
val rows = buildUnreadConversationSummaries(
unreadConversationIDs = setOf("mesh-alias", "nostr_alias"),
privateChats = mapOf(
"mesh-alias" to listOf(message),
"nostr_alias" to listOf(message)
),
currentUserIdentifiers = setOf("me"),
canonicalize = { "contact_alice" },
isMessageRead = { false }
)
assertEquals(1, rows.size)
assertEquals("contact_alice", rows.single().conversationID)
assertEquals(DirectMessageTransport.NOSTR, rows.single().transport)
assertEquals(
setOf("mesh-alias", "nostr_alias", "contact_alice"),
rows.single().identityAliases
)
}
@Test
fun `only unseen incoming messages contribute to unread count`() {
val read = incoming(
id = "read",
sender = "alice",
timestamp = 100
)
val unread = incoming(
id = "unread",
sender = "alice",
timestamp = 200
)
val outgoing = incoming(
id = "outgoing",
sender = "me",
timestamp = 300
)
val row = buildUnreadConversationSummaries(
unreadConversationIDs = setOf("alice-peer"),
privateChats = mapOf("alice-peer" to listOf(read, unread, outgoing)),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { it.id == "read" }
).single()
assertEquals(1, row.unreadCount)
assertEquals(200, row.latestMessageAt)
}
@Test
fun `unread key without hydrated messages still produces a row`() {
val row = buildUnreadConversationSummaries(
unreadConversationIDs = setOf("orphan-peer"),
privateChats = emptyMap(),
currentUserIdentifiers = setOf("me"),
canonicalize = { it },
isMessageRead = { false }
).single()
assertEquals("orphan-peer", row.conversationID)
assertEquals(1, row.unreadCount)
assertTrue(row.displayName.isNotBlank())
}
@Test
fun `canonical unread lookup returns every matching source alias`() {
val aliases = matchingUnreadAliases(
unreadConversationIDs = setOf("mesh-alias", "nostr_alias", "other-contact"),
canonicalConversationID = "contact_alice",
canonicalize = { unreadID ->
if (unreadID == "other-contact") unreadID else "contact_alice"
}
)
assertEquals(
setOf("mesh-alias", "nostr_alias", "contact_alice"),
aliases
)
}
private fun incoming(
id: String,
sender: String,
timestamp: Long
) = BitchatMessage(
id = id,
sender = sender,
content = "hello",
timestamp = Date(timestamp)
)
}

View File

@ -1,93 +0,0 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedIngressLinkPolicyTest {
@Test
fun `promotion claim must match the challenged relay and link`() {
val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link")
assertTrue(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "different-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
expected = null,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
}
@Test
fun `authentication promotes only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "victim-link",
authenticatedRelayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be promoted`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "missing-link",
authenticatedRelayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "completed-link",
authenticatedRelayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "completed-link",
authenticatedRelayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class IngressLinkPolicyTest {
@Test
fun `observation resolves only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = IngressLinkPolicy.resolve(
ingressLinkID = "victim-link",
relayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be observed`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to IngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "missing-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}

View File

@ -17,21 +17,21 @@ import java.net.Socket
class WifiAwareConnectionTrackerTest {
@Test
fun `compare and rebind rejects stale authenticated socket after replacement`() {
fun `compare and rebind rejects stale observed socket after replacement`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()
)
val authenticatedSocket = syncedSocket()
val observedSocket = syncedSocket()
val replacementSocket = syncedSocket()
tracker.onClientConnected("provisional", authenticatedSocket)
tracker.onClientConnected("provisional", observedSocket)
tracker.onClientConnected("provisional", replacementSocket)
assertFalse(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
expectedSocket = authenticatedSocket
expectedSocket = observedSocket
)
)
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
@ -49,7 +49,7 @@ class WifiAwareConnectionTrackerTest {
}
@Test
fun `authenticated provisional socket cannot displace existing canonical socket`() {
fun `observed provisional socket cannot displace existing canonical socket`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()

View File

@ -107,6 +107,28 @@ source-route metadata. It does not imply multi-gigabyte mesh transfer support.
transport threshold; the data portion is at most 469 bytes and becomes
smaller when recipient or source-route overhead is present.
#### Compressed expansion rollout gate (resolved)
Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer
message type. It also requires a non-empty compressed body, an exact declared output size, and a
complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is
attacker-controlled before packet signature verification, and the current receive pipeline must
inflate before it can perform that verification.
New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode
any payload above the receiver ceiling; exceeding the cap surfaces a user-visible error in chat.
Support for legacy >10 MiB compressed transfers is explicitly ended: legacy Android senders can
still produce a highly compressible public file between 10 MiB and their 50 MiB UI limit that the
bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type check: inflation
allocates the declared payload and
`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak
for a maximum-size transfer.
Before enabling that legacy range, receive processing needs an authenticated admission decision
made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or
another ownership-preserving design that avoids the second full-size copy). The sender limit and a
wire capability/version transition must then be coordinated so old and new clients fail predictably.
### 1.3 File Transfer TLV payload (BitchatFilePacket)
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.