Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet

This commit is contained in:
Dev 2026-07-28 00:44:27 +03:00
commit 2d7c1feafe
10 changed files with 507 additions and 624 deletions

View File

@ -1,178 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [1.4.0] - 2025-10-15
### Fixed
- fix: Resolve debug settings bottom sheet crash on some devices (Issue #472)
- Fixed IllegalFormatConversionException in DebugSettingsSheet.kt when scrolling through debug settings
- Corrected string formatting for debug_target_fpr_fmt and debug_derived_p_fmt string resources
- Improved string resource parameter handling for numeric values
## [0.7.2] - 2025-07-20
### Fixed
- fix: battery optimization screen content scrollable with fixed buttons
## [0.7.1] - 2025-07-19
### Added
- feat(battery): add battery optimization management for background reliability
### Fixed
- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered
- fix: update sidebar text to use string resources
- fix(chat): cursor location and enhance message input with slash command styling
### Changed
- refactor: remove context attribute at ChatViewModel.kt
- Refactor: Migrate MainViewModel to use StateFlow
### Improved
- Use HorizontalDivider instead of deprecated Divider
- Use contentPadding instead of padding so items remain fully visible
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7]
### Added
- Location services check during app startup with educational UI
- Message text selection functionality in chat interface
- Enhanced RSSI tracking and unread message indicators
- Major Bluetooth connection architecture refactoring with dedicated managers
### Fixed
- **Critical**: Android-iOS message fragmentation compatibility issues
- Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging
- Ensures Android can properly communicate with iOS devices
- DirectMessage notifications and text copying functionality
- Smart routing optimizations (no relay loops, targeted delivery)
- Build system compilation issues and null pointer exceptions
### Changed
- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01)
- Optimized BLE scan intervals for better battery performance
- Reduced excessive logging output
### Improved
- Cross-platform compatibility with iOS and Rust implementations
- Connection stability through architectural improvements
- Battery performance via scan duty cycling
- User onboarding with location services education
## [0.6]
### Added
- Channel password management with `/pass` command for channel owners
- Monochrome/themed launcher icon for Android 12+ dynamic theming support
- Unit tests package with initial testing infrastructure
- Production build optimization with code minification and shrinking
- Native back gesture/button handling for all app views
### Fixed
- Favorite peer functionality completely restored and improved
- Enhanced favorite system with fallback mechanism for peers without key exchange
- Fixed UI state updates for favorite stars in both header and sidebar
- Improved favorite persistence across app sessions
- `/w` command now displays user nicknames instead of peer IDs
- Button styling and layout improvements across the app
- Enhanced back button positioning and styling
- Improved private chat and channel header button layouts
- Fixed button padding and alignment issues
- Color scheme consistency updates
- Updated orange color throughout the app to match iOS version
- Consistent color usage for private messages and UI elements
- App startup reliability improvements
- Better initialization sequence handling
- Fixed null pointer exceptions during startup
- Enhanced error handling and logging
- Input field styling and behavior improvements
- Sidebar user interaction enhancements
- Permission explanation screen layout fixes with proper vertical padding
### Changed
- Updated GitHub organization references in project files
- Improved README documentation with updated clone URLs
- Enhanced logging throughout the application for better debugging
## [0.5.1] - 2025-07-10
### Added
- Bluetooth startup check with user prompt to enable Bluetooth if disabled
### Fixed
- Improved Bluetooth initialization reliability on first app launch
## [0.5] - 2025-07-10
### Added
- New user onboarding screen with permission explanations
- Educational content explaining why each permission is required
- Privacy assurance messaging (no tracking, no servers, local-only data)
### Fixed
- Comprehensive permission validation - ensures all required permissions are granted
- Proper Bluetooth stack initialization on first app load
- Eliminated need for manual app restart after installation
- Enhanced permission request coordination and error handling
### Changed
- Improved first-time user experience with guided setup flow
## [0.4] - 2025-07-10
### Added
- Push notifications for direct messages
- Enhanced notification system with proper click handling and grouping
### Improved
- Direct message (DM) view with better user interface
- Enhanced private messaging experience
### Known Issues
- Favorite peer functionality currently broken
## [0.3] - 2025-07-09
### Added
- Battery-aware scanning policies for improved power management
- Dynamic scan behavior based on device battery state
### Fixed
- Android-to-Android Bluetooth Low Energy connections
- Peer discovery reliability between Android devices
- Connection stability improvements
## [0.2] - 2025-07-09
### Added
- Initial Android implementation of bitchat protocol
- Bluetooth Low Energy mesh networking
- End-to-end encryption for private messages
- Channel-based messaging with password protection
- Store-and-forward message delivery
- IRC-style commands (/msg, /join, /clear, etc.)
- RSSI-based signal quality indicators
### Fixed
- Various Bluetooth handling improvements
- User interface refinements
- Connection reliability enhancements
## [0.1] - 2025-07-08
### Added
- Initial release of bitchat Android client
- Basic mesh networking functionality
- Core messaging features
- Protocol compatibility with iOS bitchat client
[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD
[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1
[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5
[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4
[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3
[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2
[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1

View File

@ -1 +0,0 @@
I, callebtc, creator of bitchat for android, owner of the copyright claims, and owner of the official bitchat android repository (https://github.com/permissionlesstech/bitchat-android), hereby authorize Verse Communication PBC to publish bitchat (com.bitchat.doid) on the Google Play Store.

View File

@ -1,156 +0,0 @@
# bitchat Privacy Policy
*Last updated: January 2025*
## Our Commitment
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
## Summary
**WE DO NOT COLLECT ANY INFORMATION.**
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
- **Hybrid Functionality** - bitchat offers two modes of communication:
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
## What Information bitchat Stores
### On Your Device Only
1. **Identity Key**
- A cryptographic key generated on first launch
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Never leaves your device
2. **Nickname**
- The display name you choose (or auto-generated)
- Stored only on your device
- Shared with peers you communicate with
3. **Message History** (if enabled)
- When room owners enable retention, messages are saved locally
- Stored encrypted on your device
- You can delete this at any time
4. **Favorite Peers**
- Public keys of peers you mark as favorites
- Stored only on your device
- Allows you to recognize these peers in future sessions
### Temporary Session Data
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
## What Information is Shared
### With Other bitchat Users
When you use bitchat, nearby peers can see:
- Your chosen nickname
- Your ephemeral public key (changes each session)
- Messages you send to public rooms or directly to them
- Your approximate Bluetooth signal strength (for connection quality)
### With Room Members
When you join a password-protected room:
- Your messages are visible to others with the password
- Your nickname appears in the member list
- Room owners can see you've joined
## What We DON'T Do
bitchat **never**:
- Collects personal information
- Collects location history
- Transmits any data to us (the developers)
- Stores data on servers
- Shares data with third parties
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
## Encryption
All private messages use end-to-end encryption:
- **X25519** for key exchange
- **AES-256-GCM** for message encryption
- **Ed25519** for digital signatures
- **Argon2id** for password-protected rooms
## Your Rights
You have complete control:
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
- **Leave Anytime**: Close the app and your presence disappears
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
## Location Data & Permissions
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
### 1. Bluetooth Low Energy (BLE) Scanning
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
### 2. Geohash Chat Functionality
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
- **Privacy protection:**
- Your precise GPS coordinates are **never** sent to any server or peer.
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
## Children's Privacy
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
## Data Retention
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No data transmitted to servers (there are none)
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
## Changes to This Policy
If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can affect data (since we don't collect any)
## Contact
bitchat is an open source project. For privacy questions:
- Review our code: https://github.com/yourusername/bitchat
- Open an issue on GitHub
- Join the discussion in public rooms
## Philosophy
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
---
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*

328
README.md
View File

@ -1,308 +1,86 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/188c42f8-d249-4a72-b27a-e2b4f10a00a8" alt="Bitchat Android Logo" width="480">
</p>
<img width="256" height="256" alt="icon_128x128@2x" src="https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47" />
> [!WARNING]
> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress.
## bitchat for Android
# bitchat for Android
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers.
A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required for mesh chats, no servers, no phone numbers - just pure encrypted communication. Bitchat also supports geohash channels, which use an internet connection to connect you with others in your geographic area.
This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication.
This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication.
[bitchat.free](http://bitchat.free)
## Install bitchat
You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
Or you can:
[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases)
[<img alt="Get it on Google Play" height="60" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/>](https://play.google.com/store/apps/details?id=com.bitchat.droid)
**Instructions:**
1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it.
2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**.
3. **Install:** Open the downloaded `.apk` file to begin the installation.
## License
This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details.
## Features
- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat
- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection
- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect
- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners
- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data
- **✅ Modern Android UI**: Jetpack Compose with Material Design 3
- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version
- **✅ Battery Optimization**: Adaptive scanning and power management
## Android Setup
### Prerequisites
- **Android Studio**: Arctic Fox (2020.3.1) or newer
- **Android SDK**: API level 26 (Android 8.0) or higher
- **Kotlin**: 1.8.0 or newer
- **Gradle**: 7.0 or newer
### Build Instructions
1. **Clone the repository:**
```bash
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
```
2. **Open in Android Studio:**
```bash
# Open Android Studio and select "Open an Existing Project"
# Navigate to the bitchat-android directory
```
3. **Build the project:**
```bash
./gradlew build
```
4. **Install on device:**
```bash
./gradlew installDebug
```
### Development Build
For development builds with debugging enabled:
```bash
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
### Release Build
For production releases:
```bash
./gradlew assembleRelease
```
## Android-Specific Requirements
### Permissions
The app requires the following permissions (automatically requested):
- **Bluetooth**: Core BLE functionality
- **Location**: Required for BLE scanning on Android
- **Network**: Expand your mesh through public internet relays
- **Notifications**: Message alerts and background updates
### Hardware Requirements
- **Bluetooth LE (BLE)**: Required for mesh networking
- **Android 8.0+**: API level 26 minimum
- **RAM**: 2GB recommended for optimal performance
## Usage
### Basic Commands
- `/j #channel` - Join or create a channel
- `/m @name message` - Send a private message
- `/w` - List online users
- `/channels` - Show all discovered channels
- `/block @name` - Block a peer from messaging you
- `/block` - List all blocked peers
- `/unblock @name` - Unblock a peer
- `/clear` - Clear chat messages
- `/pass [password]` - Set/change channel password (owner only)
- `/transfer @name` - Transfer channel ownership
- `/save` - Toggle message retention for channel (owner only)
### Getting Started
1. **Install the app** on your Android device (requires Android 8.0+)
2. **Grant permissions** for Bluetooth and location when prompted
3. **Launch bitchat** - it will auto-start mesh networking
4. **Set your nickname** or use the auto-generated one
5. **Connect automatically** to nearby iOS and Android bitchat users
6. **Join a channel** with `/j #general` or start chatting in public
7. **Messages relay** through the mesh network to reach distant peers
### Android UI Features
- **Jetpack Compose UI**: Modern Material Design 3 interface
- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS
- **Haptic Feedback**: Vibrations for interactions and notifications
- **Adaptive Layout**: Optimized for various Android screen sizes
- **Message Status**: Real-time delivery and read receipts
- **RSSI Indicators**: Signal strength colors for each peer
### Channel Features
- **Password Protection**: Channel owners can set passwords with `/pass`
- **Message Retention**: Owners can enable mandatory message saving with `/save`
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
- **Ownership Transfer**: Pass control to trusted users with `/transfer`
## Security & Privacy
### Encryption
- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
- **Channel Messages**: Argon2id password derivation + AES-256-GCM
- **Digital Signatures**: Ed25519 for message authenticity
- **Forward Secrecy**: New key pairs generated each session
### Privacy Features
- **No Registration**: No accounts, emails, or phone numbers required
- **Ephemeral by Default**: Messages exist only in device memory
- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis
- **Emergency Wipe**: Triple-tap logo to instantly clear all data
- **Bundled Tor Support**: Built-in Tor network integration for enhanced privacy when internet connectivity is available
## Performance & Efficiency
### Message Compression
- **LZ4 Compression**: Automatic compression for messages >100 bytes
- **30-70% bandwidth savings** on typical text messages
- **Smart compression**: Skips already-compressed data
### Battery Optimization
- **Adaptive Power Modes**: Automatically adjusts based on battery level
- Performance mode: Full features when charging or >60% battery
- Balanced mode: Default operation (30-60% battery)
- Power saver: Reduced scanning when <30% battery
- Ultra-low power: Emergency mode when <10% battery
- **Background efficiency**: Automatic power saving when app backgrounded
- **Configurable scanning**: Duty cycle adapts to battery state
### Network Efficiency
- **Optimized Bloom filters**: Faster duplicate detection with less memory
- **Message aggregation**: Batches small messages to reduce transmissions
- **Adaptive connection limits**: Adjusts peer connections based on power mode
- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays
- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable
- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops)
- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices
- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM)
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
- **Tor Support**: Built-in Tor (Arti) for private internet connectivity
- **Emergency Wipe**: Triple-tap to instantly clear all data
- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS
## Technical Architecture
### Binary Protocol
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
- Compact packet format with 1-byte type field
- TTL-based message routing (max 7 hops)
- Automatic fragmentation for large messages
- Message deduplication via unique IDs
### Bluetooth Mesh Network (Offline)
### Mesh Networking
- Each device acts as both client and peripheral
- Automatic peer discovery and connection management
- Store-and-forward for offline message delivery
- Adaptive duty cycling for battery optimization
- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices
- Noise Protocol sessions with forward secrecy; peer identities derived from static keys
- Compact binary packet format with fragmentation, TTL routing, and deduplication
- Adaptive duty cycling and connection limits for battery efficiency
- Foreground service keeps the mesh alive within Android background execution limits
### Android-Specific Optimizations
- **Coroutine Architecture**: Asynchronous operations for mesh networking
- **Kotlin Coroutines**: Thread-safe concurrent mesh operations
- **EncryptedSharedPreferences**: Secure storage for user settings
- **Lifecycle-Aware**: Proper handling of Android app lifecycle
- **Battery Optimization**: Foreground service and adaptive scanning
### Nostr Protocol (Internet)
## Android Technical Architecture
- Global reach via public relays, geohash-based location channels
- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable
- Ephemeral keys per geohash area
### Core Components
### Android Stack
1. **BitchatApplication.kt**: Application-level initialization and dependency injection
2. **MainActivity.kt**: Main activity handling permissions and UI hosting
3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic
4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles)
5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle
6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format
7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3
- Kotlin, Jetpack Compose (Material 3), MVVM
- Coroutines and Flow for all networking and state
- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry)
### Dependencies
## Building
- **Jetpack Compose**: Modern declarative UI
- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM)
- **Nordic BLE Library**: Reliable Bluetooth LE operations
- **Kotlin Coroutines**: Asynchronous programming
- **LZ4**: Message compression (when enabled)
- **EncryptedSharedPreferences**: Secure local storage
Requires Android Studio and the Android SDK (API 26+).
### Binary Protocol Compatibility
```bash
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
./gradlew assembleDebug
```
The Android implementation maintains 100% binary protocol compatibility with iOS:
- **Header Format**: Identical 13-byte header structure
- **Packet Types**: Same message types and routing logic
- **Encryption**: Identical cryptographic algorithms and key exchange
- **UUIDs**: Same Bluetooth service and characteristic identifiers
- **Fragmentation**: Compatible message fragmentation for large content
Install on a connected device:
## Publishing to Google Play
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
### Preparation
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
1. **Update version information:**
```kotlin
// In app/build.gradle.kts
defaultConfig {
versionCode = 2 // Increment for each release
versionName = "1.1.0" // User-visible version
}
```
## Testing
2. **Create a signed release build:**
```bash
./gradlew assembleRelease
```
```bash
# Unit tests
./gradlew test
3. **Generate app bundle (recommended for Play Store):**
```bash
./gradlew bundleRelease
```
# Lint
./gradlew lint
### Play Store Requirements
# Instrumented tests (requires a device or emulator)
./gradlew connectedAndroidTest
```
- **Target API**: Latest Android API (currently 34)
- **Privacy Policy**: Required for apps requesting sensitive permissions
- **App Permissions**: Justify Bluetooth and location usage
- **Content Rating**: Complete questionnaire for age-appropriate content
### Distribution
- **Google Play Store**: Main distribution channel
- **F-Droid**: For open-source distribution
- **Direct APK**: For testing and development
## Cross-Platform Communication
This Android port enables seamless communication with the original iOS bitchat app:
- **iPhone ↔ Android**: Full bidirectional messaging
- **Mixed Groups**: iOS and Android users in same channels
- **Feature Parity**: All commands and encryption work across platforms
- **Protocol Sync**: Identical message format and routing behavior
**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat)
## Contributing
Contributions are welcome! Key areas for enhancement:
1. **Performance**: Battery optimization and connection reliability
2. **UI/UX**: Additional Material Design 3 features
3. **Security**: Enhanced cryptographic features
4. **Testing**: Unit and integration test coverage
5. **Documentation**: API documentation and development guides
## Support & Issues
- **Bug Reports**: [Create an issue](../../issues) with device info and logs
- **Feature Requests**: [Start a discussion](https://github.com/orgs/permissionlesstech/discussions)
- **Security Issues**: Email security concerns privately
- **iOS Compatibility**: Cross-reference with [original iOS repo](https://github.com/jackjackbits/bitchat)
For iOS-specific issues, please refer to the [original iOS bitchat repository](https://github.com/jackjackbits/bitchat).
Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices.

View File

@ -142,6 +142,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,

View File

@ -156,6 +156,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }

View File

@ -6,6 +6,15 @@ import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
@ -22,9 +31,27 @@ class MessageRouter private constructor(
DROPPED
}
private data class QueuedMessage(
val content: String,
val nickname: String,
val messageID: String,
val enqueuedAtMs: Long
)
private data class ConversationRetry(
val handshakeAttempts: Int,
val nextHandshakeAttemptAtMs: Long
)
companion object {
private const val TAG = "MessageRouter"
private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS
private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER
private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
@Volatile private var INSTANCE: MessageRouter? = null
internal var disableSchedulerForTesting = false
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
@ -39,15 +66,38 @@ class MessageRouter private constructor(
}
}
}
// Always update mesh reference and sync peer ID
// Always update mesh reference and sync peer ID, and make sure the retry
// scheduler is running (it is stopped together with MeshForegroundService).
instance.mesh = mesh
instance.nostr.senderPeerID = mesh.myPeerID
instance.startOutboxScheduler()
return instance
}
internal fun resetForTesting() {
INSTANCE?.schedulerScope?.cancel()
INSTANCE = null
}
}
// Outbox: peerID -> queued (content, nickname, messageID)
private val outbox = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
// Outbox: conversationID -> queued messages, oldest first
private val outbox = ConcurrentHashMap<String, MutableList<QueuedMessage>>()
// Per-conversation handshake retry state for queued messages
private val retryState = ConcurrentHashMap<String, ConversationRetry>()
private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var schedulerJob: kotlinx.coroutines.Job? = null
// Injectable clock for tests
internal var clock: () -> Long = { System.currentTimeMillis() }
// Called with the messageID of queued messages that expired or were evicted
var onMessageExpired: ((String) -> Unit)? = null
init {
startOutboxScheduler()
}
// Listener for favorites changes to flush outbox when npub mapping appears/changes
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
@ -88,10 +138,9 @@ class MessageRouter private constructor(
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(conversationID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock()))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}")
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
return RouteResult.QUEUED
}
}
@ -139,7 +188,10 @@ class MessageRouter private constructor(
}
}
// Flush any queued messages for a specific peerID
// Flush any queued messages for a specific peerID.
// All outbox mutations happen under the router monitor so a concurrent enqueue cannot
// be lost between the empty check and the map removal.
@Synchronized
fun flushOutboxFor(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
@ -147,21 +199,23 @@ class MessageRouter private constructor(
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
val (content, nickname, messageID) = iterator.next()
val entry = iterator.next()
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(content, meshTarget, nickname, messageID)
mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
iterator.remove()
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
iterator.remove()
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID)
outbox.remove(peerID)
outbox.remove(conversationID, queued)
outbox.remove(peerID, queued)
retryState.remove(conversationID)
retryState.remove(peerID)
}
}
@ -170,6 +224,116 @@ class MessageRouter private constructor(
outbox.keys.toList().forEach { flushOutboxFor(it) }
}
@Synchronized
private fun enqueue(conversationID: String, entry: QueuedMessage) {
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
queue.add(entry)
while (queue.size > OUTBOX_MAX_PER_PEER) {
val evicted = queue.removeAt(0)
Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}")
notifyExpired(evicted.messageID)
}
}
private fun notifyExpired(messageID: String) {
try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { }
}
/**
* Initiate a Noise handshake for a conversation with queued messages, applying
* exponential backoff between attempts. [immediate] resets the backoff (peer just
* appeared or a new message was queued). Kicks are suppressed while a previous
* attempt is still inside its backoff window, so alias duplicates and frequent
* peer-list updates cannot spam handshakes.
*/
@Synchronized
private fun kickHandshake(conversationID: String, meshTarget: String, immediate: Boolean) {
val now = clock()
val current = retryState[conversationID]
if (current != null && now < current.nextHandshakeAttemptAtMs) return
val attempts = if (immediate) 0 else (current?.handshakeAttempts ?: 0)
try { mesh.initiateNoiseHandshake(meshTarget) } catch (_: Exception) { }
val backoff = HANDSHAKE_RETRY_BACKOFF_MS[attempts.coerceAtMost(HANDSHAKE_RETRY_BACKOFF_MS.size - 1)]
retryState[conversationID] = ConversationRetry(
handshakeAttempts = attempts + 1,
nextHandshakeAttemptAtMs = now + backoff
)
Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms")
}
@Synchronized
private fun startOutboxScheduler() {
if (disableSchedulerForTesting) return
if (schedulerJob?.isActive == true) return
schedulerJob = schedulerScope.launch {
while (isActive) {
delay(OUTBOX_TICK_MS)
try { tickOutbox() } catch (e: Exception) {
Log.w(TAG, "Outbox scheduler tick failed: ${e.message}")
}
}
}
}
/**
* Stop retrying while the mesh transports are down. Persistent network work must
* follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
* and rebinds the mesh reference when the service comes back.
*/
fun stopOutboxScheduler() {
schedulerJob?.cancel()
schedulerJob = null
}
internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true
/**
* One scheduler pass over the outbox: expire old entries, flush what can be sent,
* and re-initiate handshakes (with backoff) for peers that are connected but have
* no established session yet.
*/
@Synchronized
internal fun tickOutbox(nowMs: Long = clock()) {
outbox.keys.toList().forEach { conversationID ->
expireOldEntries(conversationID, nowMs)
val queued = outbox[conversationID] ?: return@forEach
if (queued.isEmpty()) return@forEach
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
if (meshTarget != null && isReady(mesh, meshTarget)) {
flushOutboxFor(conversationID)
return@forEach
}
if (canSendViaNostr(resolution.noiseKeyHex ?: conversationID)) {
flushOutboxFor(conversationID)
return@forEach
}
// Peer visible but no session: retry the handshake with backoff.
if (meshTarget != null && isConnected(mesh, meshTarget)) {
kickHandshake(conversationID, meshTarget, immediate = false)
}
}
}
private fun expireOldEntries(conversationID: String, nowMs: Long) {
val queued = outbox[conversationID] ?: return
val iterator = queued.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) {
Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}")
iterator.remove()
notifyExpired(entry.messageID)
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID, queued)
retryState.remove(conversationID)
}
}
private fun canSendViaNostr(peerID: String): Boolean {
return try {
val resolution = ContactDirectory.resolve(peerID)
@ -208,20 +372,51 @@ class MessageRouter private constructor(
// Called when mesh peer list changes; attempt to flush any matching outbox entries
fun onPeersUpdated(peers: List<String>) {
peers.forEach { pid ->
kickHandshakeIfPending(pid)
flushOutboxFor(pid)
val noiseHex = try {
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
noiseHex?.let {
kickHandshakeIfPending(it)
flushOutboxFor(it)
}
}
}
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
fun onSessionEstablished(peerID: String) {
resetRetry(peerID)
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
noiseHex?.let {
resetRetry(it)
flushOutboxFor(it)
}
}
/** Reset handshake backoff for a conversation whose session just came up. */
private fun resetRetry(peerID: String) {
retryState.remove(ContactDirectory.canonicalConversationId(peerID))
retryState.remove(peerID)
}
/**
* A peer (re)appeared: if we still owe them queued messages and there is no working
* session yet, restart the handshake immediately instead of waiting for the backoff.
*/
@Synchronized
private fun kickHandshakeIfPending(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID ?: return
if (isReady(mesh, meshTarget)) return
if (!isConnected(mesh, meshTarget)) return
Log.d(TAG, "Peer ${meshTarget.take(8)}… reappeared with ${queued.size} queued PM(s); re-initiating handshake")
kickHandshake(conversationID, meshTarget, immediate = true)
}
}

View File

@ -375,6 +375,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 ->

View File

@ -136,6 +136,13 @@ object AppConstants {
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 {
const val SEEN_MESSAGE_MAX_IDS: Int = 10_000
}

View File

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