mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
commit
c54fdcd9fb
178
CHANGELOG.md
178
CHANGELOG.md
@ -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
|
||||
@ -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.
|
||||
@ -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
328
README.md
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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) { }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
@ -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) }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
@ -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)))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>()
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user