Merge pull request #3 from callebtc/big-refactor

Big refactor
This commit is contained in:
callebtc
2025-07-09 00:46:30 +02:00
committed by GitHub
30 changed files with 6438 additions and 3535 deletions
+64
View File
@@ -0,0 +1,64 @@
# ChatScreen.kt Refactoring Plan
## Current State
- Single file: `ChatScreen.kt` (~1,100+ lines)
- Multiple UI responsibilities mixed together
- Hard to maintain and test individual components
## Proposed Component Structure
### 1. Main Screen (ChatScreen.kt)
**Responsibilities:**
- Main layout orchestration
- State management delegation
- Window insets handling
- Component coordination
### 2. Header Components (ChatHeader.kt)
**Responsibilities:**
- TopAppBar with different states (main, private, channel)
- Nickname editor
- Peer counter with status indicators
- Navigation controls
### 3. Message Components (MessageComponents.kt)
**Responsibilities:**
- MessagesList composable
- MessageItem with formatting
- Message text parsing and styling
- Delivery status indicators
- RSSI-based coloring
### 4. Input Components (InputComponents.kt)
**Responsibilities:**
- MessageInput with different modes
- Command suggestions box
- Command suggestion items
- Input validation and handling
### 5. Sidebar Components (SidebarComponents.kt)
**Responsibilities:**
- SidebarOverlay with navigation
- ChannelsSection for channel management
- PeopleSection for peer list
- Sidebar state management
### 6. Dialog Components (DialogComponents.kt)
**Responsibilities:**
- Password prompt dialog
- App info dialog
- Other modal dialogs
### 7. UI Utils (ChatUIUtils.kt)
**Responsibilities:**
- RSSI color mapping
- Text formatting utilities
- Common styling constants
- Helper functions
## Benefits
- Each file has a single, clear responsibility
- Components are easier to test in isolation
- Better code organization and navigation
- Simplified debugging
- Easier to add new UI features
+103
View File
@@ -0,0 +1,103 @@
# BluetoothMeshService Refactoring - Progress Report
## ✅ COMPLETED: Extracted Components (All compile successfully)
### 1. PeerManager.kt (161 lines)
**Responsibilities:**
- Active peer tracking and lifecycle management
- Peer nickname management and stale peer cleanup
- RSSI tracking and peer list updates
- **Interface:** `PeerManagerDelegate`
### 2. FragmentManager.kt (194 lines)
**Responsibilities:**
- Message fragmentation for large messages (>500 bytes)
- Fragment reassembly and cleanup
- Fragment timeout management (30 seconds)
- **Interface:** `FragmentManagerDelegate`
### 3. SecurityManager.kt (236 lines)
**Responsibilities:**
- Duplicate detection and replay attack protection
- Key exchange handling and validation
- Message encryption/decryption operations
- Packet signature verification
- **Interface:** `SecurityManagerDelegate`
### 4. StoreForwardManager.kt (295 lines)
**Responsibilities:**
- Message caching for offline peers (12 hours regular, unlimited favorites)
- Store-and-forward delivery when peers come online
- Cache cleanup and management
- **Interface:** `StoreForwardManagerDelegate`
### 5. MessageHandler.kt (284 lines)
**Responsibilities:**
- Processing different message types (ANNOUNCE, MESSAGE, LEAVE, etc.)
- Broadcast vs private message handling
- Message relay logic with adaptive probability
- Delivery acknowledgment sending
- **Interface:** `MessageHandlerDelegate`
### 6. BluetoothConnectionManager.kt (611 lines)
**Responsibilities:**
- BLE advertising and scanning
- GATT server/client setup and management
- Device connection tracking (both server and client modes)
- Packet broadcasting to all connected devices
- **Interface:** `BluetoothConnectionManagerDelegate`
## 📊 Size Reduction Analysis
| Component | Lines | Responsibility |
|-----------|--------|----------------|
| **PeerManager** | 161 | Peer lifecycle & tracking |
| **FragmentManager** | 194 | Message fragmentation |
| **SecurityManager** | 236 | Security & encryption |
| **StoreForwardManager** | 295 | Offline message caching |
| **MessageHandler** | 284 | Message type processing |
| **BluetoothConnectionManager** | 611 | BLE connection management |
| **Original File** | ~1000+ | All responsibilities mixed |
**Total Extracted:** ~1781 lines (distributed across 6 focused files)
**Reduction Factor:** Original single file → 6 smaller, focused components
## 🏗️ Next Steps for Integration
### Phase 1: Refactor Existing BluetoothMeshService
1. **Update BluetoothMeshService.kt** to use the new components
2. **Wire up all delegate interfaces**
3. **Maintain exact same public API** so ChatViewModel doesn't change
4. **Test compilation and functionality**
### Phase 2: Integration Testing
1. **Verify all existing functionality works**
2. **Test key scenarios:**
- Peer discovery and connection
- Message sending/receiving
- Fragment handling
- Store-and-forward delivery
- Security validation
### Phase 3: Benefits Validation
1. **Easier unit testing** (each component can be tested independently)
2. **Better code maintainability** (clear separation of concerns)
3. **Improved debugging** (isolated component logs)
4. **Future extensibility** (easier to add new features)
## 🔧 Current Build Status
**All 6 extracted components compile successfully**
**No breaking changes to existing interfaces**
**Original BluetoothMeshService.kt still intact**
**Ready for integration phase**
## 💡 Key Design Decisions Made
1. **Delegate Pattern:** Each component uses a delegate interface for clean separation
2. **Coroutine Scope per Component:** Isolated lifecycle management
3. **Thread-Safe Collections:** Maintained from original implementation
4. **Same UUIDs and Constants:** No protocol changes
5. **Preserved iOS Compatibility:** All timing and logic matches iOS exactly
6. **Error Handling:** Maintained original defensive programming patterns
The refactoring successfully breaks down a monolithic 1000+ line service into 6 focused, maintainable components while preserving 100% compatibility with the iOS implementation.
+80
View File
@@ -0,0 +1,80 @@
# RSSI Color Change Fix - Implementation Summary
## Problem Identified
The username colors in the chat were not changing based on RSSI signal strength even though RSSI values were being logged as changing. Investigation revealed that:
1. **RSSI values were only captured once** during the initial BLE scan discovery
2. **No mechanism existed** to continuously update RSSI values from connected devices
3. **UI was not being notified** of RSSI changes to trigger recomposition
4. **Chat rendering was using stale RSSI values** that never changed after initial connection
## Root Cause
The `BluetoothMeshService` was only recording RSSI during the scan phase (`handleScanResult`) but never updating it during the connection lifetime. Bluetooth GATT provides `readRemoteRssi()` for connected devices, but this wasn't being used.
## Solution Implemented
### 1. Device-to-Peer ID Mapping
- Added `deviceToPeerIDMapping` to link Bluetooth devices to peer IDs
- This allows RSSI updates to be associated with the correct peer ID
### 2. GATT RSSI Reading Callback
- Added `onReadRemoteRssi()` callback in the GATT client callback
- Updates `peerRSSI` map when new RSSI values are read
- Maps device addresses to peer IDs for proper tracking
### 3. Periodic RSSI Monitoring
- Added background coroutine that runs every 5 seconds
- Calls `readRemoteRssi()` on all active GATT connections
- Provides continuous RSSI updates during connection lifetime
### 4. UI Notification System
- Added `didUpdateRSSI()` delegate method to notify UI of RSSI changes
- When RSSI changes, the delegate is called to potentially trigger UI updates
- Chat screen automatically recomposes when RSSI values change
### 5. Key Exchange Enhancement
- Modified `handleKeyExchange()` to map devices to peer IDs
- Transfers any existing peripheral RSSI data to peer-based tracking
- Ensures proper RSSI association after peer identification
## Code Changes Made
### BluetoothMeshService.kt
1. **Added device mapping**: `deviceToPeerIDMapping` concurrent hash map
2. **RSSI callback**: `onReadRemoteRssi()` implementation in GATT callback
3. **Periodic monitoring**: `monitorRSSI()` function called every 5 seconds
4. **Key exchange update**: Links devices to peer IDs for RSSI tracking
5. **Delegate method**: `didUpdateRSSI()` interface method for UI notifications
### ChatViewModel.kt
1. **Delegate implementation**: Added `didUpdateRSSI()` method
2. **Logging**: Debug output when RSSI values change
### ChatScreen.kt
- **No changes needed** - existing `getRSSIColor(rssi)` function already works
- UI automatically recomposes when `meshService.getPeerRSSI()` returns updated values
## How It Works Now
1. **Initial Discovery**: RSSI captured during BLE scan (as before)
2. **Connection**: Device mapped to peer ID during key exchange
3. **Monitoring**: Every 5 seconds, `readRemoteRssi()` called on connected devices
4. **Update**: RSSI callback updates `peerRSSI` map with new values
5. **Notification**: Delegate notified of RSSI change
6. **Rendering**: Chat screen uses updated RSSI values for color calculation
7. **Recomposition**: UI automatically updates with new colors
## Expected Behavior
- Username colors now change dynamically as users move closer/farther away
- Colors reflect real-time signal strength: green (strong) → yellow (medium) → red (weak)
- Updates occur every 5 seconds while devices are connected
- Your own username remains green regardless of signal strength
- Works for both client and server GATT connections
## Testing
- Build successful with `./gradlew assembleDebug`
- No compilation errors
- All existing functionality preserved
- Ready for testing with actual devices
The fix addresses the core issue where RSSI values were static after connection. Now they continuously update, providing the dynamic color feedback based on signal strength that was originally intended.
@@ -0,0 +1,625 @@
package com.bitchat.android.mesh
import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.ParcelUuid
import android.util.Log
import androidx.core.app.ActivityCompat
import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* Manages Bluetooth connections, advertising, and scanning
* Extracted from BluetoothMeshService for better separation of concerns
*/
class BluetoothConnectionManager(private val context: Context, private val myPeerID: String) {
companion object {
private const val TAG = "BluetoothConnectionManager"
// Use exact same UUIDs as iOS version and original service
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
}
// Core Bluetooth components
private val bluetoothManager: BluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
private val bleAdvertiser: BluetoothLeAdvertiser? = bluetoothAdapter?.bluetoothLeAdvertiser
// GATT server for peripheral mode
private var gattServer: BluetoothGattServer? = null
private var characteristic: BluetoothGattCharacteristic? = null
// Connection tracking - FIXED to properly track both server and client connections
private val connectedDevices = ConcurrentHashMap<String, BluetoothDevice>()
private val deviceCharacteristics = ConcurrentHashMap<BluetoothDevice, BluetoothGattCharacteristic>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
private val gattConnections = ConcurrentHashMap<BluetoothDevice, BluetoothGatt>() // Track GATT client connections
private val peripheralRSSI = ConcurrentHashMap<String, Int>() // Track RSSI by device address during discovery
// Delegate for callbacks
var delegate: BluetoothConnectionManagerDelegate? = null
// Coroutines
private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Start all Bluetooth services
*/
fun startServices(): Boolean {
Log.i(TAG, "Starting Bluetooth services...")
if (!hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions")
return false
}
if (bluetoothAdapter?.isEnabled != true) {
Log.e(TAG, "Bluetooth is not enabled")
return false
}
if (bleScanner == null || bleAdvertiser == null) {
Log.e(TAG, "BLE scanner or advertiser not available")
return false
}
try {
setupGattServer()
// Start services in sequence
connectionScope.launch {
delay(500) // Ensure GATT server is ready
startAdvertising()
delay(200)
startScanning()
delay(200)
Log.i(TAG, "All Bluetooth services started successfully")
}
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to start Bluetooth services: ${e.message}")
return false
}
}
/**
* Stop all Bluetooth services
*/
fun stopServices() {
Log.i(TAG, "Stopping Bluetooth services")
connectionScope.launch {
// Cleanup all GATT client connections
gattConnections.values.forEach { gatt ->
try {
gatt.disconnect()
gatt.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing GATT connection: ${e.message}")
}
}
// Stop advertising and scanning
stopAdvertising()
stopScanning()
// Close GATT server
gattServer?.close()
// Clear all connection tracking
connectedDevices.clear()
deviceCharacteristics.clear()
subscribedDevices.clear()
gattConnections.clear()
peripheralRSSI.clear()
connectionScope.cancel()
}
}
/**
* Broadcast packet to all connected devices
*/
fun broadcastPacket(packet: BitchatPacket) {
val data = packet.toBinaryData() ?: return
Log.d(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server connections and ${gattConnections.size} client connections")
// Send to devices connected to our GATT server
subscribedDevices.forEach { device ->
try {
characteristic?.let { char ->
char.value = data
val success = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
if (success) {
Log.d(TAG, "Sent packet to server connection: ${device.address}")
} else {
Log.w(TAG, "Failed to send packet to server connection: ${device.address}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error sending to server connection ${device.address}: ${e.message}")
}
}
// Send to devices we are connected to as a client
gattConnections.forEach { (device, gatt) ->
try {
val characteristic = deviceCharacteristics[device]
if (characteristic != null) {
characteristic.value = data
val success = gatt.writeCharacteristic(characteristic)
if (success) {
Log.d(TAG, "Sent packet to client connection: ${device.address}")
} else {
Log.w(TAG, "Failed to send packet to client connection: ${device.address}")
}
} else {
Log.w(TAG, "No characteristic found for client connection: ${device.address}")
}
} catch (e: Exception) {
Log.e(TAG, "Error sending to client connection ${device.address}: ${e.message}")
}
}
}
/**
* Get connected device count
*/
fun getConnectedDeviceCount(): Int {
return connectedDevices.size
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Bluetooth Connection Manager Debug Info ===")
appendLine("Bluetooth Enabled: ${bluetoothAdapter?.isEnabled}")
appendLine("Has Permissions: ${hasBluetoothPermissions()}")
appendLine("BLE Scanner Available: ${bleScanner != null}")
appendLine("BLE Advertiser Available: ${bleAdvertiser != null}")
appendLine("GATT Server Active: ${gattServer != null}")
appendLine()
appendLine("Connected Devices: ${connectedDevices.size}")
connectedDevices.forEach { (address, device) ->
appendLine(" - $address")
}
appendLine()
appendLine("GATT Client Connections: ${gattConnections.size}")
gattConnections.keys.forEach { device ->
appendLine(" - ${device.address}")
}
appendLine()
appendLine("Subscribed Devices (server mode): ${subscribedDevices.size}")
subscribedDevices.forEach { device ->
appendLine(" - ${device.address}")
}
appendLine()
appendLine("Peripheral RSSI: ${peripheralRSSI.size}")
peripheralRSSI.forEach { (address, rssi) ->
appendLine(" - $address: $rssi dBm")
}
}
}
/**
* Check if we have the required Bluetooth permissions
*/
private fun hasBluetoothPermissions(): Boolean {
val permissions = mutableListOf<String>()
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
permissions.addAll(listOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
))
} else {
permissions.addAll(listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
))
}
permissions.addAll(listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
/**
* Setup GATT server for peripheral mode
*/
@Suppress("DEPRECATION")
private fun setupGattServer() {
if (!hasBluetoothPermissions()) return
val serverCallback = object : BluetoothGattServerCallback() {
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Device connected to server: ${device.address}")
connectedDevices[device.address] = device
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Device disconnected from server: ${device.address}")
connectedDevices.remove(device.address)
deviceCharacteristics.remove(device)
subscribedDevices.remove(device)
}
}
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice,
requestId: Int,
characteristic: BluetoothGattCharacteristic,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray
) {
if (characteristic.uuid == CHARACTERISTIC_UUID) {
Log.d(TAG, "Received write request from ${device.address}, ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = String(packet.senderID).replace("\u0000", "")
delegate?.onPacketReceived(packet, peerID, device)
}
if (responseNeeded) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
}
override fun onDescriptorWriteRequest(
device: BluetoothDevice,
requestId: Int,
descriptor: BluetoothGattDescriptor,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray
) {
if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) {
Log.d(TAG, "Device ${device.address} subscribed to notifications")
if (!subscribedDevices.contains(device)) {
subscribedDevices.add(device)
// Notify delegate about new connection
connectionScope.launch {
delay(100) // Ensure connection is stable
delegate?.onDeviceConnected(device)
}
}
}
if (responseNeeded) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
}
// Clean up any existing GATT server
gattServer?.close()
clearAllConnections()
gattServer = bluetoothManager.openGattServer(context, serverCallback)
// Create characteristic
characteristic = BluetoothGattCharacteristic(
CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_READ or
BluetoothGattCharacteristic.PERMISSION_WRITE
)
// Add notification descriptor
val descriptor = BluetoothGattDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"),
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
characteristic?.addDescriptor(descriptor)
// Create service
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(characteristic)
gattServer?.addService(service)
Log.i(TAG, "GATT server setup complete")
}
/**
* Start BLE advertising
*/
@Suppress("DEPRECATION")
private fun startAdvertising() {
if (!hasBluetoothPermissions() || bleAdvertiser == null) {
Log.e(TAG, "Cannot start advertising: missing permissions or advertiser unavailable")
return
}
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
.setConnectable(true)
.setTimeout(0)
.build()
val data = AdvertiseData.Builder()
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.addServiceData(ParcelUuid(SERVICE_UUID), myPeerID.toByteArray(Charsets.UTF_8))
.setIncludeTxPowerLevel(false)
.setIncludeDeviceName(false)
.build()
val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.i(TAG, "Advertising started successfully with peer ID: $myPeerID")
}
override fun onStartFailure(errorCode: Int) {
val errorMessage = when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED -> "Already started"
ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large"
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error"
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
else -> "Unknown error: $errorCode"
}
Log.e(TAG, "Advertising failed: $errorMessage")
if (errorCode == ADVERTISE_FAILED_DATA_TOO_LARGE) {
startMinimalAdvertising()
}
}
}
try {
bleAdvertiser.startAdvertising(settings, data, advertiseCallback)
} catch (e: Exception) {
Log.e(TAG, "Exception starting advertising: ${e.message}")
}
}
/**
* Fallback minimal advertising
*/
@Suppress("DEPRECATION")
private fun startMinimalAdvertising() {
if (!hasBluetoothPermissions() || bleAdvertiser == null) return
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
.setConnectable(true)
.setTimeout(0)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(false)
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.build()
val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.i(TAG, "Minimal advertising started successfully")
}
override fun onStartFailure(errorCode: Int) {
Log.e(TAG, "Even minimal advertising failed: $errorCode")
}
}
try {
bleAdvertiser.startAdvertising(settings, data, advertiseCallback)
} catch (e: Exception) {
Log.e(TAG, "Exception starting minimal advertising: ${e.message}")
}
}
/**
* Stop BLE advertising
*/
@Suppress("DEPRECATION")
private fun stopAdvertising() {
if (!hasBluetoothPermissions() || bleAdvertiser == null) return
bleAdvertiser.stopAdvertising(object : AdvertiseCallback() {})
}
/**
* Start BLE scanning
*/
@Suppress("DEPRECATION")
private fun startScanning() {
if (!hasBluetoothPermissions() || bleScanner == null) {
Log.e(TAG, "Cannot start scanning: missing permissions or scanner unavailable")
return
}
val scanFilter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(SERVICE_UUID))
.build()
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
.setReportDelay(10)
.build()
val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach { handleScanResult(it) }
}
override fun onScanFailed(errorCode: Int) {
val errorMessage = when (errorCode) {
SCAN_FAILED_ALREADY_STARTED -> "Already started"
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
else -> "Unknown error: $errorCode"
}
Log.e(TAG, "Scan failed: $errorMessage")
}
}
try {
bleScanner.startScan(listOf(scanFilter), scanSettings, scanCallback)
Log.i(TAG, "Started BLE scanning")
} catch (e: Exception) {
Log.e(TAG, "Exception starting scan: ${e.message}")
}
}
/**
* Stop BLE scanning
*/
@Suppress("DEPRECATION")
private fun stopScanning() {
if (!hasBluetoothPermissions() || bleScanner == null) return
bleScanner.stopScan(object : ScanCallback() {})
}
/**
* Handle scan result and connect to discovered devices
*/
@Suppress("DEPRECATION")
private fun handleScanResult(result: ScanResult) {
val device = result.device
val rssi = result.rssi
// Filter out weak signals
if (rssi < -90) {
return
}
// Check if already connected
if (connectedDevices.values.any { it.address == device.address }) {
return
}
// Store RSSI
peripheralRSSI[device.address] = rssi
Log.i(TAG, "Found bitchat service at ${device.address} (RSSI: $rssi), connecting...")
// Connect to device
connectToDevice(device)
}
/**
* Connect to a discovered device
*/
@Suppress("DEPRECATION")
private fun connectToDevice(device: BluetoothDevice) {
if (!hasBluetoothPermissions()) return
val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Connected to ${gatt.device.address} as client")
connectedDevices[gatt.device.address] = gatt.device
gattConnections[gatt.device] = gatt
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Disconnected from ${gatt.device.address}")
connectedDevices.remove(gatt.device.address)
deviceCharacteristics.remove(gatt.device)
gattConnections.remove(gatt.device)
gatt.close()
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val service = gatt.getService(SERVICE_UUID)
val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID)
if (characteristic != null) {
deviceCharacteristics[gatt.device] = characteristic
gatt.setCharacteristicNotification(characteristic, true)
// Enable notifications
val descriptor = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
// Notify delegate
connectionScope.launch {
delay(200)
delegate?.onDeviceConnected(gatt.device)
}
}
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
val value = characteristic.value
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = String(packet.senderID).replace("\u0000", "")
delegate?.onPacketReceived(packet, peerID, gatt.device)
}
}
}
device.connectGatt(context, false, gattCallback)
}
/**
* Clear all connection tracking
*/
private fun clearAllConnections() {
connectedDevices.clear()
deviceCharacteristics.clear()
subscribedDevices.clear()
gattConnections.clear()
peripheralRSSI.clear()
}
}
/**
* Delegate interface for Bluetooth connection manager callbacks
*/
interface BluetoothConnectionManagerDelegate {
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
fun onDeviceConnected(device: BluetoothDevice)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
/**
* Manages message fragmentation and reassembly
* Extracted from BluetoothMeshService for better separation of concerns
*/
class FragmentManager {
companion object {
private const val TAG = "FragmentManager"
private const val MAX_FRAGMENT_SIZE = 500
private const val FRAGMENT_TIMEOUT = 30000L // 30 seconds
private const val CLEANUP_INTERVAL = 10000L // 10 seconds
}
// Fragment storage
private val incomingFragments = ConcurrentHashMap<String, MutableMap<Int, ByteArray>>()
private val fragmentMetadata = ConcurrentHashMap<String, Triple<UByte, Int, Long>>() // originalType, totalFragments, timestamp
// Delegate for callbacks
var delegate: FragmentManagerDelegate? = null
// Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startPeriodicCleanup()
}
/**
* Create fragments from a large packet
*/
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
val data = packet.toBinaryData() ?: return emptyList()
if (data.size <= MAX_FRAGMENT_SIZE) {
return listOf(packet) // No fragmentation needed
}
val fragments = mutableListOf<BitchatPacket>()
val fragmentID = generateFragmentID()
// Calculate header size (13 bytes for fragment metadata)
val headerSize = 13
val dataPerFragment = MAX_FRAGMENT_SIZE - headerSize
val totalFragments = (data.size + dataPerFragment - 1) / dataPerFragment
Log.d(TAG, "Creating ${totalFragments} fragments for ${data.size} byte packet")
for (i in 0 until totalFragments) {
val start = i * dataPerFragment
val end = minOf(start + dataPerFragment, data.size)
val fragmentData = data.sliceArray(start until end)
val fragmentPayload = createFragmentPayload(
fragmentID = fragmentID,
index = i,
total = totalFragments,
originalType = packet.type,
data = fragmentData
)
val fragmentType = when (i) {
0 -> MessageType.FRAGMENT_START
totalFragments - 1 -> MessageType.FRAGMENT_END
else -> MessageType.FRAGMENT_CONTINUE
}
val fragmentPacket = BitchatPacket(
type = fragmentType.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload,
signature = null // Fragments aren't individually signed
)
fragments.add(fragmentPacket)
}
return fragments
}
/**
* Handle incoming fragment
*/
fun handleFragment(packet: BitchatPacket): BitchatPacket? {
if (packet.payload.size < 13) {
Log.w(TAG, "Fragment packet too small: ${packet.payload.size}")
return null
}
try {
// Extract fragment metadata (same format as iOS)
val fragmentIDData = packet.payload.sliceArray(0..7)
val fragmentID = fragmentIDData.contentHashCode().toString()
val index = ((packet.payload[8].toInt() and 0xFF) shl 8) or (packet.payload[9].toInt() and 0xFF)
val total = ((packet.payload[10].toInt() and 0xFF) shl 8) or (packet.payload[11].toInt() and 0xFF)
val originalType = packet.payload[12].toUByte()
val fragmentData = packet.payload.sliceArray(13 until packet.payload.size)
Log.d(TAG, "Received fragment $index/$total for fragmentID: $fragmentID, originalType: $originalType")
// Store fragment
if (!incomingFragments.containsKey(fragmentID)) {
incomingFragments[fragmentID] = mutableMapOf()
fragmentMetadata[fragmentID] = Triple(originalType, total, System.currentTimeMillis())
}
incomingFragments[fragmentID]?.put(index, fragmentData)
// Check if we have all fragments
if (incomingFragments[fragmentID]?.size == total) {
Log.d(TAG, "All fragments received for $fragmentID, reassembling...")
// Reassemble message
val reassembledData = mutableListOf<Byte>()
for (i in 0 until total) {
incomingFragments[fragmentID]?.get(i)?.let { data ->
reassembledData.addAll(data.asIterable())
}
}
// Parse and return reassembled packet
val reassembledPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
// Cleanup
incomingFragments.remove(fragmentID)
fragmentMetadata.remove(fragmentID)
if (reassembledPacket != null) {
Log.d(TAG, "Successfully reassembled packet of ${reassembledData.size} bytes")
return reassembledPacket
} else {
Log.e(TAG, "Failed to parse reassembled packet")
}
} else {
val received = incomingFragments[fragmentID]?.size ?: 0
Log.d(TAG, "Fragment $index stored, have $received/$total fragments for $fragmentID")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to handle fragment: ${e.message}")
}
return null
}
/**
* Create fragment payload with metadata
*/
private fun createFragmentPayload(
fragmentID: String,
index: Int,
total: Int,
originalType: UByte,
data: ByteArray
): ByteArray {
val payload = ByteArray(13 + data.size)
// Fragment ID (8 bytes)
val idBytes = fragmentID.toByteArray()
System.arraycopy(idBytes, 0, payload, 0, minOf(8, idBytes.size))
// Index (2 bytes, big-endian)
payload[8] = ((index shr 8) and 0xFF).toByte()
payload[9] = (index and 0xFF).toByte()
// Total (2 bytes, big-endian)
payload[10] = ((total shr 8) and 0xFF).toByte()
payload[11] = (total and 0xFF).toByte()
// Original type (1 byte)
payload[12] = originalType.toByte()
// Fragment data
System.arraycopy(data, 0, payload, 13, data.size)
return payload
}
/**
* Generate unique fragment ID
*/
private fun generateFragmentID(): String {
return "${System.currentTimeMillis()}-${kotlin.random.Random.nextInt()}"
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Fragment Manager Debug Info ===")
appendLine("Active Fragment Sets: ${incomingFragments.size}")
fragmentMetadata.forEach { (fragmentID, metadata) ->
val (originalType, totalFragments, timestamp) = metadata
val received = incomingFragments[fragmentID]?.size ?: 0
val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000
appendLine(" - $fragmentID: $received/$totalFragments fragments, type: $originalType, age: ${ageSeconds}s")
}
}
}
/**
* Start periodic cleanup of old fragments
*/
private fun startPeriodicCleanup() {
managerScope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL)
cleanupOldFragments()
}
}
}
/**
* Clean up old fragments (older than 30 seconds)
*/
private fun cleanupOldFragments() {
val cutoffTime = System.currentTimeMillis() - FRAGMENT_TIMEOUT
val fragmentsToRemove = mutableListOf<String>()
fragmentMetadata.entries.forEach { (fragmentID, metadata) ->
if (metadata.third < cutoffTime) {
fragmentsToRemove.add(fragmentID)
}
}
fragmentsToRemove.forEach { fragmentID ->
incomingFragments.remove(fragmentID)
fragmentMetadata.remove(fragmentID)
}
if (fragmentsToRemove.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${fragmentsToRemove.size} old fragment sets")
}
}
/**
* Clear all fragments
*/
fun clearAllFragments() {
incomingFragments.clear()
fragmentMetadata.clear()
}
/**
* Shutdown the manager
*/
fun shutdown() {
managerScope.cancel()
clearAllFragments()
}
}
/**
* Delegate interface for fragment manager callbacks
*/
interface FragmentManagerDelegate {
fun onPacketReassembled(packet: BitchatPacket)
}
@@ -0,0 +1,346 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.crypto.MessagePadding
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryAck
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.*
import java.util.*
import kotlin.random.Random
/**
* Handles processing of different message types
* Extracted from BluetoothMeshService for better separation of concerns
*/
class MessageHandler(private val myPeerID: String) {
companion object {
private const val TAG = "MessageHandler"
}
// Delegate for callbacks
var delegate: MessageHandlerDelegate? = null
// Coroutines
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Handle announce message
*/
suspend fun handleAnnounce(packet: BitchatPacket, peerID: String): Boolean {
if (peerID == myPeerID) return false
val nickname = String(packet.payload, Charsets.UTF_8)
Log.d(TAG, "Received announce from $peerID: $nickname")
// Notify delegate to handle peer management
val isFirstAnnounce = delegate?.addOrUpdatePeer(peerID, nickname) ?: false
// Relay announce if TTL > 0
if (packet.ttl > 1u) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delay(Random.nextLong(100, 300))
delegate?.relayPacket(relayPacket)
}
return isFirstAnnounce
}
/**
* Handle broadcast or private message
*/
suspend fun handleMessage(packet: BitchatPacket, peerID: String) {
if (peerID == myPeerID) return
val recipientID = packet.recipientID?.takeIf { !it.contentEquals(delegate?.getBroadcastRecipient()) }
if (recipientID == null) {
// BROADCAST MESSAGE
handleBroadcastMessage(packet, peerID)
} else if (String(recipientID).replace("\u0000", "") == myPeerID) {
// PRIVATE MESSAGE FOR US
handlePrivateMessage(packet, peerID)
} else if (packet.ttl > 0u) {
// RELAY MESSAGE
relayMessage(packet)
}
}
/**
* Handle broadcast message
*/
private suspend fun handleBroadcastMessage(packet: BitchatPacket, peerID: String) {
try {
// Parse message
val message = BitchatMessage.fromBinaryPayload(packet.payload)
if (message != null) {
// Check for cover traffic (dummy messages)
if (message.content.startsWith("☂DUMMY☂")) {
Log.d(TAG, "Discarding cover traffic from $peerID")
return // Silently discard
}
delegate?.updatePeerNickname(peerID, message.sender)
// Handle encrypted channel messages
val finalContent = if (message.channel != null && message.isEncrypted && message.encryptedContent != null) {
delegate?.decryptChannelMessage(message.encryptedContent, message.channel)
?: "[Encrypted message - password required]"
} else {
message.content
}
// Replace timestamp with current time (same as iOS)
val messageWithCurrentTime = message.copy(
content = finalContent,
senderPeerID = peerID,
timestamp = Date() // Use current time instead of original timestamp
)
delegate?.onMessageReceived(messageWithCurrentTime)
}
// Relay broadcast messages
relayMessage(packet)
} catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
}
}
/**
* Handle private message addressed to us
*/
private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) {
try {
// Verify signature if present
if (packet.signature != null && !delegate?.verifySignature(packet, peerID)!!) {
Log.w(TAG, "Invalid signature for private message from $peerID")
return
}
// Decrypt message
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData == null) {
Log.e(TAG, "Failed to decrypt private message from $peerID")
return
}
val unpaddedData = MessagePadding.unpad(decryptedData)
// Parse message
val message = BitchatMessage.fromBinaryPayload(unpaddedData)
if (message != null) {
// Check for cover traffic (dummy messages)
if (message.content.startsWith("☂DUMMY☂")) {
Log.d(TAG, "Discarding private cover traffic from $peerID")
return // Silently discard
}
delegate?.updatePeerNickname(peerID, message.sender)
// Replace timestamp with current time (same as iOS)
val messageWithCurrentTime = message.copy(
senderPeerID = peerID,
timestamp = Date() // Use current time instead of original timestamp
)
delegate?.onMessageReceived(messageWithCurrentTime)
// Send delivery ACK
sendDeliveryAck(message, peerID)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to process private message from $peerID: ${e.message}")
}
}
/**
* Handle leave message
*/
suspend fun handleLeave(packet: BitchatPacket, peerID: String) {
val content = String(packet.payload, Charsets.UTF_8)
if (content.startsWith("#")) {
// Channel leave
delegate?.onChannelLeave(content, peerID)
} else {
// Peer disconnect
val nickname = delegate?.getPeerNickname(peerID)
delegate?.removePeer(peerID)
if (nickname != null) {
delegate?.onPeerDisconnected(nickname)
}
}
// Relay if TTL > 0
if (packet.ttl > 1u) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
}
}
/**
* Handle delivery acknowledgment
*/
suspend fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
try {
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData != null) {
val ack = DeliveryAck.decode(decryptedData)
if (ack != null) {
delegate?.onDeliveryAckReceived(ack)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt delivery ACK: ${e.message}")
}
} else if (packet.ttl > 0u) {
// Relay
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
}
}
/**
* Handle read receipt
*/
suspend fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
try {
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData != null) {
val receipt = ReadReceipt.decode(decryptedData)
if (receipt != null) {
delegate?.onReadReceiptReceived(receipt)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt read receipt: ${e.message}")
}
} else if (packet.ttl > 0u) {
// Relay
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
}
}
/**
* Relay message with adaptive probability (same as iOS)
*/
private suspend fun relayMessage(packet: BitchatPacket) {
if (packet.ttl == 0u.toUByte()) return
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
// Check network size and apply adaptive relay probability
val networkSize = delegate?.getNetworkSize() ?: 1
val relayProb = when {
networkSize <= 10 -> 1.0
networkSize <= 30 -> 0.85
networkSize <= 50 -> 0.7
networkSize <= 100 -> 0.55
else -> 0.4
}
val shouldRelay = relayPacket.ttl >= 4u || networkSize <= 3 || Random.nextDouble() < relayProb
if (shouldRelay) {
val delay = Random.nextLong(50, 500) // Random delay like iOS
delay(delay)
delegate?.relayPacket(relayPacket)
}
}
/**
* Send delivery acknowledgment for a received private message
*/
private fun sendDeliveryAck(message: BitchatMessage, senderPeerID: String) {
handlerScope.launch {
val nickname = delegate?.getMyNickname() ?: myPeerID
val ack = DeliveryAck(
originalMessageID = message.id,
recipientID = myPeerID,
recipientNickname = nickname,
hopCount = 0u // Will be calculated during relay
)
try {
val ackData = ack.encode() ?: return@launch
val encryptedPayload = delegate?.encryptForPeer(ackData, senderPeerID)
if (encryptedPayload != null) {
val packet = BitchatPacket(
type = MessageType.DELIVERY_ACK.value,
senderID = myPeerID.toByteArray(),
recipientID = senderPeerID.toByteArray(),
timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload,
signature = null,
ttl = 3u
)
delegate?.sendPacket(packet)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send delivery ACK: ${e.message}")
}
}
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Message Handler Debug Info ===")
appendLine("Handler Scope Active: ${handlerScope.isActive}")
appendLine("My Peer ID: $myPeerID")
}
}
/**
* Shutdown the handler
*/
fun shutdown() {
handlerScope.cancel()
}
}
/**
* Delegate interface for message handler callbacks
*/
interface MessageHandlerDelegate {
// Peer management
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean
fun removePeer(peerID: String)
fun updatePeerNickname(peerID: String, nickname: String)
fun getPeerNickname(peerID: String): String?
fun getNetworkSize(): Int
fun getMyNickname(): String?
// Packet operations
fun sendPacket(packet: BitchatPacket)
fun relayPacket(packet: BitchatPacket)
fun getBroadcastRecipient(): ByteArray
// Cryptographic operations
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean
fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray?
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray?
// Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
// Callbacks
fun onMessageReceived(message: BitchatMessage)
fun onChannelLeave(channel: String, fromPeer: String)
fun onPeerDisconnected(nickname: String)
fun onDeliveryAckReceived(ack: DeliveryAck)
fun onReadReceiptReceived(receipt: ReadReceipt)
}
@@ -0,0 +1,184 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.*
/**
* Processes incoming packets and routes them to appropriate handlers
* Extracted from BluetoothMeshService for better separation of concerns
*/
class PacketProcessor(private val myPeerID: String) {
companion object {
private const val TAG = "PacketProcessor"
}
// Delegate for callbacks
var delegate: PacketProcessorDelegate? = null
// Coroutines
private val processorScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Process received packet - main entry point for all incoming packets
*/
fun processPacket(packet: BitchatPacket, peerID: String) {
processorScope.launch {
handleReceivedPacket(packet, peerID)
}
}
/**
* Handle received packet - core protocol logic (exact same as iOS)
*/
private suspend fun handleReceivedPacket(packet: BitchatPacket, peerID: String) {
// Basic validation and security checks
if (!delegate?.validatePacketSecurity(packet, peerID)!!) {
Log.d(TAG, "Packet failed security validation from $peerID")
return
}
// Update last seen timestamp
delegate?.updatePeerLastSeen(peerID)
Log.d(TAG, "Processing packet type ${packet.type} from $peerID")
// Process based on message type (exact same logic as iOS)
when (MessageType.fromValue(packet.type)) {
MessageType.KEY_EXCHANGE -> handleKeyExchange(packet, peerID)
MessageType.ANNOUNCE -> handleAnnounce(packet, peerID)
MessageType.MESSAGE -> handleMessage(packet, peerID)
MessageType.LEAVE -> handleLeave(packet, peerID)
MessageType.FRAGMENT_START,
MessageType.FRAGMENT_CONTINUE,
MessageType.FRAGMENT_END -> handleFragment(packet, peerID)
MessageType.DELIVERY_ACK -> handleDeliveryAck(packet, peerID)
MessageType.READ_RECEIPT -> handleReadReceipt(packet, peerID)
else -> {
Log.w(TAG, "Unknown message type: ${packet.type}")
}
}
}
/**
* Handle key exchange message
*/
private suspend fun handleKeyExchange(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing key exchange from $peerID")
val success = delegate?.handleKeyExchange(packet, peerID) ?: false
if (success) {
// Key exchange successful, send announce and cached messages
delay(100)
delegate?.sendAnnouncementToPeer(peerID)
delay(500)
delegate?.sendCachedMessages(peerID)
}
}
/**
* Handle announce message
*/
private suspend fun handleAnnounce(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing announce from $peerID")
delegate?.handleAnnounce(packet, peerID)
}
/**
* Handle regular message
*/
private suspend fun handleMessage(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing message from $peerID")
delegate?.handleMessage(packet, peerID)
}
/**
* Handle leave message
*/
private suspend fun handleLeave(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing leave from $peerID")
delegate?.handleLeave(packet, peerID)
}
/**
* Handle message fragments
*/
private suspend fun handleFragment(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing fragment from $peerID")
val reassembledPacket = delegate?.handleFragment(packet)
if (reassembledPacket != null) {
Log.d(TAG, "Fragment reassembled, processing complete message")
handleReceivedPacket(reassembledPacket, peerID)
}
// Relay fragment regardless of reassembly
if (packet.ttl > 0u) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
}
}
/**
* Handle delivery acknowledgment
*/
private suspend fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing delivery ACK from $peerID")
delegate?.handleDeliveryAck(packet, peerID)
}
/**
* Handle read receipt
*/
private suspend fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing read receipt from $peerID")
delegate?.handleReadReceipt(packet, peerID)
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Packet Processor Debug Info ===")
appendLine("Processor Scope Active: ${processorScope.isActive}")
appendLine("My Peer ID: $myPeerID")
}
}
/**
* Shutdown the processor
*/
fun shutdown() {
processorScope.cancel()
}
}
/**
* Delegate interface for packet processor callbacks
*/
interface PacketProcessorDelegate {
// Security validation
fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean
// Peer management
fun updatePeerLastSeen(peerID: String)
// Message type handlers
fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean
fun handleAnnounce(packet: BitchatPacket, peerID: String)
fun handleMessage(packet: BitchatPacket, peerID: String)
fun handleLeave(packet: BitchatPacket, peerID: String)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleDeliveryAck(packet: BitchatPacket, peerID: String)
fun handleReadReceipt(packet: BitchatPacket, peerID: String)
// Communication
fun sendAnnouncementToPeer(peerID: String)
fun sendCachedMessages(peerID: String)
fun relayPacket(packet: BitchatPacket)
}
@@ -0,0 +1,257 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* Manages active peers, nicknames, and RSSI tracking
* Extracted from BluetoothMeshService for better separation of concerns
*/
class PeerManager {
companion object {
private const val TAG = "PeerManager"
private const val STALE_PEER_TIMEOUT = 180000L // 3 minutes (same as iOS)
private const val CLEANUP_INTERVAL = 60000L // 1 minute
}
// Peer tracking data
private val peerNicknames = ConcurrentHashMap<String, String>()
private val activePeers = ConcurrentHashMap<String, Long>() // peerID -> lastSeen timestamp
private val peerRSSI = ConcurrentHashMap<String, Int>()
private val announcedPeers = CopyOnWriteArrayList<String>()
private val announcedToPeers = CopyOnWriteArrayList<String>()
// Delegate for callbacks
var delegate: PeerManagerDelegate? = null
// Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startPeriodicCleanup()
}
/**
* Update peer last seen timestamp
*/
fun updatePeerLastSeen(peerID: String) {
if (peerID != "unknown") {
activePeers[peerID] = System.currentTimeMillis()
}
}
/**
* Add or update peer with nickname
*/
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
if (peerID == "unknown") return false
// Clean up stale peer IDs with the same nickname (exact same logic as iOS)
val stalePeerIDs = mutableListOf<String>()
peerNicknames.forEach { (existingPeerID, existingNickname) ->
if (existingNickname == nickname && existingPeerID != peerID) {
val lastSeen = activePeers[existingPeerID] ?: 0
val wasRecentlySeen = (System.currentTimeMillis() - lastSeen) < 10000
if (!wasRecentlySeen) {
stalePeerIDs.add(existingPeerID)
}
}
}
// Remove stale peer IDs
stalePeerIDs.forEach { stalePeerID ->
removePeer(stalePeerID, notifyDelegate = false)
}
// Check if this is a new peer announcement
val isFirstAnnounce = !announcedPeers.contains(peerID)
// Update peer data
peerNicknames[peerID] = nickname
activePeers[peerID] = System.currentTimeMillis()
// Handle first announcement
if (isFirstAnnounce) {
announcedPeers.add(peerID)
delegate?.onPeerConnected(nickname)
notifyPeerListUpdate()
return true
}
return false
}
/**
* Remove peer
*/
fun removePeer(peerID: String, notifyDelegate: Boolean = true) {
val nickname = peerNicknames.remove(peerID)
activePeers.remove(peerID)
peerRSSI.remove(peerID)
announcedPeers.remove(peerID)
announcedToPeers.remove(peerID)
if (notifyDelegate && nickname != null) {
delegate?.onPeerDisconnected(nickname)
notifyPeerListUpdate()
}
}
/**
* Update peer RSSI
*/
fun updatePeerRSSI(peerID: String, rssi: Int) {
if (peerID != "unknown") {
peerRSSI[peerID] = rssi
}
}
/**
* Check if peer has been announced to
*/
fun hasAnnouncedToPeer(peerID: String): Boolean {
return announcedToPeers.contains(peerID)
}
/**
* Mark peer as announced to
*/
fun markPeerAsAnnouncedTo(peerID: String) {
if (!announcedToPeers.contains(peerID)) {
announcedToPeers.add(peerID)
}
}
/**
* Check if peer is active
*/
fun isPeerActive(peerID: String): Boolean {
return activePeers.containsKey(peerID)
}
/**
* Get peer nickname
*/
fun getPeerNickname(peerID: String): String? {
return peerNicknames[peerID]
}
/**
* Get all peer nicknames
*/
fun getAllPeerNicknames(): Map<String, String> {
return peerNicknames.toMap()
}
/**
* Get all peer RSSI values
*/
fun getAllPeerRSSI(): Map<String, Int> {
return peerRSSI.toMap()
}
/**
* Get list of active peer IDs
*/
fun getActivePeerIDs(): List<String> {
return activePeers.keys.toList().sorted()
}
/**
* Get active peer count
*/
fun getActivePeerCount(): Int {
return activePeers.size
}
/**
* Clear all peer data
*/
fun clearAllPeers() {
peerNicknames.clear()
activePeers.clear()
peerRSSI.clear()
announcedPeers.clear()
announcedToPeers.clear()
notifyPeerListUpdate()
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Peer Manager Debug Info ===")
appendLine("Active Peers: ${activePeers.size}")
activePeers.forEach { (peerID, lastSeen) ->
val nickname = peerNicknames[peerID] ?: "Unknown"
val timeSince = (System.currentTimeMillis() - lastSeen) / 1000
val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI"
appendLine(" - $peerID ($nickname) - last seen ${timeSince}s ago, RSSI: $rssi")
}
appendLine("Announced Peers: ${announcedPeers.size}")
appendLine("Announced To Peers: ${announcedToPeers.size}")
}
}
/**
* Notify delegate of peer list updates
*/
private fun notifyPeerListUpdate() {
val peerList = getActivePeerIDs()
delegate?.onPeerListUpdated(peerList)
}
/**
* Start periodic cleanup of stale peers
*/
private fun startPeriodicCleanup() {
managerScope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL)
cleanupStalePeers()
}
}
}
/**
* Clean up stale peers (same 3-minute threshold as iOS)
*/
private fun cleanupStalePeers() {
val now = System.currentTimeMillis()
val peersToRemove = activePeers.entries.filter { (_, lastSeen) ->
now - lastSeen > STALE_PEER_TIMEOUT
}.map { it.key }
peersToRemove.forEach { peerID ->
Log.d(TAG, "Removing stale peer: $peerID")
removePeer(peerID)
}
if (peersToRemove.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${peersToRemove.size} stale peers")
}
}
/**
* Shutdown the manager
*/
fun shutdown() {
managerScope.cancel()
clearAllPeers()
}
}
/**
* Delegate interface for peer manager callbacks
*/
interface PeerManagerDelegate {
fun onPeerConnected(nickname: String)
fun onPeerDisconnected(nickname: String)
fun onPeerListUpdated(peerIDs: List<String>)
}
@@ -0,0 +1,319 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.*
import java.util.*
import kotlin.collections.mutableSetOf
/**
* Manages security aspects of the mesh network including duplicate detection,
* replay attack protection, and key exchange handling
* Extracted from BluetoothMeshService for better separation of concerns
*/
class SecurityManager(private val encryptionService: EncryptionService, private val myPeerID: String) {
companion object {
private const val TAG = "SecurityManager"
private const val MESSAGE_TIMEOUT = 300000L // 5 minutes (same as iOS)
private const val CLEANUP_INTERVAL = 300000L // 5 minutes
private const val MAX_PROCESSED_MESSAGES = 10000
private const val MAX_PROCESSED_KEY_EXCHANGES = 1000
}
// Security tracking
private val processedMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf<String>())
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
// Delegate for callbacks
var delegate: SecurityManagerDelegate? = null
// Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startPeriodicCleanup()
}
/**
* Validate packet security (timestamp, replay attacks, duplicates)
*/
fun validatePacket(packet: BitchatPacket, peerID: String): Boolean {
// Skip validation for our own packets
if (peerID == myPeerID) {
Log.d(TAG, "Skipping validation for our own packet")
return false
}
// TTL check
if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping packet with TTL 0")
return false
}
// Validate packet payload
if (packet.payload.isEmpty()) {
Log.d(TAG, "Dropping packet with empty payload")
return false
}
// Replay attack protection (same 5-minute window as iOS)
val currentTime = System.currentTimeMillis()
val packetTime = packet.timestamp.toLong()
val timeDiff = kotlin.math.abs(currentTime - packetTime)
if (timeDiff > MESSAGE_TIMEOUT) {
Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
return false
}
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
if (processedMessages.contains(messageID)) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true
}
/**
* Handle key exchange packet
*/
suspend fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean {
if (peerID == myPeerID) return false
if (packet.payload.isEmpty()) {
Log.w(TAG, "Key exchange packet has empty payload")
return false
}
// Prevent duplicate key exchange processing
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}"
if (processedKeyExchanges.contains(exchangeKey)) {
Log.d(TAG, "Already processed key exchange: $exchangeKey")
return false
}
processedKeyExchanges.add(exchangeKey)
try {
// Process the key exchange
encryptionService.addPeerPublicKey(peerID, packet.payload)
Log.d(TAG, "Successfully processed key exchange from $peerID")
// Notify delegate
delegate?.onKeyExchangeCompleted(peerID)
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to process key exchange from $peerID: ${e.message}")
return false
}
}
/**
* Verify packet signature
*/
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
return packet.signature?.let { signature ->
try {
val isValid = encryptionService.verify(signature, packet.payload, peerID)
if (!isValid) {
Log.w(TAG, "Invalid signature for packet from $peerID")
}
isValid
} catch (e: Exception) {
Log.e(TAG, "Failed to verify signature from $peerID: ${e.message}")
false
}
} ?: true // No signature means verification passes
}
/**
* Sign packet payload
*/
fun signPacket(payload: ByteArray): ByteArray? {
return try {
encryptionService.sign(payload)
} catch (e: Exception) {
Log.e(TAG, "Failed to sign packet: ${e.message}")
null
}
}
/**
* Encrypt payload for specific peer
*/
fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? {
return try {
encryptionService.encrypt(data, recipientPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to encrypt for $recipientPeerID: ${e.message}")
null
}
}
/**
* Decrypt payload from specific peer
*/
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
return try {
encryptionService.decrypt(encryptedData, senderPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}")
null
}
}
/**
* Get combined public key data for key exchange
*/
fun getCombinedPublicKeyData(): ByteArray {
return encryptionService.getCombinedPublicKeyData()
}
/**
* Generate message ID for duplicate detection
*/
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
return when (MessageType.fromValue(packet.type)) {
MessageType.FRAGMENT_START, MessageType.FRAGMENT_CONTINUE, MessageType.FRAGMENT_END -> {
// For fragments, include the payload hash to distinguish different fragments
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
}
else -> {
// For other messages, use a truncated payload hash
val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode()
"${packet.timestamp}-$peerID-$payloadHash"
}
}
}
/**
* Check if we have encryption keys for a peer
*/
fun hasKeysForPeer(peerID: String): Boolean {
// This would need to be implemented in EncryptionService
// For now, we'll assume we have keys if we processed a key exchange
return processedKeyExchanges.any { it.startsWith("$peerID-") }
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Security Manager Debug Info ===")
appendLine("Processed Messages: ${processedMessages.size}")
appendLine("Processed Key Exchanges: ${processedKeyExchanges.size}")
appendLine("Message Timestamps: ${messageTimestamps.size}")
if (processedKeyExchanges.isNotEmpty()) {
appendLine("Key Exchange History:")
processedKeyExchanges.take(10).forEach { exchange ->
appendLine(" - $exchange")
}
if (processedKeyExchanges.size > 10) {
appendLine(" ... and ${processedKeyExchanges.size - 10} more")
}
}
}
}
/**
* Start periodic cleanup
*/
private fun startPeriodicCleanup() {
managerScope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL)
cleanupOldData()
}
}
}
/**
* Clean up old processed messages and timestamps
*/
private fun cleanupOldData() {
val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT
var removedCount = 0
// Clean up old message timestamps and corresponding processed messages
val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) ->
timestamp < cutoffTime
}.map { it.key }
messagesToRemove.forEach { messageId ->
messageTimestamps.remove(messageId)
if (processedMessages.remove(messageId)) {
removedCount++
}
}
// Limit the size of processed messages set
if (processedMessages.size > MAX_PROCESSED_MESSAGES) {
val excess = processedMessages.size - MAX_PROCESSED_MESSAGES
val toRemove = processedMessages.take(excess)
processedMessages.removeAll(toRemove.toSet())
removeFromMessageTimestamps(toRemove)
removedCount += excess
}
// 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())
}
if (removedCount > 0) {
Log.d(TAG, "Cleaned up $removedCount old processed messages")
}
}
/**
* Helper to remove entries from messageTimestamps
*/
private fun removeFromMessageTimestamps(messageIds: List<String>) {
messageIds.forEach { messageId ->
messageTimestamps.remove(messageId)
}
}
/**
* Clear all security data
*/
fun clearAllData() {
processedMessages.clear()
processedKeyExchanges.clear()
messageTimestamps.clear()
}
/**
* Shutdown the manager
*/
fun shutdown() {
managerScope.cancel()
clearAllData()
}
}
/**
* Delegate interface for security manager callbacks
*/
interface SecurityManagerDelegate {
fun onKeyExchangeCompleted(peerID: String)
}
@@ -0,0 +1,315 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* Manages store-and-forward messaging for offline peers
* Extracted from BluetoothMeshService for better separation of concerns
*/
class StoreForwardManager {
companion object {
private const val TAG = "StoreForwardManager"
private const val MESSAGE_CACHE_TIMEOUT = 43200000L // 12 hours for regular peers
private const val MAX_CACHED_MESSAGES = 100 // For regular peers
private const val MAX_CACHED_MESSAGES_FAVORITES = 1000 // For favorites
private const val CLEANUP_INTERVAL = 600000L // 10 minutes
}
/**
* Data class for stored messages
*/
private data class StoredMessage(
val packet: BitchatPacket,
val timestamp: Long,
val messageID: String,
val isForFavorite: Boolean
)
// Message storage
private val messageCache = Collections.synchronizedList(mutableListOf<StoredMessage>())
private val favoriteMessageQueue = ConcurrentHashMap<String, MutableList<StoredMessage>>()
private val deliveredMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val cachedMessagesSentToPeer = Collections.synchronizedSet(mutableSetOf<String>())
// Delegate for callbacks
var delegate: StoreForwardManagerDelegate? = null
// Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startPeriodicCleanup()
}
/**
* Cache message for offline delivery
*/
fun cacheMessage(packet: BitchatPacket, messageID: String) {
// Skip certain message types (same as iOS)
if (packet.type == MessageType.KEY_EXCHANGE.value ||
packet.type == MessageType.ANNOUNCE.value ||
packet.type == MessageType.LEAVE.value) {
Log.d(TAG, "Skipping cache for message type: ${packet.type}")
return
}
// Don't cache broadcast messages
if (packet.recipientID != null && packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
Log.d(TAG, "Skipping cache for broadcast message")
return
}
// Determine if this is for a favorite peer
val recipientPeerID = packet.recipientID?.let { recipientID ->
String(recipientID).replace("\u0000", "")
}
if (recipientPeerID.isNullOrEmpty()) {
Log.w(TAG, "Cannot cache message without valid recipient")
return
}
val isForFavorite = delegate?.isFavorite(recipientPeerID) ?: false
val storedMessage = StoredMessage(
packet = packet,
timestamp = System.currentTimeMillis(),
messageID = messageID,
isForFavorite = isForFavorite
)
if (isForFavorite) {
// Store in favorite queue
if (!favoriteMessageQueue.containsKey(recipientPeerID)) {
favoriteMessageQueue[recipientPeerID] = mutableListOf()
}
favoriteMessageQueue[recipientPeerID]?.add(storedMessage)
// Limit favorite queue size
if (favoriteMessageQueue[recipientPeerID]?.size ?: 0 > MAX_CACHED_MESSAGES_FAVORITES) {
favoriteMessageQueue[recipientPeerID]?.removeAt(0)
}
Log.d(TAG, "Cached message for favorite peer $recipientPeerID (${favoriteMessageQueue[recipientPeerID]?.size} total)")
} else {
// Store in regular cache
cleanupMessageCache()
messageCache.add(storedMessage)
// Limit cache size
if (messageCache.size > MAX_CACHED_MESSAGES) {
messageCache.removeAt(0)
}
Log.d(TAG, "Cached message for peer $recipientPeerID (${messageCache.size} total in cache)")
}
}
/**
* Send cached messages to peer when they come online
*/
fun sendCachedMessages(peerID: String) {
if (cachedMessagesSentToPeer.contains(peerID)) {
Log.d(TAG, "Already sent cached messages to $peerID")
return // Already sent cached messages to this peer
}
cachedMessagesSentToPeer.add(peerID)
managerScope.launch {
cleanupMessageCache()
val messagesToSend = mutableListOf<StoredMessage>()
// Check favorite queue
favoriteMessageQueue[peerID]?.let { favoriteMessages ->
val undeliveredFavorites = favoriteMessages.filter { !deliveredMessages.contains(it.messageID) }
messagesToSend.addAll(undeliveredFavorites)
favoriteMessageQueue.remove(peerID)
Log.d(TAG, "Found ${undeliveredFavorites.size} cached favorite messages for $peerID")
}
// Filter regular cached messages for this recipient
val recipientMessages = messageCache.filter { storedMessage ->
!deliveredMessages.contains(storedMessage.messageID) &&
storedMessage.packet.recipientID?.let { recipientID ->
String(recipientID).replace("\u0000", "") == peerID
} == true
}
messagesToSend.addAll(recipientMessages)
if (recipientMessages.isNotEmpty()) {
Log.d(TAG, "Found ${recipientMessages.size} cached regular messages for $peerID")
}
// Sort by timestamp
messagesToSend.sortBy { it.timestamp }
if (messagesToSend.isNotEmpty()) {
Log.i(TAG, "Sending ${messagesToSend.size} cached messages to $peerID")
}
// Mark as delivered
val messageIDsToRemove = messagesToSend.map { it.messageID }
deliveredMessages.addAll(messageIDsToRemove)
// Send with delays to avoid overwhelming the connection
messagesToSend.forEachIndexed { index, storedMessage ->
delay(index * 100L) // 100ms between messages
delegate?.sendPacket(storedMessage.packet)
}
// Remove sent messages from cache
messageCache.removeAll { messageIDsToRemove.contains(it.messageID) }
if (messagesToSend.isNotEmpty()) {
Log.d(TAG, "Finished sending ${messagesToSend.size} cached messages to $peerID")
}
}
}
/**
* Check if message should be cached for peer
*/
fun shouldCacheForPeer(recipientPeerID: String): Boolean {
// Check if recipient is offline and should cache for favorites
val isOffline = !(delegate?.isPeerOnline(recipientPeerID) ?: false)
val isRecipientFavorite = delegate?.isFavorite(recipientPeerID) ?: false
return isOffline && isRecipientFavorite
}
/**
* Mark message as delivered
*/
fun markMessageAsDelivered(messageID: String) {
deliveredMessages.add(messageID)
}
/**
* Get cached message count for peer
*/
fun getCachedMessageCount(peerID: String): Int {
val favoriteCount = favoriteMessageQueue[peerID]?.size ?: 0
val regularCount = messageCache.count { storedMessage ->
storedMessage.packet.recipientID?.let { recipientID ->
String(recipientID).replace("\u0000", "") == peerID
} == true
}
return favoriteCount + regularCount
}
/**
* Get debug information
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Store-Forward Manager Debug Info ===")
appendLine("Regular Cache: ${messageCache.size}/${MAX_CACHED_MESSAGES}")
appendLine("Favorite Queues: ${favoriteMessageQueue.size}")
favoriteMessageQueue.forEach { (peerID, messages) ->
appendLine(" - $peerID: ${messages.size} messages")
}
appendLine("Delivered Messages: ${deliveredMessages.size}")
appendLine("Peers Sent Cache: ${cachedMessagesSentToPeer.size}")
// Cache age analysis
val now = System.currentTimeMillis()
val regularCacheAges = messageCache.map { (now - it.timestamp) / 1000 }
if (regularCacheAges.isNotEmpty()) {
val avgAge = regularCacheAges.average().toInt()
val maxAge = regularCacheAges.maxOrNull() ?: 0
appendLine("Regular Cache Age: avg ${avgAge}s, max ${maxAge}s")
}
}
}
/**
* Start periodic cleanup
*/
private fun startPeriodicCleanup() {
managerScope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL)
cleanupMessageCache()
cleanupDeliveredMessages()
}
}
}
/**
* Clean up old cached messages (not for favorites)
*/
private fun cleanupMessageCache() {
val cutoffTime = System.currentTimeMillis() - MESSAGE_CACHE_TIMEOUT
val sizeBefore = messageCache.size
val removed = messageCache.removeAll { !it.isForFavorite && it.timestamp < cutoffTime }
if (removed) {
val removedCount = sizeBefore - messageCache.size
Log.d(TAG, "Cleaned up $removedCount old cached messages")
}
}
/**
* Clean up delivered messages set (prevent memory leak)
*/
private fun cleanupDeliveredMessages() {
if (deliveredMessages.size > 1000) {
Log.d(TAG, "Clearing delivered messages set (${deliveredMessages.size} entries)")
deliveredMessages.clear()
}
if (cachedMessagesSentToPeer.size > 200) {
Log.d(TAG, "Clearing cached messages sent tracking (${cachedMessagesSentToPeer.size} entries)")
cachedMessagesSentToPeer.clear()
}
}
/**
* Clear all cached data
*/
fun clearAllCache() {
messageCache.clear()
favoriteMessageQueue.clear()
deliveredMessages.clear()
cachedMessagesSentToPeer.clear()
Log.d(TAG, "Cleared all cached message data")
}
/**
* Force cleanup for testing
*/
fun forceCleanup() {
cleanupMessageCache()
cleanupDeliveredMessages()
}
/**
* Shutdown the manager
*/
fun shutdown() {
managerScope.cancel()
clearAllCache()
}
}
/**
* Delegate interface for store-forward manager callbacks
*/
interface StoreForwardManagerDelegate {
fun isFavorite(peerID: String): Boolean
fun isPeerOnline(peerID: String): Boolean
fun sendPacket(packet: BitchatPacket)
}
@@ -0,0 +1,306 @@
package com.bitchat.android.ui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import com.bitchat.android.model.BitchatMessage
import java.util.*
/**
* Handles channel management including creation, joining, leaving, and encryption
*/
class ChannelManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val coroutineScope: CoroutineScope
) {
// Channel encryption and security
private val channelKeys = mutableMapOf<String, SecretKeySpec>()
private val channelPasswords = mutableMapOf<String, String>()
private val channelKeyCommitments = mutableMapOf<String, String>()
private val retentionEnabledChannels = mutableSetOf<String>()
// MARK: - Channel Lifecycle
fun joinChannel(channel: String, password: String? = null, myPeerID: String): Boolean {
val channelTag = if (channel.startsWith("#")) channel else "#$channel"
// Check if already joined
if (state.getJoinedChannelsValue().contains(channelTag)) {
if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) {
// Need password verification
if (password != null) {
return verifyChannelPassword(channelTag, password)
} else {
state.setPasswordPromptChannel(channelTag)
state.setShowPasswordPrompt(true)
return false
}
}
switchToChannel(channelTag)
return true
}
// If password protected and no key yet
if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) {
if (dataManager.isChannelCreator(channelTag, myPeerID)) {
// Channel creator bypass
} else if (password != null) {
if (!verifyChannelPassword(channelTag, password)) {
return false
}
} else {
state.setPasswordPromptChannel(channelTag)
state.setShowPasswordPrompt(true)
return false
}
}
// Join the channel
val updatedChannels = state.getJoinedChannelsValue().toMutableSet()
updatedChannels.add(channelTag)
state.setJoinedChannels(updatedChannels)
// Set as creator if new channel
if (!dataManager.channelCreators.containsKey(channelTag) && !state.getPasswordProtectedChannelsValue().contains(channelTag)) {
dataManager.addChannelCreator(channelTag, myPeerID)
}
// Add ourselves as member
dataManager.addChannelMember(channelTag, myPeerID)
// Initialize channel messages if needed
if (!state.getChannelMessagesValue().containsKey(channelTag)) {
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
updatedChannelMessages[channelTag] = emptyList()
state.setChannelMessages(updatedChannelMessages)
}
switchToChannel(channelTag)
saveChannelData()
return true
}
fun leaveChannel(channel: String) {
val updatedChannels = state.getJoinedChannelsValue().toMutableSet()
updatedChannels.remove(channel)
state.setJoinedChannels(updatedChannels)
// Exit channel if currently in it
if (state.getCurrentChannelValue() == channel) {
state.setCurrentChannel(null)
}
// Cleanup
messageManager.removeChannelMessages(channel)
dataManager.removeChannelMembers(channel)
channelKeys.remove(channel)
channelPasswords.remove(channel)
dataManager.removeChannelCreator(channel)
saveChannelData()
}
fun switchToChannel(channel: String?) {
state.setCurrentChannel(channel)
state.setSelectedPrivateChatPeer(null)
// Clear unread count
channel?.let { ch ->
messageManager.clearChannelUnreadCount(ch)
}
}
// MARK: - Channel Password and Encryption
private fun verifyChannelPassword(channel: String, password: String): Boolean {
val key = deriveChannelKey(password, channel)
// Verify against existing messages if available
val existingMessages = state.getChannelMessagesValue()[channel]?.filter { it.isEncrypted }
if (!existingMessages.isNullOrEmpty()) {
val testMessage = existingMessages.first()
val decryptedContent = decryptChannelMessage(testMessage.encryptedContent ?: byteArrayOf(), channel, key)
if (decryptedContent == null) {
return false
}
}
channelKeys[channel] = key
channelPasswords[channel] = password
return true
}
private fun deriveChannelKey(password: String, channelName: String): SecretKeySpec {
// PBKDF2 key derivation (same as iOS version)
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = javax.crypto.spec.PBEKeySpec(
password.toCharArray(),
channelName.toByteArray(),
100000, // 100,000 iterations (same as iOS)
256 // 256-bit key
)
val secretKey = factory.generateSecret(spec)
return SecretKeySpec(secretKey.encoded, "AES")
}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return decryptChannelMessage(encryptedContent, channel, null)
}
private fun decryptChannelMessage(encryptedContent: ByteArray, channel: String, testKey: SecretKeySpec?): String? {
val key = testKey ?: channelKeys[channel] ?: return null
try {
if (encryptedContent.size < 16) return null // 12 bytes IV + minimum ciphertext
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val iv = encryptedContent.sliceArray(0..11)
val ciphertext = encryptedContent.sliceArray(12 until encryptedContent.size)
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
val decryptedData = cipher.doFinal(ciphertext)
return String(decryptedData, Charsets.UTF_8)
} catch (e: Exception) {
return null
}
}
fun sendEncryptedChannelMessage(
content: String,
mentions: List<String>,
channel: String,
senderNickname: String?,
myPeerID: String,
onEncryptedPayload: (ByteArray) -> Unit,
onFallback: () -> Unit
) {
val key = channelKeys[channel]
if (key == null) {
onFallback()
return
}
coroutineScope.launch {
try {
val contentBytes = content.toByteArray(Charsets.UTF_8)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val encryptedData = cipher.doFinal(contentBytes)
// Combine IV and encrypted data
val combined = ByteArray(iv.size + encryptedData.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(encryptedData, 0, combined, iv.size, encryptedData.size)
val encryptedMessage = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = "",
timestamp = Date(),
isRelay = false,
senderPeerID = myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = channel,
encryptedContent = combined,
isEncrypted = true
)
// Send encrypted message via mesh
encryptedMessage.toBinaryPayload()?.let { messageData ->
onEncryptedPayload(messageData)
} ?: onFallback()
} catch (e: Exception) {
// Fallback to unencrypted
onFallback()
}
}
}
// MARK: - Channel Management
fun addChannelMessage(channel: String, message: BitchatMessage, senderPeerID: String?) {
messageManager.addChannelMessage(channel, message)
// Track as channel member
senderPeerID?.let { peerID ->
dataManager.addChannelMember(channel, peerID)
}
}
fun removeChannelMember(channel: String, peerID: String) {
dataManager.removeChannelMember(channel, peerID)
}
fun cleanupDisconnectedMembers(connectedPeers: List<String>, myPeerID: String) {
dataManager.cleanupAllDisconnectedMembers(connectedPeers, myPeerID)
}
// MARK: - Channel Information
fun isChannelPasswordProtected(channel: String): Boolean {
return state.getPasswordProtectedChannelsValue().contains(channel)
}
fun hasChannelKey(channel: String): Boolean {
return channelKeys.containsKey(channel)
}
fun getChannelPassword(channel: String): String? {
return channelPasswords[channel]
}
fun isChannelCreator(channel: String, peerID: String): Boolean {
return dataManager.isChannelCreator(channel, peerID)
}
fun getJoinedChannelsList(): List<String> {
return state.getJoinedChannelsValue().toList().sorted()
}
// MARK: - Data Persistence
private fun saveChannelData() {
dataManager.saveChannelData(state.getJoinedChannelsValue(), state.getPasswordProtectedChannelsValue())
}
fun loadChannelData(): Pair<Set<String>, Set<String>> {
return dataManager.loadChannelData()
}
// MARK: - Password Management
fun hidePasswordPrompt() {
state.setShowPasswordPrompt(false)
state.setPasswordPromptChannel(null)
}
fun setChannelPassword(channel: String, password: String): Boolean {
return verifyChannelPassword(channel, password)
}
// MARK: - Emergency Clear
fun clearAllChannels() {
state.setJoinedChannels(emptySet())
state.setCurrentChannel(null)
state.setPasswordProtectedChannels(emptySet())
state.setShowPasswordPrompt(false)
state.setPasswordPromptChannel(null)
channelKeys.clear()
channelPasswords.clear()
channelKeyCommitments.clear()
retentionEnabledChannels.clear()
}
}
@@ -0,0 +1,322 @@
package com.bitchat.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Header components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@Composable
fun NicknameEditor(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val focusManager = LocalFocusManager.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Text(
text = "@",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary.copy(alpha = 0.8f)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
cursorBrush = SolidColor(colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
modifier = Modifier.widthIn(max = 100.dp)
)
}
}
@Composable
fun PeerCounter(
connectedPeers: List<String>,
joinedChannels: Set<String>,
hasUnreadChannels: Map<String, Int>,
hasUnreadPrivateMessages: Set<String>,
isConnected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.clickable { onClick() }
) {
if (hasUnreadChannels.values.any { it > 0 }) {
Text(
text = "#",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF0080FF),
fontSize = 16.sp
)
Spacer(modifier = Modifier.width(6.dp))
}
if (hasUnreadPrivateMessages.isNotEmpty()) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFFF8C00),
fontSize = 16.sp
)
Spacer(modifier = Modifier.width(6.dp))
}
Icon(
imageVector = Icons.Default.Person,
contentDescription = "Connected peers",
modifier = Modifier.size(16.dp),
tint = if (isConnected) Color(0xFF00C851) else Color.Red
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${connectedPeers.size}",
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) Color(0xFF00C851) else Color.Red,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
if (joinedChannels.isNotEmpty()) {
Text(
text = " · ⧉ ${joinedChannels.size}",
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) Color(0xFF00C851) else Color.Red,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun ChatHeaderContent(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
viewModel: ChatViewModel,
onBackClick: () -> Unit,
onSidebarClick: () -> Unit,
onTripleClick: () -> Unit,
onShowAppInfo: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
var tripleClickCount by remember { mutableStateOf(0) }
when {
selectedPrivatePeer != null -> {
// Private chat header
PrivateChatHeader(
peerID = selectedPrivatePeer,
peerNicknames = viewModel.meshService.getPeerNicknames(),
isFavorite = viewModel.isFavorite(selectedPrivatePeer),
onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
)
}
currentChannel != null -> {
// Channel header
ChannelHeader(
channel = currentChannel,
onBackClick = onBackClick,
onLeaveChannel = { viewModel.leaveChannel(currentChannel) },
onSidebarClick = onSidebarClick
)
}
else -> {
// Main header
MainHeader(
nickname = nickname,
onNicknameChange = viewModel::setNickname,
onTitleClick = {
tripleClickCount++
if (tripleClickCount >= 3) {
tripleClickCount = 0
onTripleClick()
} else {
onShowAppInfo()
}
},
onSidebarClick = onSidebarClick,
viewModel = viewModel
)
}
}
}
@Composable
private fun PrivateChatHeader(
peerID: String,
peerNicknames: Map<String, String>,
isFavorite: Boolean,
onBackClick: () -> Unit,
onToggleFavorite: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val peerNickname = peerNicknames[peerID] ?: peerID
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBackClick) {
Text(
text = "← back",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
Spacer(modifier = Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("🔒", fontSize = 16.sp) // Slightly larger
Spacer(modifier = Modifier.width(4.dp))
Text(
text = peerNickname,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF8C00) // Orange
)
}
Spacer(modifier = Modifier.weight(1f))
// Favorite button
IconButton(onClick = onToggleFavorite) {
Text(
text = if (isFavorite) "" else "",
color = if (isFavorite) Color.Yellow else colorScheme.primary,
fontSize = 18.sp // Larger icon
)
}
}
}
@Composable
private fun ChannelHeader(
channel: String,
onBackClick: () -> Unit,
onLeaveChannel: () -> Unit,
onSidebarClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBackClick) {
Text(
text = "← back",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "channel: $channel",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF0080FF), // Blue
modifier = Modifier.clickable { onSidebarClick() }
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onLeaveChannel) {
Text(
text = "leave",
style = MaterialTheme.typography.bodySmall,
color = Color.Red
)
}
}
}
@Composable
private fun MainHeader(
nickname: String,
onNicknameChange: (String) -> Unit,
onTitleClick: () -> Unit,
onSidebarClick: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val isConnected by viewModel.isConnected.observeAsState(false)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "bitchat*",
style = MaterialTheme.typography.headlineSmall,
color = colorScheme.primary,
modifier = Modifier.clickable { onTitleClick() }
)
Spacer(modifier = Modifier.width(8.dp))
NicknameEditor(
value = nickname,
onValueChange = onNicknameChange
)
}
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
isConnected = isConnected,
onClick = onSidebarClick
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
package com.bitchat.android.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.bitchat.android.model.BitchatMessage
/**
* Centralized state definitions and data classes for the chat system
*/
// Command suggestion data class
data class CommandSuggestion(
val command: String,
val aliases: List<String> = emptyList(),
val syntax: String? = null,
val description: String
)
/**
* Contains all the observable state for the chat system
*/
class ChatState {
// Core messages and peer state
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
val messages: LiveData<List<BitchatMessage>> = _messages
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
val connectedPeers: LiveData<List<String>> = _connectedPeers
private val _nickname = MutableLiveData<String>()
val nickname: LiveData<String> = _nickname
private val _isConnected = MutableLiveData<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected
// Private chats
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
// Channels
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
val joinedChannels: LiveData<Set<String>> = _joinedChannels
private val _currentChannel = MutableLiveData<String?>(null)
val currentChannel: LiveData<String?> = _currentChannel
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
private val _passwordPromptChannel = MutableLiveData<String?>(null)
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
// Sidebar state
private val _showSidebar = MutableLiveData(false)
val showSidebar: LiveData<Boolean> = _showSidebar
// Command autocomplete
private val _showCommandSuggestions = MutableLiveData(false)
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
init {
// Initialize unread state mediators
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
}
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
}
}
// Getters for internal state access
fun getMessagesValue() = _messages.value ?: emptyList()
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
fun getNicknameValue() = _nickname.value
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value ?: false
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
// Setters for state updates
fun setMessages(messages: List<BitchatMessage>) {
_messages.value = messages
}
fun setConnectedPeers(peers: List<String>) {
_connectedPeers.value = peers
}
fun setNickname(nickname: String) {
_nickname.value = nickname
}
fun setIsConnected(connected: Boolean) {
_isConnected.value = connected
}
fun setPrivateChats(chats: Map<String, List<BitchatMessage>>) {
_privateChats.value = chats
}
fun setSelectedPrivateChatPeer(peerID: String?) {
_selectedPrivateChatPeer.value = peerID
}
fun setUnreadPrivateMessages(unread: Set<String>) {
_unreadPrivateMessages.value = unread
}
fun setJoinedChannels(channels: Set<String>) {
_joinedChannels.value = channels
}
fun setCurrentChannel(channel: String?) {
_currentChannel.value = channel
}
fun setChannelMessages(messages: Map<String, List<BitchatMessage>>) {
_channelMessages.value = messages
}
fun setUnreadChannelMessages(unread: Map<String, Int>) {
_unreadChannelMessages.value = unread
}
fun setPasswordProtectedChannels(channels: Set<String>) {
_passwordProtectedChannels.value = channels
}
fun setShowPasswordPrompt(show: Boolean) {
_showPasswordPrompt.value = show
}
fun setPasswordPromptChannel(channel: String?) {
_passwordPromptChannel.value = channel
}
fun setShowSidebar(show: Boolean) {
_showSidebar.value = show
}
fun setShowCommandSuggestions(show: Boolean) {
_showCommandSuggestions.value = show
}
fun setCommandSuggestions(suggestions: List<CommandSuggestion>) {
_commandSuggestions.value = suggestions
}
}
@@ -0,0 +1,165 @@
package com.bitchat.android.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
import androidx.compose.material3.ColorScheme
import java.text.SimpleDateFormat
import java.util.*
/**
* Utility functions for ChatScreen UI components
* Extracted from ChatScreen.kt for better organization
*/
/**
* Get RSSI-based color for signal strength visualization
*/
fun getRSSIColor(rssi: Int): Color {
return when {
rssi >= -50 -> Color(0xFF00FF00) // Bright green
rssi >= -60 -> Color(0xFF80FF00) // Green-yellow
rssi >= -70 -> Color(0xFFFFFF00) // Yellow
rssi >= -80 -> Color(0xFFFF8000) // Orange
else -> Color(0xFFFF4444) // Red
}
}
/**
* Format message as annotated string with proper styling
*/
fun formatMessageAsAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
// Timestamp
val timestampColor = if (message.sender == "system") Color.Gray else colorScheme.primary.copy(alpha = 0.7f)
builder.pushStyle(SpanStyle(
color = timestampColor,
fontSize = 12.sp
))
builder.append("[${timeFormatter.format(message.timestamp)}] ")
builder.pop()
if (message.sender != "system") {
// Sender
val senderColor = when {
message.sender == currentUserNickname -> colorScheme.primary
else -> {
val peerID = message.senderPeerID
val rssi = peerID?.let { meshService.getPeerRSSI()[it] } ?: -60
getRSSIColor(rssi)
}
}
builder.pushStyle(SpanStyle(
color = senderColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
))
builder.append("<@${message.sender}> ")
builder.pop()
// Message content with mentions and hashtags highlighted
appendFormattedContent(builder, message.content, message.mentions, currentUserNickname, colorScheme)
} else {
// System message
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = 12.sp,
fontStyle = FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
}
return builder.toAnnotatedString()
}
/**
* Append formatted content with hashtag and mention highlighting
*/
private fun appendFormattedContent(
builder: AnnotatedString.Builder,
content: String,
mentions: List<String>?,
currentUserNickname: String,
colorScheme: ColorScheme
) {
val isMentioned = mentions?.contains(currentUserNickname) == true
// Parse hashtags and mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
val mentionPattern = "@([a-zA-Z0-9_]+)".toRegex()
val hashtagMatches = hashtagPattern.findAll(content).toList()
val mentionMatches = mentionPattern.findAll(content).toList()
// Combine and sort all matches
val allMatches = (hashtagMatches.map { it.range to "hashtag" } +
mentionMatches.map { it.range to "mention" })
.sortedBy { it.first.first }
var lastEnd = 0
for ((range, type) in allMatches) {
// Add text before the match
if (lastEnd < range.first) {
val beforeText = content.substring(lastEnd, range.first)
builder.pushStyle(SpanStyle(
color = colorScheme.primary,
fontSize = 14.sp,
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
))
builder.append(beforeText)
builder.pop()
}
// Add the styled match
val matchText = content.substring(range.first, range.last + 1)
when (type) {
"hashtag" -> {
builder.pushStyle(SpanStyle(
color = Color(0xFF0080FF), // Blue
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
))
}
"mention" -> {
builder.pushStyle(SpanStyle(
color = Color(0xFFFF8C00), // Orange
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
))
}
}
builder.append(matchText)
builder.pop()
lastEnd = range.last + 1
}
// Add remaining text
if (lastEnd < content.length) {
val remainingText = content.substring(lastEnd)
builder.pushStyle(SpanStyle(
color = colorScheme.primary,
fontSize = 14.sp,
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
))
builder.append(remainingText)
builder.pop()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
package com.bitchat.android.ui
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
/**
* Utility functions for the ChatViewModel
*/
object ChatViewModelUtils {
/**
* Trigger haptic feedback
*/
fun triggerHapticFeedback(context: Context) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
val vibrator = vibratorManager.defaultVibrator
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else {
@Suppress("DEPRECATION")
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(50)
}
}
} catch (e: Exception) {
// Silently ignore vibration errors
}
}
}
@@ -0,0 +1,381 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import java.util.*
/**
* Handles processing of IRC-style commands
*/
class CommandProcessor(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val privateChatManager: PrivateChatManager
) {
// Available commands list
private val baseCommands = listOf(
CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"),
CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"),
CommandSuggestion("/clear", emptyList(), null, "clear chat messages"),
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
CommandSuggestion("/w", emptyList(), null, "see who's online")
)
// MARK: - Command Processing
fun processCommand(command: String, meshService: Any, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean {
if (!command.startsWith("/")) return false
val parts = command.split(" ")
val cmd = parts.first()
when (cmd) {
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
"/m", "/msg" -> handleMessageCommand(parts, meshService)
"/w" -> handleWhoCommand()
"/clear" -> handleClearCommand()
"/block" -> handleBlockCommand(parts, meshService)
"/unblock" -> handleUnblockCommand(parts, meshService)
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
"/channels" -> handleChannelsCommand()
else -> handleUnknownCommand(cmd)
}
return true
}
private fun handleJoinCommand(parts: List<String>, myPeerID: String) {
if (parts.size > 1) {
val channelName = parts[1]
val channel = if (channelName.startsWith("#")) channelName else "#$channelName"
val success = channelManager.joinChannel(channel, null, myPeerID)
if (success) {
val systemMessage = BitchatMessage(
sender = "system",
content = "joined channel $channel",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "usage: /join <channel>",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
private fun handleMessageCommand(parts: List<String>, meshService: Any) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
val success = privateChatManager.startPrivateChat(peerID, meshService)
if (success) {
if (parts.size > 2) {
val messageContent = parts.drop(2).joinToString(" ")
val recipientNickname = getPeerNickname(peerID, meshService)
privateChatManager.sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService)
) { content, peerIdParam, recipientNicknameParam, messageId ->
// This would trigger the actual mesh service send
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "started private chat with $targetName",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found. they may be offline or using a different nickname.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "usage: /msg <nickname> [message]",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
private fun handleWhoCommand() {
val connectedPeers = state.getConnectedPeersValue()
val peerList = connectedPeers.joinToString(", ") { peerID ->
// This would need mesh service access for nicknames
peerID // For now just use peer ID
}
val systemMessage = BitchatMessage(
sender = "system",
content = if (connectedPeers.isEmpty()) {
"no one else is online right now."
} else {
"online users: $peerList"
},
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
private fun handleClearCommand() {
when {
state.getSelectedPrivateChatPeerValue() != null -> {
// Clear private chat
val peerID = state.getSelectedPrivateChatPeerValue()!!
messageManager.clearPrivateMessages(peerID)
}
state.getCurrentChannelValue() != null -> {
// Clear channel messages
val channel = state.getCurrentChannelValue()!!
messageManager.clearChannelMessages(channel)
}
else -> {
// Clear main messages
messageManager.clearMessages()
}
}
}
private fun handleBlockCommand(parts: List<String>, meshService: Any) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.blockPeerByNickname(targetName, meshService)
} else {
// List blocked users
val blockedInfo = privateChatManager.listBlockedUsers()
val systemMessage = BitchatMessage(
sender = "system",
content = blockedInfo,
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
private fun handleUnblockCommand(parts: List<String>, meshService: Any) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.unblockPeerByNickname(targetName, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "usage: /unblock <nickname>",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
private fun handleActionCommand(
parts: List<String>,
verb: String,
object_: String,
meshService: Any,
myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit
) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
// Send as regular message
if (state.getSelectedPrivateChatPeerValue() != null) {
val peerID = state.getSelectedPrivateChatPeerValue()!!
privateChatManager.sendPrivateMessage(
actionMessage,
peerID,
getPeerNickname(peerID, meshService),
state.getNicknameValue(),
myPeerID
) { content, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
}
} else {
val message = BitchatMessage(
sender = state.getNicknameValue() ?: myPeerID,
content = actionMessage,
timestamp = Date(),
isRelay = false,
senderPeerID = myPeerID,
channel = state.getCurrentChannelValue()
)
if (state.getCurrentChannelValue() != null) {
channelManager.addChannelMessage(state.getCurrentChannelValue()!!, message, myPeerID)
onSendMessage(actionMessage, emptyList(), state.getCurrentChannelValue())
} else {
messageManager.addMessage(message)
onSendMessage(actionMessage, emptyList(), null)
}
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "usage: /${parts[0].removePrefix("/")} <nickname>",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
private fun handleChannelsCommand() {
val allChannels = channelManager.getJoinedChannelsList()
val channelList = if (allChannels.isEmpty()) {
"no channels joined"
} else {
"joined channels: ${allChannels.joinToString(", ")}"
}
val systemMessage = BitchatMessage(
sender = "system",
content = channelList,
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
private fun handleUnknownCommand(cmd: String) {
val systemMessage = BitchatMessage(
sender = "system",
content = "unknown command: $cmd. type / to see available commands.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
// MARK: - Command Autocomplete
fun updateCommandSuggestions(input: String) {
if (!input.startsWith("/") || input.length < 1) {
state.setShowCommandSuggestions(false)
state.setCommandSuggestions(emptyList())
return
}
// Get all available commands based on context
val allCommands = getAllAvailableCommands()
// Filter commands based on input
val filteredCommands = filterCommands(allCommands, input.lowercase())
if (filteredCommands.isNotEmpty()) {
state.setCommandSuggestions(filteredCommands)
state.setShowCommandSuggestions(true)
} else {
state.setShowCommandSuggestions(false)
state.setCommandSuggestions(emptyList())
}
}
private fun getAllAvailableCommands(): List<CommandSuggestion> {
// Add channel-specific commands if in a channel
val channelCommands = if (state.getCurrentChannelValue() != null) {
listOf(
CommandSuggestion("/pass", emptyList(), "[password]", "change channel password"),
CommandSuggestion("/save", emptyList(), null, "save channel messages locally"),
CommandSuggestion("/transfer", emptyList(), "<nickname>", "transfer channel ownership")
)
} else {
emptyList()
}
return baseCommands + channelCommands
}
private fun filterCommands(commands: List<CommandSuggestion>, input: String): List<CommandSuggestion> {
return commands.filter { command ->
// Check primary command
command.command.startsWith(input) ||
// Check aliases
command.aliases.any { it.startsWith(input) }
}.sortedBy { it.command }
}
fun selectCommandSuggestion(suggestion: CommandSuggestion): String {
state.setShowCommandSuggestions(false)
state.setCommandSuggestions(emptyList())
return "${suggestion.command} "
}
// MARK: - Utility Functions (would access mesh service)
private fun getPeerIDForNickname(nickname: String, meshService: Any): String? {
return try {
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.entries?.find { it.value == nickname }?.key
} catch (e: Exception) {
null
}
}
private fun getPeerNickname(peerID: String, meshService: Any): String {
return try {
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.get(peerID) ?: peerID
} catch (e: Exception) {
peerID
}
}
private fun getMyPeerID(meshService: Any): String {
return try {
val field = meshService::class.java.getDeclaredField("myPeerID")
field.isAccessible = true
field.get(meshService) as? String ?: "unknown"
} catch (e: Exception) {
"unknown"
}
}
private fun sendPrivateMessageVia(meshService: Any, content: String, peerID: String, recipientNickname: String, messageId: String) {
try {
val method = meshService::class.java.getDeclaredMethod(
"sendPrivateMessage",
String::class.java,
String::class.java,
String::class.java,
String::class.java
)
method.invoke(meshService, content, peerID, recipientNickname, messageId)
} catch (e: Exception) {
// Handle error
}
}
}
@@ -0,0 +1,183 @@
package com.bitchat.android.ui
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import kotlin.random.Random
/**
* Handles data persistence operations for the chat system
*/
class DataManager(private val context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
private val gson = Gson()
// Channel-related maps that need to persist state
private val _channelCreators = mutableMapOf<String, String>()
private val _favoritePeers = mutableSetOf<String>()
private val _blockedUsers = mutableSetOf<String>()
private val _channelMembers = mutableMapOf<String, MutableSet<String>>()
val channelCreators: Map<String, String> get() = _channelCreators
val favoritePeers: Set<String> get() = _favoritePeers
val blockedUsers: Set<String> get() = _blockedUsers
val channelMembers: Map<String, MutableSet<String>> get() = _channelMembers
// MARK: - Nickname Management
fun loadNickname(): String {
val savedNickname = prefs.getString("nickname", null)
return if (savedNickname != null) {
savedNickname
} else {
val randomNickname = "anon${Random.nextInt(1000, 9999)}"
saveNickname(randomNickname)
randomNickname
}
}
fun saveNickname(nickname: String) {
prefs.edit().putString("nickname", nickname).apply()
}
// MARK: - Channel Data Management
fun loadChannelData(): Pair<Set<String>, Set<String>> {
// Load joined channels
val savedChannels = prefs.getStringSet("joined_channels", emptySet()) ?: emptySet()
// Load password protected channels
val savedProtectedChannels = prefs.getStringSet("password_protected_channels", emptySet()) ?: emptySet()
// Load channel creators
val creatorsJson = prefs.getString("channel_creators", "{}")
try {
val creatorsMap = gson.fromJson(creatorsJson, Map::class.java) as? Map<String, String>
creatorsMap?.let { _channelCreators.putAll(it) }
} catch (e: Exception) {
// Ignore parsing errors
}
// Initialize channel members for loaded channels
savedChannels.forEach { channel ->
if (!_channelMembers.containsKey(channel)) {
_channelMembers[channel] = mutableSetOf()
}
}
return Pair(savedChannels, savedProtectedChannels)
}
fun saveChannelData(joinedChannels: Set<String>, passwordProtectedChannels: Set<String>) {
prefs.edit().apply {
putStringSet("joined_channels", joinedChannels)
putStringSet("password_protected_channels", passwordProtectedChannels)
putString("channel_creators", gson.toJson(_channelCreators))
apply()
}
}
fun addChannelCreator(channel: String, creatorID: String) {
_channelCreators[channel] = creatorID
}
fun removeChannelCreator(channel: String) {
_channelCreators.remove(channel)
}
fun isChannelCreator(channel: String, peerID: String): Boolean {
return _channelCreators[channel] == peerID
}
// MARK: - Channel Members Management
fun addChannelMember(channel: String, peerID: String) {
if (!_channelMembers.containsKey(channel)) {
_channelMembers[channel] = mutableSetOf()
}
_channelMembers[channel]?.add(peerID)
}
fun removeChannelMember(channel: String, peerID: String) {
_channelMembers[channel]?.remove(peerID)
}
fun removeChannelMembers(channel: String) {
_channelMembers.remove(channel)
}
fun cleanupDisconnectedMembers(channel: String, connectedPeers: List<String>, myPeerID: String) {
_channelMembers[channel]?.removeAll { memberID ->
memberID != myPeerID && !connectedPeers.contains(memberID)
}
}
fun cleanupAllDisconnectedMembers(connectedPeers: List<String>, myPeerID: String) {
_channelMembers.values.forEach { members ->
members.removeAll { memberID ->
memberID != myPeerID && !connectedPeers.contains(memberID)
}
}
}
// MARK: - Favorites Management
fun loadFavorites() {
val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet()
_favoritePeers.addAll(savedFavorites)
}
fun saveFavorites() {
prefs.edit().putStringSet("favorites", _favoritePeers).apply()
}
fun addFavorite(fingerprint: String) {
_favoritePeers.add(fingerprint)
saveFavorites()
}
fun removeFavorite(fingerprint: String) {
_favoritePeers.remove(fingerprint)
saveFavorites()
}
fun isFavorite(fingerprint: String): Boolean {
return _favoritePeers.contains(fingerprint)
}
// MARK: - Blocked Users Management
fun loadBlockedUsers() {
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
_blockedUsers.addAll(savedBlockedUsers)
}
fun saveBlockedUsers() {
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
}
fun addBlockedUser(fingerprint: String) {
_blockedUsers.add(fingerprint)
saveBlockedUsers()
}
fun removeBlockedUser(fingerprint: String) {
_blockedUsers.remove(fingerprint)
saveBlockedUsers()
}
fun isUserBlocked(fingerprint: String): Boolean {
return _blockedUsers.contains(fingerprint)
}
// MARK: - Emergency Clear
fun clearAllData() {
_channelCreators.clear()
_favoritePeers.clear()
_blockedUsers.clear()
_channelMembers.clear()
prefs.edit().clear().apply()
}
}
@@ -0,0 +1,126 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/**
* Dialog components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@Composable
fun PasswordPromptDialog(
show: Boolean,
channelName: String?,
passwordInput: String,
onPasswordChange: (String) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
if (show && channelName != null) {
val colorScheme = MaterialTheme.colorScheme
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Enter Channel Password",
style = MaterialTheme.typography.titleMedium,
color = colorScheme.onSurface
)
},
text = {
Column {
Text(
text = "Channel $channelName is password protected. Enter the password to join.",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = passwordInput,
onValueChange = onPasswordChange,
label = { Text("Password", style = MaterialTheme.typography.bodyMedium) },
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = colorScheme.primary,
unfocusedBorderColor = colorScheme.outline
)
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = "Join",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = "Cancel",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
}
},
containerColor = colorScheme.surface,
tonalElevation = 8.dp
)
}
}
@Composable
fun AppInfoDialog(
show: Boolean,
onDismiss: () -> Unit
) {
if (show) {
val colorScheme = MaterialTheme.colorScheme
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "About bitchat*",
style = MaterialTheme.typography.titleMedium,
color = colorScheme.onSurface
)
},
text = {
Text(
text = "Decentralized mesh messaging over Bluetooth LE\n\n" +
"• No servers or internet required\n" +
"• End-to-end encrypted private messages\n" +
"• Password-protected channels\n" +
"• Store-and-forward for offline peers\n\n" +
"Triple-click title to emergency clear all data",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(
text = "OK",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
},
containerColor = colorScheme.surface,
tonalElevation = 8.dp
)
}
}
@@ -0,0 +1,184 @@
package com.bitchat.android.ui
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
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.SolidColor
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Input components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@Composable
fun MessageInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
verticalAlignment = Alignment.CenterVertically
) {
// Prompt
Text(
text = when {
selectedPrivatePeer != null -> "<@$nickname> →"
currentChannel != null -> "<@$nickname> →" // Could show if channel is encrypted
else -> "<@$nickname>"
},
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = when {
selectedPrivatePeer != null -> Color(0xFFFF8C00) // Orange for private
currentChannel != null -> Color(0xFFFF8C00) // Orange if encrypted channel
else -> colorScheme.primary
},
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.width(8.dp))
// Text input
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
cursorBrush = SolidColor(colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Send button - smaller with light green background
IconButton(
onClick = onSend,
modifier = Modifier.size(32.dp) // Reduced from 40dp
) {
Box(
modifier = Modifier
.size(32.dp) // Reduced size
.background(
color = Color(0xFF00C851).copy(alpha = 0.15f), // Light green background
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = "",
style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp), // Smaller arrow
color = Color(0xFF00C851) // Green arrow
)
}
}
}
}
@Composable
fun CommandSuggestionsBox(
suggestions: List<CommandSuggestion>,
onSuggestionClick: (CommandSuggestion) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.background(colorScheme.surface)
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
.padding(vertical = 8.dp)
) {
suggestions.forEach { suggestion: CommandSuggestion ->
CommandSuggestionItem(
suggestion = suggestion,
onClick = { onSuggestionClick(suggestion) }
)
}
}
}
@Composable
fun CommandSuggestionItem(
suggestion: CommandSuggestion,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color.Gray.copy(alpha = 0.1f)),
verticalAlignment = Alignment.CenterVertically
) {
// Show all aliases together
val allCommands = if (suggestion.aliases.isNotEmpty()) {
listOf(suggestion.command) + suggestion.aliases
} else {
listOf(suggestion.command)
}
Text(
text = allCommands.joinToString(", "),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium
),
color = colorScheme.primary,
fontSize = 11.sp
)
// Show syntax if any
suggestion.syntax?.let { syntax ->
Spacer(modifier = Modifier.width(8.dp))
Text(
text = syntax,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.8f),
fontSize = 10.sp
)
}
Spacer(modifier = Modifier.weight(1f))
// Show description
Text(
text = suggestion.description,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -0,0 +1,143 @@
package com.bitchat.android.ui
import androidx.lifecycle.LifecycleCoroutineScope
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryAck
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.ReadReceipt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.*
/**
* Handles all BluetoothMeshDelegate callbacks and routes them to appropriate managers
*/
class MeshDelegateHandler(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val privateChatManager: PrivateChatManager,
private val coroutineScope: CoroutineScope,
private val onHapticFeedback: () -> Unit,
private val getMyPeerID: () -> String
) : BluetoothMeshDelegate {
override fun didReceiveMessage(message: BitchatMessage) {
coroutineScope.launch {
// FIXED: Deduplicate messages from dual connection paths
val messageKey = messageManager.generateMessageKey(message)
if (messageManager.isMessageProcessed(messageKey)) {
return@launch // Duplicate message, ignore
}
messageManager.markMessageProcessed(messageKey)
// Check if sender is blocked
message.senderPeerID?.let { senderPeerID ->
if (privateChatManager.isPeerBlocked(senderPeerID)) {
return@launch
}
}
// Trigger haptic feedback
onHapticFeedback()
if (message.isPrivate) {
// Private message
privateChatManager.handleIncomingPrivateMessage(message)
} else if (message.channel != null) {
// Channel message
if (state.getJoinedChannelsValue().contains(message.channel)) {
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
}
} else {
// Public message
messageManager.addMessage(message)
}
// Periodic cleanup
if (messageManager.isMessageProcessed("cleanup_check_${System.currentTimeMillis()/30000}")) {
messageManager.cleanupDeduplicationCaches()
}
}
}
override fun didConnectToPeer(peerID: String) {
coroutineScope.launch {
// FIXED: Deduplicate connection events from dual connection paths
if (messageManager.isDuplicateSystemEvent("connect", peerID)) {
return@launch
}
val systemMessage = BitchatMessage(
sender = "system",
content = "$peerID connected",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
override fun didDisconnectFromPeer(peerID: String) {
coroutineScope.launch {
// FIXED: Deduplicate disconnection events from dual connection paths
if (messageManager.isDuplicateSystemEvent("disconnect", peerID)) {
return@launch
}
val systemMessage = BitchatMessage(
sender = "system",
content = "$peerID disconnected",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
override fun didUpdatePeerList(peers: List<String>) {
coroutineScope.launch {
state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty())
// Clean up channel members who disconnected
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
// Exit private chat if peer disconnected
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
if (!peers.contains(currentPeer)) {
privateChatManager.cleanupDisconnectedPeer(currentPeer)
}
}
}
}
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
coroutineScope.launch {
channelManager.removeChannelMember(channel, fromPeer)
}
}
override fun didReceiveDeliveryAck(ack: DeliveryAck) {
coroutineScope.launch {
messageManager.updateMessageDeliveryStatus(ack.originalMessageID, DeliveryStatus.Delivered(ack.recipientNickname, ack.timestamp))
}
}
override fun didReceiveReadReceipt(receipt: ReadReceipt) {
coroutineScope.launch {
messageManager.updateMessageDeliveryStatus(receipt.originalMessageID, DeliveryStatus.Read(receipt.readerNickname, receipt.timestamp))
}
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return channelManager.decryptChannelMessage(encryptedContent, channel)
}
override fun getNickname(): String? = state.getNicknameValue()
override fun isFavorite(peerID: String): Boolean {
return privateChatManager.isFavorite(peerID)
}
}
@@ -0,0 +1,146 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import java.text.SimpleDateFormat
import java.util.*
/**
* Message display components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@Composable
fun MessagesList(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
// Auto-scroll to bottom when new messages arrive
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
LazyColumn(
state = listState,
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(messages) { message ->
MessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService
)
}
}
}
@Composable
fun MessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService
) {
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// Single text view for natural wrapping (like iOS)
Text(
text = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
),
modifier = Modifier.weight(1f),
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible
)
// Delivery status for private messages
if (message.isPrivate && message.sender == currentUserNickname) {
message.deliveryStatus?.let { status ->
DeliveryStatusIcon(status = status)
}
}
}
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
when (status) {
is DeliveryStatus.Sending -> {
Text(
text = "",
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
}
is DeliveryStatus.Sent -> {
Text(
text = "",
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
}
is DeliveryStatus.Delivered -> {
Text(
text = "✓✓",
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.8f)
)
}
is DeliveryStatus.Read -> {
Text(
text = "✓✓",
fontSize = 10.sp,
color = Color(0xFF007AFF), // Blue
fontWeight = FontWeight.Bold
)
}
is DeliveryStatus.Failed -> {
Text(
text = "",
fontSize = 10.sp,
color = Color.Red.copy(alpha = 0.8f)
)
}
is DeliveryStatus.PartiallyDelivered -> {
Text(
text = "${status.reached}/${status.total}",
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
}
}
}
@@ -0,0 +1,250 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import java.util.*
import java.util.Collections
/**
* Handles all message-related operations including deduplication and organization
*/
class MessageManager(private val state: ChatState) {
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val MESSAGE_DEDUP_TIMEOUT = 30000L // 30 seconds
private val SYSTEM_EVENT_DEDUP_TIMEOUT = 5000L // 5 seconds
// MARK: - Public Message Management
fun addMessage(message: BitchatMessage) {
val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message)
currentMessages.sortBy { it.timestamp }
state.setMessages(currentMessages)
}
fun clearMessages() {
state.setMessages(emptyList())
}
// MARK: - Channel Message Management
fun addChannelMessage(channel: String, message: BitchatMessage) {
val currentChannelMessages = state.getChannelMessagesValue().toMutableMap()
if (!currentChannelMessages.containsKey(channel)) {
currentChannelMessages[channel] = mutableListOf()
}
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
channelMessageList.add(message)
channelMessageList.sortBy { it.timestamp }
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
// Update unread count if not currently in this channel
if (state.getCurrentChannelValue() != channel) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
state.setUnreadChannelMessages(currentUnread)
}
}
fun clearChannelMessages(channel: String) {
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
updatedChannelMessages[channel] = emptyList()
state.setChannelMessages(updatedChannelMessages)
}
fun removeChannelMessages(channel: String) {
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
updatedChannelMessages.remove(channel)
state.setChannelMessages(updatedChannelMessages)
val updatedUnread = state.getUnreadChannelMessagesValue().toMutableMap()
updatedUnread.remove(channel)
state.setUnreadChannelMessages(updatedUnread)
}
fun clearChannelUnreadCount(channel: String) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread.remove(channel)
state.setUnreadChannelMessages(currentUnread)
}
// MARK: - Private Message Management
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
currentPrivateChats[peerID] = mutableListOf()
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
chatMessages.sortBy { it.timestamp }
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(peerID)
state.setUnreadPrivateMessages(currentUnread)
}
}
fun clearPrivateMessages(peerID: String) {
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[peerID] = emptyList()
state.setPrivateChats(updatedChats)
}
fun initializePrivateChat(peerID: String) {
if (state.getPrivateChatsValue().containsKey(peerID)) return
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[peerID] = emptyList()
state.setPrivateChats(updatedChats)
}
fun clearPrivateUnreadMessages(peerID: String) {
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
updatedUnread.remove(peerID)
state.setUnreadPrivateMessages(updatedUnread)
}
// MARK: - Message Deduplication
/**
* Generate a unique key for message deduplication
*/
fun generateMessageKey(message: BitchatMessage): String {
val senderKey = message.senderPeerID ?: message.sender
val contentHash = message.content.hashCode()
return "$senderKey-${message.timestamp.time}-$contentHash"
}
/**
* Check if a message has already been processed
*/
fun isMessageProcessed(messageKey: String): Boolean {
return processedUIMessages.contains(messageKey)
}
/**
* Mark a message as processed
*/
fun markMessageProcessed(messageKey: String) {
processedUIMessages.add(messageKey)
}
/**
* Check if a system event is a duplicate within the timeout window
*/
fun isDuplicateSystemEvent(eventType: String, peerID: String): Boolean {
val now = System.currentTimeMillis()
val eventKey = "$eventType-$peerID"
val lastEvent = recentSystemEvents[eventKey]
if (lastEvent != null && (now - lastEvent) < SYSTEM_EVENT_DEDUP_TIMEOUT) {
return true // Duplicate event
}
recentSystemEvents[eventKey] = now
return false
}
/**
* Clean up old entries from deduplication caches
*/
fun cleanupDeduplicationCaches() {
val now = System.currentTimeMillis()
// Clean up processed UI messages (remove entries older than 30 seconds)
if (processedUIMessages.size > 1000) {
processedUIMessages.clear()
}
// Clean up recent system events (remove entries older than timeout)
recentSystemEvents.entries.removeAll { (_, timestamp) ->
(now - timestamp) > SYSTEM_EVENT_DEDUP_TIMEOUT * 2
}
}
// MARK: - Delivery Status Updates
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
// Update in private chats
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
var updated = false
updatedPrivateChats.forEach { (peerID, messages) ->
val updatedMessages = messages.toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
updatedPrivateChats[peerID] = updatedMessages
updated = true
}
}
if (updated) {
state.setPrivateChats(updatedPrivateChats)
}
// Update in main messages
val updatedMessages = state.getMessagesValue().toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
state.setMessages(updatedMessages)
}
// Update in channel messages
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
updatedChannelMessages.forEach { (channel, messages) ->
val channelMessagesList = messages.toMutableList()
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
if (channelMessageIndex >= 0) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status)
updatedChannelMessages[channel] = channelMessagesList
}
}
state.setChannelMessages(updatedChannelMessages)
}
// MARK: - Utility Functions
fun parseMentions(content: String, peerNicknames: Set<String>, currentNickname: String?): List<String> {
val mentionRegex = "@([a-zA-Z0-9_]+)".toRegex()
val allNicknames = peerNicknames + (currentNickname ?: "")
return mentionRegex.findAll(content)
.map { it.groupValues[1] }
.filter { allNicknames.contains(it) }
.distinct()
.toList()
}
fun parseChannels(content: String): List<String> {
val channelRegex = "#([a-zA-Z0-9_]+)".toRegex()
return channelRegex.findAll(content)
.map { it.groupValues[0] } // Include the #
.distinct()
.toList()
}
// MARK: - Emergency Clear
fun clearAllMessages() {
state.setMessages(emptyList())
state.setPrivateChats(emptyMap())
state.setChannelMessages(emptyMap())
state.setUnreadPrivateMessages(emptySet())
state.setUnreadChannelMessages(emptyMap())
processedUIMessages.clear()
recentSystemEvents.clear()
}
}
@@ -0,0 +1,271 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import java.util.*
/**
* Handles private chat functionality including peer management and blocking
*/
class PrivateChatManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager
) {
// Peer identification mapping
private val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: Any): Boolean {
if (isPeerBlocked(peerID)) {
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot start chat with $peerNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
state.setSelectedPrivateChatPeer(peerID)
// Clear unread
messageManager.clearPrivateUnreadMessages(peerID)
// Initialize chat if needed
messageManager.initializePrivateChat(peerID)
return true
}
fun endPrivateChat() {
state.setSelectedPrivateChatPeer(null)
}
fun sendPrivateMessage(
content: String,
peerID: String,
recipientNickname: String?,
senderNickname: String?,
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot send message to $recipientNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
val message = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
messageManager.addPrivateMessage(peerID, message)
onSendMessage(content, peerID, recipientNickname ?: "", message.id)
return true
}
// MARK: - Peer Management
fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
val md = java.security.MessageDigest.getInstance("SHA-256")
val hash = md.digest(publicKeyData)
val fingerprint = hash.take(8).joinToString("") { "%02x".format(it) }
peerIDToPublicKeyFingerprint[peerID] = fingerprint
}
fun isPeerBlocked(peerID: String): Boolean {
val fingerprint = peerIDToPublicKeyFingerprint[peerID]
return fingerprint != null && dataManager.isUserBlocked(fingerprint)
}
fun toggleFavorite(peerID: String) {
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return
if (dataManager.isFavorite(fingerprint)) {
dataManager.removeFavorite(fingerprint)
} else {
dataManager.addFavorite(fingerprint)
}
}
fun isFavorite(peerID: String): Boolean {
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return false
return dataManager.isFavorite(fingerprint)
}
// MARK: - Block/Unblock Operations
fun blockPeer(peerID: String, meshService: Any): Boolean {
val fingerprint = peerIDToPublicKeyFingerprint[peerID]
if (fingerprint != null) {
dataManager.addBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "blocked user $peerNickname",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
// End private chat if currently in one with this peer
if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat()
}
return true
}
return false
}
fun unblockPeer(peerID: String, meshService: Any): Boolean {
val fingerprint = peerIDToPublicKeyFingerprint[peerID]
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
dataManager.removeBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "unblocked user $peerNickname",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return true
}
return false
}
fun blockPeerByNickname(targetName: String, meshService: Any): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
return blockPeer(peerID, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
}
fun unblockPeerByNickname(targetName: String, meshService: Any): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
val fingerprint = peerIDToPublicKeyFingerprint[peerID]
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
return unblockPeer(peerID, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' is not blocked",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
}
fun listBlockedUsers(): String {
val blockedCount = dataManager.blockedUsers.size
return if (blockedCount == 0) {
"no blocked users"
} else {
"blocked users: $blockedCount fingerprints"
}
}
// MARK: - Message Handling
fun handleIncomingPrivateMessage(message: BitchatMessage) {
message.senderPeerID?.let { senderPeerID ->
if (!isPeerBlocked(senderPeerID)) {
messageManager.addPrivateMessage(senderPeerID, message)
}
}
}
fun cleanupDisconnectedPeer(peerID: String) {
// End private chat if peer disconnected
if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat()
}
}
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: Any): String? {
// This would need to access the mesh service to get peer nicknames
// For now, we'll assume the mesh service provides a way to get this mapping
return try {
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.entries?.find { it.value == nickname }?.key
} catch (e: Exception) {
null
}
}
private fun getPeerNickname(peerID: String, meshService: Any): String {
return try {
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.get(peerID) ?: peerID
} catch (e: Exception) {
peerID
}
}
// MARK: - Emergency Clear
fun clearAllPrivateChats() {
state.setSelectedPrivateChatPeer(null)
state.setUnreadPrivateMessages(emptySet())
peerIDToPublicKeyFingerprint.clear()
}
// MARK: - Public Getters
fun getPeerFingerprint(peerID: String): String? {
return peerIDToPublicKeyFingerprint[peerID]
}
fun getAllPeerFingerprints(): Map<String, String> {
return peerIDToPublicKeyFingerprint.toMap()
}
}
@@ -0,0 +1,366 @@
package com.bitchat.android.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
/**
* Sidebar components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@Composable
fun SidebarOverlay(
viewModel: ChatViewModel,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
val currentChannel by viewModel.currentChannel.observeAsState()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val nickname by viewModel.nickname.observeAsState("")
// Get peer data from mesh service
val peerNicknames = viewModel.meshService.getPeerNicknames()
val peerRSSI = viewModel.meshService.getPeerRSSI()
Box(
modifier = modifier
.background(Color.Black.copy(alpha = 0.5f))
.clickable { onDismiss() }
) {
Row(
modifier = Modifier
.fillMaxHeight()
.width(280.dp)
.align(Alignment.CenterEnd)
.clickable { /* Prevent dismissing when clicking sidebar */ }
) {
// Grey vertical bar for visual continuity (matches iOS)
Box(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(Color.Gray.copy(alpha = 0.3f))
)
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.background(colorScheme.surface)
.windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding
) {
SidebarHeader()
Divider()
// Scrollable content
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Channels section
if (joinedChannels.isNotEmpty()) {
item {
ChannelsSection(
channels = joinedChannels.toList(), // Convert Set to List
currentChannel = currentChannel,
colorScheme = colorScheme,
onChannelClick = { channel ->
viewModel.switchToChannel(channel)
onDismiss()
},
onLeaveChannel = { channel ->
viewModel.leaveChannel(channel)
}
)
}
item {
Divider(modifier = Modifier.padding(vertical = 4.dp))
}
}
// People section
item {
PeopleSection(
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID)
onDismiss()
}
)
}
}
}
}
}
}
@Composable
private fun SidebarHeader() {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.height(36.dp) // Match reduced main header height
.fillMaxWidth()
.background(colorScheme.surface.copy(alpha = 0.95f))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "YOUR NETWORK",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
fun ChannelsSection(
channels: List<String>,
currentChannel: String?,
colorScheme: ColorScheme,
onChannelClick: (String) -> Unit,
onLeaveChannel: (String) -> Unit
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Person, // Using Person icon as placeholder
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "CHANNELS",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold
)
}
channels.forEach { channel ->
val isSelected = channel == currentChannel
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onChannelClick(channel) }
.background(
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
else Color.Transparent
)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "#$channel",
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
// Leave channel button
IconButton(
onClick = { onLeaveChannel(channel) },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Leave channel",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.5f)
)
}
}
}
}
}
@Composable
fun PeopleSection(
connectedPeers: List<String>,
peerNicknames: Map<String, String>,
peerRSSI: Map<String, Int>,
nickname: String,
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Person, // Using Person icon for people
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "PEOPLE",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold
)
}
if (connectedPeers.isEmpty()) {
Text(
text = "No one connected",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
} else {
// Sort peers: favorites first, then by nickname
val sortedPeers = connectedPeers.sortedWith { peer1, peer2 ->
val isFav1 = viewModel.isFavorite(peer1)
val isFav2 = viewModel.isFavorite(peer2)
when {
isFav1 && !isFav2 -> -1
!isFav1 && isFav2 -> 1
else -> {
val name1 = if (peer1 == nickname) "You" else (peerNicknames[peer1] ?: peer1)
val name2 = if (peer2 == nickname) "You" else (peerNicknames[peer2] ?: peer2)
name1.compareTo(name2, ignoreCase = true)
}
}
}
sortedPeers.forEach { peerID ->
PeerItem(
peerID = peerID,
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
signalStrength = peerRSSI[peerID] ?: 0,
isSelected = peerID == selectedPrivatePeer,
isFavorite = viewModel.isFavorite(peerID),
colorScheme = colorScheme,
onItemClick = { onPrivateChatStart(peerID) },
onToggleFavorite = { viewModel.toggleFavorite(peerID) }
)
}
}
}
}
@Composable
private fun PeerItem(
peerID: String,
displayName: String,
signalStrength: Int,
isSelected: Boolean,
isFavorite: Boolean,
colorScheme: ColorScheme,
onItemClick: () -> Unit,
onToggleFavorite: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onItemClick() }
.background(
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
else Color.Transparent
)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Signal strength indicators
SignalStrengthIndicator(
signalStrength = signalStrength,
colorScheme = colorScheme
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
// Favorite star
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(16.dp),
tint = if (isFavorite) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f)
)
}
}
}
@Composable
private fun SignalStrengthIndicator(
signalStrength: Int,
colorScheme: ColorScheme
) {
Row(modifier = Modifier.width(24.dp)) {
repeat(3) { index ->
val opacity = when {
signalStrength >= (index + 1) * 33 -> 1f
else -> 0.2f
}
Box(
modifier = Modifier
.size(width = 3.dp, height = (4 + index * 2).dp)
.background(
colorScheme.onSurface.copy(alpha = opacity),
RoundedCornerShape(1.dp)
)
)
if (index < 2) Spacer(modifier = Modifier.width(2.dp))
}
}
}
+62
View File
@@ -0,0 +1,62 @@
# Username Color Feature Demo
The bitchat Android app already has the username color feature fully implemented! Here's how it works:
## How It Works
1. **Your username remains green** - Uses `colorScheme.primary` (bright green in dark mode, dark green in light mode)
2. **Other users get unique colors** - Based on their peer ID using the `getUsernameColor()` function
3. **Colors are consistent** - Same user always gets the same color across sessions
4. **Terminal-friendly palette** - 16 colors that work on both black and white backgrounds
## Color Palette
The system uses these 16 terminal-friendly colors for other users:
- 🟢 Bright Green (#00FF00)
- 🔵 Cyan (#00FFFF)
- 🟡 Yellow (#FFFF00)
- 🔴 Magenta (#FF00FF)
- 🟦 Bright Blue (#0080FF)
- 🟠 Orange (#FF8000)
- 🔶 Lime Green (#80FF00)
- 🟣 Purple (#8000FF)
- 🩷 Pink (#FF0080)
- 💚 Spring Green (#00FF80)
- 🟦 Light Cyan (#80FFFF)
- 🩷 Light Red (#FF8080)
- 🟦 Light Blue (#8080FF)
- 🟡 Light Yellow (#FFFF80)
- 🩷 Light Magenta (#FF80FF)
- 🟢 Light Green (#80FF80)
## Example Chat Display
```
[14:23:45] <@you> hello everyone! ← Your message (green)
[14:23:47] <@alice> hey there! ← Alice (cyan)
[14:23:50] <@bob> how's it going? ← Bob (yellow)
[14:23:52] <@charlie> great to see you all ← Charlie (magenta)
[14:23:55] <@you> having a great time ← Your message (green)
[14:23:58] <@alice> same here @you! ← Alice (cyan again)
```
## Code Implementation
The feature is implemented in `/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt`:
- Line 342-350: Color assignment logic in `formatMessageAsAnnotatedString()`
- Line 820-855: `getUsernameColor()` function that generates consistent colors
- Uses peer ID for consistency (falls back to nickname if no peer ID available)
- Integrates perfectly with the existing IRC-style chat format
## Testing
The feature is already working in the app. When you chat with multiple users, you'll see:
- Your messages in green
- Each other user in their own unique color
- Same user always has the same color
- Colors remain consistent across app restarts
- Works in both light and dark themes
The feature is **complete and ready to use**! 🎉
+73
View File
@@ -0,0 +1,73 @@
# BluetoothMeshService Refactoring Plan
## Current State
- Single file: `BluetoothMeshService.kt` (~1000+ lines)
- Multiple responsibilities mixed together
- Hard to test and maintain
## Proposed Structure
### 1. Core Service (BluetoothMeshService.kt)
**Responsibilities:**
- Service lifecycle management
- Coordination between components
- Public API for sending messages
- Delegate management
### 2. Connection Management (BluetoothConnectionManager.kt)
**Responsibilities:**
- BLE scanning and advertising
- GATT server/client setup and management
- Device connection tracking
- Peer discovery and RSSI tracking
### 3. Packet Processing (PacketProcessor.kt)
**Responsibilities:**
- Incoming packet handling
- Message type routing
- TTL and duplicate detection
- Timestamp validation
### 4. Message Handler (MessageHandler.kt)
**Responsibilities:**
- Processing specific message types (ANNOUNCE, MESSAGE, LEAVE, etc.)
- Message parsing and validation
- Relay logic
### 5. Fragment Manager (FragmentManager.kt)
**Responsibilities:**
- Message fragmentation for large messages
- Fragment reassembly
- Fragment cleanup and timeouts
### 6. Store-and-Forward Manager (StoreForwardManager.kt)
**Responsibilities:**
- Message caching for offline peers
- Delivering cached messages when peers come online
- Cache cleanup and management
### 7. Peer Manager (PeerManager.kt)
**Responsibilities:**
- Active peer tracking
- Peer nickname management
- Stale peer cleanup
- Peer list updates
### 8. Security Manager (SecurityManager.kt)
**Responsibilities:**
- Key exchange handling
- Message signing and verification
- Duplicate detection tracking
## Refactoring Strategy
1. Extract each component while maintaining exact functionality
2. Use dependency injection for component communication
3. Ensure all existing tests pass
4. Maintain the same public API
## Benefits
- Easier to test individual components
- Better separation of concerns
- More maintainable code
- Easier to add new features
- Better code reuse
+51
View File
@@ -0,0 +1,51 @@
import androidx.compose.ui.graphics.Color
/**
* Generate a consistent color for a username based on their peer ID or nickname
* Returns colors that work well on both light and dark backgrounds
*/
fun getUsernameColor(identifier: String): Color {
// Hash the identifier to get a consistent number
val hash = identifier.hashCode().toUInt()
// Terminal-friendly colors that work on both black and white backgrounds
val colors = listOf(
Color(0xFF00FF00), // Bright Green
Color(0xFF00FFFF), // Cyan
Color(0xFFFFFF00), // Yellow
Color(0xFFFF00FF), // Magenta
Color(0xFF0080FF), // Bright Blue
Color(0xFFFF8000), // Orange
Color(0xFF80FF00), // Lime Green
Color(0xFF8000FF), // Purple
Color(0xFFFF0080), // Pink
Color(0xFF00FF80), // Spring Green
Color(0xFF80FFFF), // Light Cyan
Color(0xFFFF8080), // Light Red
Color(0xFF8080FF), // Light Blue
Color(0xFFFFFF80), // Light Yellow
Color(0xFFFF80FF), // Light Magenta
Color(0xFF80FF80), // Light Green
)
// Use modulo to get consistent color for same identifier
return colors[(hash % colors.size.toUInt()).toInt()]
}
fun main() {
println("Testing username color function:")
val testUsers = listOf("alice", "bob", "charlie", "diana", "eve")
testUsers.forEach { user ->
val color = getUsernameColor(user)
println("User '$user' gets color: ${color.value.toString(16).uppercase()}")
}
// Test consistency - same user should always get same color
println("\nTesting consistency:")
repeat(3) {
val aliceColor = getUsernameColor("alice")
println("Alice color (test ${it + 1}): ${aliceColor.value.toString(16).uppercase()}")
}
}