mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
@@ -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
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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**! 🎉
|
||||
@@ -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
|
||||
@@ -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()}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user