Merge branch 'main' into fix/android-ios-fragmentation-compatibility

This commit is contained in:
callebtc
2025-07-13 21:25:11 +02:00
committed by GitHub
68 changed files with 2102 additions and 969 deletions
@@ -17,6 +17,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.ViewModelProvider
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.onboarding.*
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
@@ -29,17 +33,31 @@ class MainActivity : ComponentActivity() {
private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator
private lateinit var bluetoothStatusManager: BluetoothStatusManager
private val chatViewModel: ChatViewModel by viewModels()
private lateinit var locationStatusManager: LocationStatusManager
// Core mesh service - managed at app level
private lateinit var meshService: BluetoothMeshService
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService) as T
}
}
}
// UI state for onboarding flow
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
private var bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED)
private var locationStatus by mutableStateOf(LocationStatus.ENABLED)
private var errorMessage by mutableStateOf("")
private var isBluetoothLoading by mutableStateOf(false)
private var isLocationLoading by mutableStateOf(false)
enum class OnboardingState {
CHECKING,
BLUETOOTH_CHECK,
LOCATION_CHECK,
PERMISSION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
@@ -50,6 +68,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize core mesh service first
meshService = BluetoothMeshService(this)
// Initialize permission management
permissionManager = PermissionManager(this)
bluetoothStatusManager = BluetoothStatusManager(
@@ -58,6 +79,12 @@ class MainActivity : ComponentActivity() {
onBluetoothEnabled = ::handleBluetoothEnabled,
onBluetoothDisabled = ::handleBluetoothDisabled
)
locationStatusManager = LocationStatusManager(
activity = this,
context = this,
onLocationEnabled = ::handleLocationEnabled,
onLocationDisabled = ::handleLocationDisabled
)
onboardingCoordinator = OnboardingCoordinator(
activity = this,
permissionManager = permissionManager,
@@ -101,15 +128,26 @@ class MainActivity : ComponentActivity() {
)
}
OnboardingState.LOCATION_CHECK -> {
LocationCheckScreen(
status = locationStatus,
onEnableLocation = {
isLocationLoading = true
locationStatusManager.requestEnableLocation()
},
onRetry = {
checkLocationAndProceed()
},
isLoading = isLocationLoading
)
}
OnboardingState.PERMISSION_EXPLANATION -> {
PermissionExplanationScreen(
permissionCategories = permissionManager.getCategorizedPermissions(),
onContinue = {
onboardingState = OnboardingState.PERMISSION_REQUESTING
onboardingCoordinator.requestPermissions()
},
onCancel = {
finish()
}
)
}
@@ -123,6 +161,24 @@ class MainActivity : ComponentActivity() {
}
OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Let ChatViewModel handle navigation state
val handled = chatViewModel.handleBackPressed()
if (!handled) {
// If ChatViewModel doesn't handle it, disable this callback
// and let the system handle it (which will exit the app)
this.isEnabled = false
onBackPressedDispatcher.onBackPressed()
this.isEnabled = true
}
}
}
// Add the callback - this will be automatically removed when the activity is destroyed
onBackPressedDispatcher.addCallback(this, backCallback)
ChatScreen(viewModel = chatViewModel)
}
@@ -157,7 +213,7 @@ class MainActivity : ComponentActivity() {
* Check Bluetooth status and proceed with onboarding flow
*/
private fun checkBluetoothAndProceed() {
android.util.Log.d("MainActivity", "Checking Bluetooth status")
// android.util.Log.d("MainActivity", "Checking Bluetooth status")
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
@@ -173,8 +229,8 @@ class MainActivity : ComponentActivity() {
when (bluetoothStatus) {
BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, proceed with permission/onboarding check
proceedWithPermissionCheck()
// Bluetooth is enabled, check location services next
checkLocationAndProceed()
}
BluetoothStatus.DISABLED -> {
// Show Bluetooth enable screen (should have permissions as existing user)
@@ -221,8 +277,77 @@ class MainActivity : ComponentActivity() {
android.util.Log.d("MainActivity", "Bluetooth enabled by user")
isBluetoothLoading = false
bluetoothStatus = BluetoothStatus.ENABLED
checkLocationAndProceed()
}
/**
* Check Location services status and proceed with onboarding flow
*/
private fun checkLocationAndProceed() {
android.util.Log.d("MainActivity", "Checking location services status")
// For first-time users, skip location check and go straight to permissions
// We'll check location after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
android.util.Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
proceedWithPermissionCheck()
return
}
// For existing users, check location status
locationStatusManager.logLocationStatus()
locationStatus = locationStatusManager.checkLocationStatus()
when (locationStatus) {
LocationStatus.ENABLED -> {
// Location services enabled, proceed with permission/onboarding check
proceedWithPermissionCheck()
}
LocationStatus.DISABLED -> {
// Show location enable screen (should have permissions as existing user)
android.util.Log.d("MainActivity", "Location services disabled, showing enable screen")
onboardingState = OnboardingState.LOCATION_CHECK
isLocationLoading = false
}
LocationStatus.NOT_AVAILABLE -> {
// Device doesn't support location services (very unusual)
android.util.Log.e("MainActivity", "Location services not available")
onboardingState = OnboardingState.LOCATION_CHECK
isLocationLoading = false
}
}
}
/**
* Handle Location enabled callback
*/
private fun handleLocationEnabled() {
android.util.Log.d("MainActivity", "Location services enabled by user")
isLocationLoading = false
locationStatus = LocationStatus.ENABLED
proceedWithPermissionCheck()
}
/**
* Handle Location disabled callback
*/
private fun handleLocationDisabled(message: String) {
android.util.Log.w("MainActivity", "Location services disabled or failed: $message")
isLocationLoading = false
locationStatus = locationStatusManager.checkLocationStatus()
when {
locationStatus == LocationStatus.NOT_AVAILABLE -> {
// Show permanent error for devices without location services
errorMessage = message
onboardingState = OnboardingState.ERROR
}
else -> {
// Stay on location check screen for retry
onboardingState = OnboardingState.LOCATION_CHECK
}
}
}
/**
* Handle Bluetooth disabled callback
@@ -257,20 +382,33 @@ class MainActivity : ComponentActivity() {
}
private fun handleOnboardingComplete() {
android.util.Log.d("MainActivity", "Onboarding completed, checking Bluetooth again before initializing app")
android.util.Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
// After permissions are granted, re-check Bluetooth status
// After permissions are granted, re-check both Bluetooth and Location status
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
if (currentBluetoothStatus == BluetoothStatus.ENABLED) {
// Bluetooth is enabled, proceed to app initialization
onboardingState = OnboardingState.INITIALIZING
initializeApp()
} else {
// Bluetooth still disabled, but now we have permissions to enable it
android.util.Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
bluetoothStatus = currentBluetoothStatus
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
val currentLocationStatus = locationStatusManager.checkLocationStatus()
when {
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it
android.util.Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
bluetoothStatus = currentBluetoothStatus
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
}
currentLocationStatus != LocationStatus.ENABLED -> {
// Location services still disabled, but now we have permissions to enable it
android.util.Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
locationStatus = currentLocationStatus
onboardingState = OnboardingState.LOCATION_CHECK
isLocationLoading = false
}
else -> {
// Both are enabled, proceed to app initialization
android.util.Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
onboardingState = OnboardingState.INITIALIZING
initializeApp()
}
}
}
@@ -289,7 +427,7 @@ class MainActivity : ComponentActivity() {
// This solves the issue where app needs restart to work on first install
delay(1000) // Give the system time to process permission grants
android.util.Log.d("MainActivity", "Permissions verified, starting mesh service")
android.util.Log.d("MainActivity", "Permissions verified, initializing chat system")
// Ensure all permissions are still granted (user might have revoked in settings)
if (!permissionManager.areAllPermissionsGranted()) {
@@ -299,8 +437,11 @@ class MainActivity : ComponentActivity() {
return@launch
}
// Initialize chat view model - this will start the mesh service
chatViewModel.meshService.startServices()
// Set up mesh service delegate and start services
meshService.delegate = chatViewModel
meshService.startServices()
android.util.Log.d("MainActivity", "Mesh service started successfully")
// Handle any notification intent
handleNotificationIntent(intent)
@@ -318,18 +459,20 @@ class MainActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle notification intents when app is already running
if (onboardingState == OnboardingState.COMPLETE) {
intent?.let { handleNotificationIntent(it) }
handleNotificationIntent(intent)
}
}
override fun onResume() {
super.onResume()
// Check Bluetooth status on resume and handle accordingly
// Check Bluetooth and Location status on resume and handle accordingly
if (onboardingState == OnboardingState.COMPLETE) {
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
// Check if Bluetooth was disabled while app was backgrounded
@@ -339,6 +482,16 @@ class MainActivity : ComponentActivity() {
bluetoothStatus = currentBluetoothStatus
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
return
}
// Check if location services were disabled while app was backgrounded
val currentLocationStatus = locationStatusManager.checkLocationStatus()
if (currentLocationStatus != LocationStatus.ENABLED) {
android.util.Log.w("MainActivity", "Location services disabled while app was backgrounded")
locationStatus = currentLocationStatus
onboardingState = OnboardingState.LOCATION_CHECK
isLocationLoading = false
}
}
}
@@ -347,6 +500,8 @@ class MainActivity : ComponentActivity() {
super.onPause()
// Only set background state if app is fully initialized
if (onboardingState == OnboardingState.COMPLETE) {
// Set app background state
meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
}
}
@@ -376,12 +531,41 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Restart mesh services (for debugging/troubleshooting)
*/
fun restartMeshServices() {
if (onboardingState == OnboardingState.COMPLETE) {
lifecycleScope.launch {
try {
android.util.Log.d("MainActivity", "Restarting mesh services")
meshService.stopServices()
delay(1000)
meshService.startServices()
android.util.Log.d("MainActivity", "Mesh services restarted successfully")
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Error restarting mesh services: ${e.message}")
}
}
}
}
override fun onDestroy() {
super.onDestroy()
// Only stop mesh services if they were started
// Cleanup location status manager
try {
locationStatusManager.cleanup()
android.util.Log.d("MainActivity", "Location status manager cleaned up successfully")
} catch (e: Exception) {
android.util.Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
}
// Stop mesh services if app was fully initialized
if (onboardingState == OnboardingState.COMPLETE) {
try {
chatViewModel.meshService.stopServices()
meshService.stopServices()
android.util.Log.d("MainActivity", "Mesh services stopped successfully")
} catch (e: Exception) {
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
}
@@ -9,6 +9,8 @@ import android.os.ParcelUuid
import android.util.Log
import androidx.core.app.ActivityCompat
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
@@ -54,6 +56,7 @@ class BluetoothConnectionManager(
// Simplified connection tracking - reduced memory footprint
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
public val addressPeerMap = ConcurrentHashMap<String, String>()
// Connection attempt tracking with automatic cleanup
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
@@ -129,15 +132,17 @@ class BluetoothConnectionManager(
try {
isActive = true
// Setup GATT server first
setupGattServer()
// Start power manager and services
connectionScope.launch {
powerManager.start()
delay(500) // Ensure GATT server is ready
delay(300) // Brief delay to ensure GATT server is ready
startAdvertising()
delay(200)
delay(100)
if (powerManager.shouldUseDutyCycle()) {
Log.i(TAG, "Using power-aware duty cycling")
@@ -147,13 +152,14 @@ class BluetoothConnectionManager(
startPeriodicCleanup()
Log.i(TAG, "Power-optimized Bluetooth services started successfully (CLIENT ONLY)")
Log.i(TAG, "Bluetooth services started successfully")
}
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to start Bluetooth services: ${e.message}")
isActive = false
return false
}
}
@@ -197,11 +203,50 @@ class BluetoothConnectionManager(
powerManager.setAppBackgroundState(inBackground)
}
// Function to send data to a single device (server side)
private fun notifyDevice(device: BluetoothDevice, data: ByteArray): Boolean {
return try {
characteristic?.let { char ->
char.value = data
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
subscribedDevices.remove(device)
addressPeerMap.remove(device.address)
}
false
}
}
// Function to send data to a single device (client side)
private fun writeToDeviceConn(deviceConn: DeviceConnection, data: ByteArray): Boolean {
return try {
deviceConn.characteristic?.let { char ->
char.value = data
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
cleanupDeviceConnection(deviceConn.device.address)
}
false
}
}
/**
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
*/
fun broadcastPacket(packet: BitchatPacket) {
fun broadcastPacket(routed: RoutedPacket) {
val packet = routed.packet
if (!isActive) return
// Check if we need to fragment
@@ -229,40 +274,61 @@ class BluetoothConnectionManager(
*/
private fun sendSinglePacket(packet: BitchatPacket) {
val data = packet.toBinaryData() ?: return
Log.d(TAG, "Sending packet type ${packet.type} (${data.size} bytes) to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
if (packet.recipientID != SpecialRecipients.BROADCAST) {
val recipientID = packet.recipientID?.let {
String(it).replace("\u0000", "").trim()
} ?: ""
// Try to find the recipient in server connections (subscribedDevices)
val targetDevice = subscribedDevices.firstOrNull { addressPeerMap[it.address] == recipientID }
// If found, send directly
if (targetDevice != null) {
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
if (notifyDevice(targetDevice, data))
return // Sent, no need to continue
}
// Try to find the recipient in client connections (connectedDevices)
val targetDeviceConn = connectedDevices.values.firstOrNull { addressPeerMap[it.device.address] == recipientID }
// If found, send directly
if (targetDeviceConn != null) {
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
if (writeToDeviceConn(targetDeviceConn, data))
return // Sent, no need to continue
}
}
// Else, continue with broadcasting to all devices
Log.d(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
val senderID = String(packet.senderID).replace("\u0000", "")
// Send to server connections (devices connected to our GATT server)
subscribedDevices.forEach { device ->
try {
characteristic?.let { char ->
char.value = data
gattServer?.notifyCharacteristicChanged(device, char, false)
}
} catch (e: Exception) {
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
// Clean up failed connection
connectionScope.launch {
delay(CLEANUP_DELAY)
subscribedDevices.remove(device)
}
if (device.address == routed.relayAddress) {
Log.d(TAG, "Skipping broadcast back to relayer: ${device.address}")
return@forEach
}
if (addressPeerMap[device.address] == senderID) {
Log.d(TAG, "Skipping broadcast back to sender: ${device.address}")
return@forEach
}
notifyDevice(device, data)
}
// Send to client connections
connectedDevices.values.forEach { deviceConn ->
if (deviceConn.isClient && deviceConn.gatt != null && deviceConn.characteristic != null) {
try {
deviceConn.characteristic.value = data
deviceConn.gatt.writeCharacteristic(deviceConn.characteristic)
} catch (e: Exception) {
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
// Clean up failed connection
connectionScope.launch {
delay(CLEANUP_DELAY)
cleanupDeviceConnection(deviceConn.device.address)
}
if (deviceConn.device.address == routed.relayAddress) {
Log.d(TAG, "Skipping broadcast back to relayer: ${deviceConn.device.address}")
return@forEach
}
if (addressPeerMap[deviceConn.device.address] == senderID) {
Log.d(TAG, "Skipping broadcast back to sender: ${deviceConn.device.address}")
return@forEach
}
writeToDeviceConn(deviceConn, data)
}
}
}
@@ -277,7 +343,8 @@ class BluetoothConnectionManager(
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Power-Optimized Bluetooth Connection Manager ===")
appendLine("=== Bluetooth Connection Manager ===")
appendLine("Bluetooth MAC Address: ${bluetoothAdapter?.address}")
appendLine("Active: $isActive")
appendLine("Bluetooth Enabled: ${bluetoothAdapter?.isEnabled}")
appendLine("Has Permissions: ${hasBluetoothPermissions()}")
@@ -381,6 +448,12 @@ class BluetoothConnectionManager(
val serverCallback = object : BluetoothGattServerCallback() {
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
// Guard against callbacks after service shutdown
if (!isActive) {
Log.d(TAG, "Server: Ignoring connection state change after shutdown")
return
}
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Server: Device connected ${device.address}")
@@ -397,6 +470,20 @@ class BluetoothConnectionManager(
}
}
override fun onServiceAdded(status: Int, service: BluetoothGattService) {
// Guard against callbacks after service shutdown
if (!isActive) {
Log.d(TAG, "Server: Ignoring service added callback after shutdown")
return
}
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d(TAG, "Server: Service added successfully: ${service.uuid}")
} else {
Log.e(TAG, "Server: Failed to add service: ${service.uuid}, status: $status")
}
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice,
requestId: Int,
@@ -406,11 +493,22 @@ class BluetoothConnectionManager(
offset: Int,
value: ByteArray
) {
// Guard against callbacks after service shutdown
if (!isActive) {
Log.d(TAG, "Server: Ignoring characteristic write after shutdown")
return
}
if (characteristic.uuid == CHARACTERISTIC_UUID) {
Log.d(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = String(packet.senderID).replace("\u0000", "")
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, device)
} else {
Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
}
if (responseNeeded) {
@@ -428,13 +526,21 @@ class BluetoothConnectionManager(
offset: Int,
value: ByteArray
) {
// Guard against callbacks after service shutdown
if (!isActive) {
Log.d(TAG, "Server: Ignoring descriptor write after shutdown")
return
}
if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) {
Log.d(TAG, "Device ${device.address} subscribed to notifications")
subscribedDevices.add(device)
connectionScope.launch {
delay(100)
delegate?.onDeviceConnected(device)
if (isActive) { // Check if still active
delegate?.onDeviceConnected(device)
}
}
}
@@ -444,9 +550,25 @@ class BluetoothConnectionManager(
}
}
// Clean up existing server
gattServer?.close()
// Proper cleanup sequencing to prevent race conditions
gattServer?.let { server ->
Log.d(TAG, "Cleaning up existing GATT server")
try {
server.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing existing GATT server: ${e.message}")
}
}
// Small delay to ensure cleanup is complete
Thread.sleep(100)
if (!isActive) {
Log.d(TAG, "Service inactive, skipping GATT server creation")
return
}
// Create new server
gattServer = bluetoothManager.openGattServer(context, serverCallback)
// Create characteristic with notification support
@@ -554,8 +676,7 @@ class BluetoothConnectionManager(
// DEBUG: Log ALL scan results first
val device = result.device
val rssi = result.rssi
val scanRecord = result.scanRecord
Log.d(TAG, "Scan result: device: ${device.address}, Name: '${device.name}', RSSI: $rssi")
// Log.d(TAG, "Scan result: device: ${device.address}, Name: '${device.name}', RSSI: $rssi")
handleScanResult(result)
}
@@ -642,9 +763,7 @@ class BluetoothConnectionManager(
} else {
null
}
Log.d(TAG, "Processing bitchat device: $deviceAddress, name: '$deviceName', peerID: $extractedPeerID, RSSI: $rssi")
// Power-aware RSSI filtering
if (rssi < powerManager.getRSSIThreshold()) {
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
@@ -654,7 +773,7 @@ class BluetoothConnectionManager(
// CRITICAL FIX: Prevent multiple simultaneous connections to same device
// Check if already connected OR already attempting to connect
if (connectedDevices.containsKey(deviceAddress)) {
Log.d(TAG, "Device $deviceAddress already connected, skipping")
// Log.d(TAG, "Device $deviceAddress already connected, skipping")
return
}
@@ -685,12 +804,6 @@ class BluetoothConnectionManager(
val attempts = (currentAttempt?.attempts ?: 0) + 1
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
if (extractedPeerID != null) {
Log.i(TAG, "Initiating connection to peer $extractedPeerID at $deviceAddress (RSSI: $rssi, attempt: $attempts)")
} else {
Log.i(TAG, "Initiating connection to device with bitchat service at $deviceAddress (RSSI: $rssi, attempt: $attempts)")
}
// Start connection immediately while holding lock
connectToDevice(device, rssi)
}
@@ -701,6 +814,7 @@ class BluetoothConnectionManager(
if (!hasBluetoothPermissions()) return
val deviceAddress = device.address
Log.d(TAG, "Connecting to bitchat device: $deviceAddress")
val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
@@ -763,9 +877,7 @@ class BluetoothConnectionManager(
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
Log.d(TAG, "Client: Service discovery completed for $deviceAddress with status: $status")
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
@@ -808,25 +920,30 @@ class BluetoothConnectionManager(
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
val value = characteristic.value
Log.d(TAG, "Client: Received packet from ${gatt.device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = String(packet.senderID).replace("\u0000", "")
Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, gatt.device)
} else {
Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
}
}
}
try {
Log.d(TAG, "Attempting GATT connection to $deviceAddress with autoConnect=false")
Log.d(TAG, "Client: Attempting GATT connection to $deviceAddress with autoConnect=false")
val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
if (gatt == null) {
Log.e(TAG, "connectGatt returned null for $deviceAddress")
pendingConnections.remove(deviceAddress)
} else {
Log.d(TAG, "GATT connection initiated successfully for $deviceAddress")
Log.d(TAG, "Client: GATT connection initiated successfully for $deviceAddress")
}
} catch (e: Exception) {
Log.e(TAG, "Exception connecting to $deviceAddress: ${e.message}")
Log.e(TAG, "Client: Exception connecting to $deviceAddress: ${e.message}")
pendingConnections.remove(deviceAddress)
}
}
@@ -879,6 +996,7 @@ class BluetoothConnectionManager(
private fun cleanupDeviceConnection(deviceAddress: String) {
connectedDevices.remove(deviceAddress)?.let { deviceConn ->
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
}
// CRITICAL FIX: Always remove from pending connections when cleaning up
// This prevents failed connections from blocking future attempts
@@ -907,6 +1025,7 @@ class BluetoothConnectionManager(
private fun clearAllConnections() {
connectedDevices.clear()
subscribedDevices.clear()
addressPeerMap.clear()
pendingConnections.clear()
}
}
@@ -5,6 +5,7 @@ import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.crypto.MessagePadding
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.DeliveryAck
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.protocol.BitchatPacket
@@ -47,6 +48,9 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
// Service state management
private var isActive = false
// Delegate for message callbacks (maintains same interface)
var delegate: BluetoothMeshDelegate? = null
@@ -66,10 +70,10 @@ class BluetoothMeshService(private val context: Context) {
while (isActive) {
try {
delay(10000) // 10 seconds
val debugInfo = getDebugStatus()
Log.d(TAG, "=== PERIODIC DEBUG STATUS ===")
Log.d(TAG, debugInfo)
Log.d(TAG, "=== END DEBUG STATUS ===")
if (isActive) { // Double-check before logging
val debugInfo = getDebugStatus()
Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===")
}
} catch (e: Exception) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
}
@@ -98,7 +102,14 @@ class BluetoothMeshService(private val context: Context) {
// SecurityManager delegate for key exchange notifications
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray, receivedAddress: String?) {
// Notify delegate about key exchange completion so it can register peer fingerprint
delegate?.registerPeerPublicKey(peerID, peerPublicKeyData)
receivedAddress?.let { address ->
connectionManager.addressPeerMap[address] = peerID
}
// Send announcement and cached messages after key exchange
serviceScope.launch {
delay(100)
@@ -121,7 +132,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun sendPacket(packet: BitchatPacket) {
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
}
}
@@ -154,11 +165,11 @@ class BluetoothMeshService(private val context: Context) {
// Packet operations
override fun sendPacket(packet: BitchatPacket) {
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
}
override fun relayPacket(packet: BitchatPacket) {
connectionManager.broadcastPacket(packet)
override fun relayPacket(routed: RoutedPacket) {
connectionManager.broadcastPacket(routed)
}
override fun getBroadcastRecipient(): ByteArray {
@@ -215,32 +226,32 @@ class BluetoothMeshService(private val context: Context) {
peerManager.updatePeerLastSeen(peerID)
}
override fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean {
return runBlocking { securityManager.handleKeyExchange(packet, peerID) }
override fun handleKeyExchange(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleKeyExchange(routed) }
}
override fun handleAnnounce(packet: BitchatPacket, peerID: String) {
serviceScope.launch { messageHandler.handleAnnounce(packet, peerID) }
override fun handleAnnounce(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleAnnounce(routed) }
}
override fun handleMessage(packet: BitchatPacket, peerID: String) {
serviceScope.launch { messageHandler.handleMessage(packet, peerID) }
override fun handleMessage(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleMessage(routed) }
}
override fun handleLeave(packet: BitchatPacket, peerID: String) {
serviceScope.launch { messageHandler.handleLeave(packet, peerID) }
override fun handleLeave(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleLeave(routed) }
}
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
return fragmentManager.handleFragment(packet)
}
override fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
serviceScope.launch { messageHandler.handleDeliveryAck(packet, peerID) }
override fun handleDeliveryAck(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleDeliveryAck(routed) }
}
override fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
serviceScope.launch { messageHandler.handleReadReceipt(packet, peerID) }
override fun handleReadReceipt(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleReadReceipt(routed) }
}
override fun sendAnnouncementToPeer(peerID: String) {
@@ -251,15 +262,15 @@ class BluetoothMeshService(private val context: Context) {
storeForwardManager.sendCachedMessages(peerID)
}
override fun relayPacket(packet: BitchatPacket) {
connectionManager.broadcastPacket(packet)
override fun relayPacket(routed: RoutedPacket) {
connectionManager.broadcastPacket(routed)
}
}
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
packetProcessor.processPacket(packet, peerID)
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
@@ -276,11 +287,16 @@ class BluetoothMeshService(private val context: Context) {
* Start the mesh service
*/
fun startServices() {
// Prevent double starts (defensive programming)
if (isActive) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
if (connectionManager.startServices()) {
Log.i(TAG, "Bluetooth services started successfully")
isActive = true
// Send initial announcements after services are ready
serviceScope.launch {
delay(1000)
@@ -295,7 +311,13 @@ class BluetoothMeshService(private val context: Context) {
* Stop all mesh services
*/
fun stopServices() {
if (!isActive) {
Log.w(TAG, "Mesh service not active, ignoring stop request")
return
}
Log.i(TAG, "Stopping Bluetooth mesh service")
isActive = false
// Send leave announcement
sendLeaveAnnouncement()
@@ -351,7 +373,7 @@ class BluetoothMeshService(private val context: Context) {
// Send with random delay and retry for reliability
// delay(Random.nextLong(50, 500))
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
}
}
}
@@ -404,7 +426,7 @@ class BluetoothMeshService(private val context: Context) {
// Send with delay
delay(Random.nextLong(50, 500))
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
}
} catch (e: Exception) {
@@ -430,13 +452,13 @@ class BluetoothMeshService(private val context: Context) {
// Send multiple times for reliability
delay(Random.nextLong(0, 500))
connectionManager.broadcastPacket(announcePacket)
connectionManager.broadcastPacket(RoutedPacket(announcePacket))
delay(500 + Random.nextLong(0, 500))
connectionManager.broadcastPacket(announcePacket)
connectionManager.broadcastPacket(RoutedPacket(announcePacket))
delay(1000 + Random.nextLong(0, 500))
connectionManager.broadcastPacket(announcePacket)
connectionManager.broadcastPacket(RoutedPacket(announcePacket))
}
}
@@ -454,7 +476,7 @@ class BluetoothMeshService(private val context: Context) {
payload = nickname.toByteArray()
)
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
peerManager.markPeerAsAnnouncedTo(peerID)
}
@@ -470,7 +492,7 @@ class BluetoothMeshService(private val context: Context) {
payload = publicKeyData
)
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
Log.d(TAG, "Sent key exchange")
}
@@ -486,7 +508,7 @@ class BluetoothMeshService(private val context: Context) {
payload = nickname.toByteArray()
)
connectionManager.broadcastPacket(packet)
connectionManager.broadcastPacket(RoutedPacket(packet))
}
/**
@@ -547,4 +569,5 @@ interface BluetoothMeshDelegate {
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray)
}
@@ -5,6 +5,7 @@ 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.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.*
@@ -30,7 +31,10 @@ class MessageHandler(private val myPeerID: String) {
/**
* Handle announce message
*/
suspend fun handleAnnounce(packet: BitchatPacket, peerID: String): Boolean {
suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return false
val nickname = String(packet.payload, Charsets.UTF_8)
@@ -43,7 +47,7 @@ class MessageHandler(private val myPeerID: String) {
if (packet.ttl > 1u) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delay(Random.nextLong(100, 300))
delegate?.relayPacket(relayPacket)
delegate?.relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
}
return isFirstAnnounce
@@ -52,27 +56,31 @@ class MessageHandler(private val myPeerID: String) {
/**
* Handle broadcast or private message
*/
suspend fun handleMessage(packet: BitchatPacket, peerID: String) {
suspend fun handleMessage(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return
val recipientID = packet.recipientID?.takeIf { !it.contentEquals(delegate?.getBroadcastRecipient()) }
if (recipientID == null) {
// BROADCAST MESSAGE
handleBroadcastMessage(packet, peerID)
handleBroadcastMessage(routed)
} else if (String(recipientID).replace("\u0000", "") == myPeerID) {
// PRIVATE MESSAGE FOR US
handlePrivateMessage(packet, peerID)
} else if (packet.ttl > 0u) {
// RELAY MESSAGE
relayMessage(packet)
relayMessage(routed)
}
}
/**
* Handle broadcast message
*/
private suspend fun handleBroadcastMessage(packet: BitchatPacket, peerID: String) {
private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
try {
// Parse message
val message = BitchatMessage.fromBinaryPayload(packet.payload)
@@ -104,7 +112,7 @@ class MessageHandler(private val myPeerID: String) {
}
// Relay broadcast messages
relayMessage(packet)
relayMessage(routed)
} catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
@@ -162,7 +170,9 @@ class MessageHandler(private val myPeerID: String) {
/**
* Handle leave message
*/
suspend fun handleLeave(packet: BitchatPacket, peerID: String) {
suspend fun handleLeave(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
val content = String(packet.payload, Charsets.UTF_8)
if (content.startsWith("#")) {
@@ -180,14 +190,16 @@ class MessageHandler(private val myPeerID: String) {
// Relay if TTL > 0
if (packet.ttl > 1u) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
delegate?.relayPacket(routed.copy(packet = relayPacket))
}
}
/**
* Handle delivery acknowledgment
*/
suspend fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
suspend fun handleDeliveryAck(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
try {
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
@@ -200,17 +212,19 @@ class MessageHandler(private val myPeerID: String) {
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt delivery ACK: ${e.message}")
}
} else if (packet.ttl > 0u) {
} else if (packet.ttl > 0u && String(packet.senderID).replace("\u0000", "") != myPeerID) {
// Relay
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
delegate?.relayPacket(routed.copy(packet = relayPacket))
}
}
/**
* Handle read receipt
*/
suspend fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
suspend fun handleReadReceipt(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
try {
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
@@ -223,17 +237,18 @@ class MessageHandler(private val myPeerID: String) {
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt read receipt: ${e.message}")
}
} else if (packet.ttl > 0u) {
} else if (packet.ttl > 0u && String(packet.senderID).replace("\u0000", "") != myPeerID) {
// Relay
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
delegate?.relayPacket(routed.copy(packet = relayPacket))
}
}
/**
* Relay message with adaptive probability (same as iOS)
*/
private suspend fun relayMessage(packet: BitchatPacket) {
private suspend fun relayMessage(routed: RoutedPacket) {
val packet = routed.packet
if (packet.ttl == 0u.toUByte()) return
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
@@ -253,7 +268,7 @@ class MessageHandler(private val myPeerID: String) {
if (shouldRelay) {
val delay = Random.nextLong(50, 500) // Random delay like iOS
delay(delay)
delegate?.relayPacket(relayPacket)
delegate?.relayPacket(routed.copy(packet = relayPacket))
}
}
@@ -326,7 +341,7 @@ interface MessageHandlerDelegate {
// Packet operations
fun sendPacket(packet: BitchatPacket)
fun relayPacket(packet: BitchatPacket)
fun relayPacket(routed: RoutedPacket)
fun getBroadcastRecipient(): ByteArray
// Cryptographic operations
@@ -3,6 +3,7 @@ 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.model.RoutedPacket
import kotlinx.coroutines.*
/**
@@ -24,16 +25,19 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Process received packet - main entry point for all incoming packets
*/
fun processPacket(packet: BitchatPacket, peerID: String) {
fun processPacket(routed: RoutedPacket) {
processorScope.launch {
handleReceivedPacket(packet, peerID)
handleReceivedPacket(routed)
}
}
/**
* Handle received packet - core protocol logic (exact same as iOS)
*/
private suspend fun handleReceivedPacket(packet: BitchatPacket, peerID: String) {
private suspend fun handleReceivedPacket(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
// Basic validation and security checks
if (!delegate?.validatePacketSecurity(packet, peerID)!!) {
Log.d(TAG, "Packet failed security validation from $peerID")
@@ -47,15 +51,15 @@ class PacketProcessor(private val myPeerID: String) {
// 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.KEY_EXCHANGE -> handleKeyExchange(routed)
MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT_START,
MessageType.FRAGMENT_CONTINUE,
MessageType.FRAGMENT_END -> handleFragment(packet, peerID)
MessageType.DELIVERY_ACK -> handleDeliveryAck(packet, peerID)
MessageType.READ_RECEIPT -> handleReadReceipt(packet, peerID)
MessageType.FRAGMENT_END -> handleFragment(routed)
MessageType.DELIVERY_ACK -> handleDeliveryAck(routed)
MessageType.READ_RECEIPT -> handleReadReceipt(routed)
else -> {
Log.w(TAG, "Unknown message type: ${packet.type}")
}
@@ -65,10 +69,11 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle key exchange message
*/
private suspend fun handleKeyExchange(packet: BitchatPacket, peerID: String) {
private suspend fun handleKeyExchange(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing key exchange from $peerID")
val success = delegate?.handleKeyExchange(packet, peerID) ?: false
val success = delegate?.handleKeyExchange(routed) ?: false
if (success) {
// Key exchange successful, send announce and cached messages
@@ -83,60 +88,60 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle announce message
*/
private suspend fun handleAnnounce(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing announce from $peerID")
delegate?.handleAnnounce(packet, peerID)
private suspend fun handleAnnounce(routed: RoutedPacket) {
Log.d(TAG, "Processing announce from ${routed.peerID}")
delegate?.handleAnnounce(routed)
}
/**
* Handle regular message
*/
private suspend fun handleMessage(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing message from $peerID")
delegate?.handleMessage(packet, peerID)
private suspend fun handleMessage(routed: RoutedPacket) {
Log.d(TAG, "Processing message from ${routed.peerID}")
delegate?.handleMessage(routed)
}
/**
* Handle leave message
*/
private suspend fun handleLeave(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing leave from $peerID")
delegate?.handleLeave(packet, peerID)
private suspend fun handleLeave(routed: RoutedPacket) {
Log.d(TAG, "Processing leave from ${routed.peerID}")
delegate?.handleLeave(routed)
}
/**
* Handle message fragments
*/
private suspend fun handleFragment(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing fragment from $peerID")
private suspend fun handleFragment(routed: RoutedPacket) {
Log.d(TAG, "Processing fragment from ${routed.peerID}")
val reassembledPacket = delegate?.handleFragment(packet)
val reassembledPacket = delegate?.handleFragment(routed.packet)
if (reassembledPacket != null) {
Log.d(TAG, "Fragment reassembled, processing complete message")
handleReceivedPacket(reassembledPacket, peerID)
handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress))
}
// Relay fragment regardless of reassembly
if (packet.ttl > 0u) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
delegate?.relayPacket(relayPacket)
if (routed.packet.ttl > 0u) {
val relayPacket = routed.packet.copy(ttl = (routed.packet.ttl - 1u).toUByte())
delegate?.relayPacket(RoutedPacket(relayPacket, routed.peerID, routed.relayAddress))
}
}
/**
* Handle delivery acknowledgment
*/
private suspend fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing delivery ACK from $peerID")
delegate?.handleDeliveryAck(packet, peerID)
private suspend fun handleDeliveryAck(routed: RoutedPacket) {
Log.d(TAG, "Processing delivery ACK from ${routed.peerID}")
delegate?.handleDeliveryAck(routed)
}
/**
* Handle read receipt
*/
private suspend fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
Log.d(TAG, "Processing read receipt from $peerID")
delegate?.handleReadReceipt(packet, peerID)
private suspend fun handleReadReceipt(routed: RoutedPacket) {
Log.d(TAG, "Processing read receipt from ${routed.peerID}")
delegate?.handleReadReceipt(routed)
}
/**
@@ -169,16 +174,16 @@ interface PacketProcessorDelegate {
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 handleKeyExchange(routed: RoutedPacket): Boolean
fun handleAnnounce(routed: RoutedPacket)
fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleDeliveryAck(packet: BitchatPacket, peerID: String)
fun handleReadReceipt(packet: BitchatPacket, peerID: String)
fun handleDeliveryAck(routed: RoutedPacket)
fun handleReadReceipt(routed: RoutedPacket)
// Communication
fun sendAnnouncementToPeer(peerID: String)
fun sendCachedMessages(peerID: String)
fun relayPacket(packet: BitchatPacket)
fun relayPacket(routed: RoutedPacket)
}
@@ -26,12 +26,12 @@ class PowerManager(private val context: Context) {
private const val MEDIUM_BATTERY = 50
// Scan duty cycle periods (ms)
private const val SCAN_ON_DURATION_NORMAL = 2000L // 2 seconds on
private const val SCAN_OFF_DURATION_NORMAL = 8000L // 8 seconds off
private const val SCAN_ON_DURATION_POWER_SAVE = 1000L // 1 second on
private const val SCAN_OFF_DURATION_POWER_SAVE = 15000L // 15 seconds off
private const val SCAN_ON_DURATION_ULTRA_LOW = 500L // 0.5 seconds on
private const val SCAN_OFF_DURATION_ULTRA_LOW = 30000L // 30 seconds off
private const val SCAN_ON_DURATION_NORMAL = 8000L // 8 seconds on
private const val SCAN_OFF_DURATION_NORMAL = 2000L // 2 seconds off
private const val SCAN_ON_DURATION_POWER_SAVE = 2000L // 2 seconds on
private const val SCAN_OFF_DURATION_POWER_SAVE = 8000L // 8 seconds off
private const val SCAN_ON_DURATION_ULTRA_LOW = 1000L // 1 second on
private const val SCAN_OFF_DURATION_ULTRA_LOW = 10000L // 10 seconds off
// Connection limits
private const val MAX_CONNECTIONS_NORMAL = 8
@@ -112,38 +112,38 @@ class PowerManager(private val context: Context) {
/**
* Get scan settings optimized for current power mode
*/
fun getScanSettings(): ScanSettings {
// CRITICAL FIX: Set reportDelay to 0 for all modes.
// When using a custom duty cycle, we want scan results delivered immediately,
// not batched. A non-zero report delay can conflict with the scan window,
// causing missed results if the scan stops before the delay is met.
val builder = ScanSettings.Builder()
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
fun getScanSettings(): ScanSettings {
// CRITICAL FIX: Set reportDelay to 0 for all modes.
// When using a custom duty cycle, we want scan results delivered immediately,
// not batched. A non-zero report delay can conflict with the scan window,
// causing missed results if the scan stops before the delay is met.
val builder = ScanSettings.Builder()
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (currentMode) {
PowerMode.PERFORMANCE -> builder
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
when (currentMode) {
PowerMode.PERFORMANCE -> builder
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
PowerMode.BALANCED -> builder
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.BALANCED -> builder
.setScanMode(ScanSettings.SCAN_MODE_BALANCED)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.POWER_SAVER -> builder
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.POWER_SAVER -> builder
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.ULTRA_LOW_POWER -> builder
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
}
return builder.setReportDelay(0).build()
PowerMode.ULTRA_LOW_POWER -> builder
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
}
return builder.setReportDelay(0).build()
}
/**
* Get advertising settings optimized for current power mode
@@ -4,6 +4,7 @@ import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import kotlinx.coroutines.*
import java.util.*
import kotlin.collections.mutableSetOf
@@ -88,7 +89,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
/**
* Handle key exchange packet
*/
suspend fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean {
suspend fun handleKeyExchange(routed: RoutedPacket): Boolean {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return false
if (packet.payload.isEmpty()) {
@@ -113,7 +117,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
Log.d(TAG, "Successfully processed key exchange from $peerID")
// Notify delegate
delegate?.onKeyExchangeCompleted(peerID)
delegate?.onKeyExchangeCompleted(peerID, packet.payload, routed.relayAddress)
return true
@@ -315,5 +319,5 @@ class SecurityManager(private val encryptionService: EncryptionService, private
* Delegate interface for security manager callbacks
*/
interface SecurityManagerDelegate {
fun onKeyExchangeCompleted(peerID: String)
fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray, receivedAddress: String?)
}
@@ -0,0 +1,13 @@
package com.bitchat.android.model
import com.bitchat.android.protocol.BitchatPacket
/**
* Represents a routed packet with additional metadata
* Used for processing and routing packets in the mesh network
*/
data class RoutedPacket(
val packet: BitchatPacket,
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
val relayAddress: String? = null // Address it came from (for avoiding loopback)
)
@@ -91,7 +91,7 @@ class BluetoothStatusManager(
* This should be called on every app startup
*/
fun checkBluetoothStatus(): BluetoothStatus {
Log.d(TAG, "Checking Bluetooth status")
// Log.d(TAG, "Checking Bluetooth status")
return when {
bluetoothAdapter == null -> {
@@ -103,7 +103,7 @@ class BluetoothStatusManager(
BluetoothStatus.DISABLED
}
else -> {
Log.d(TAG, "Bluetooth is enabled and ready")
// Log.d(TAG, "Bluetooth is enabled and ready")
BluetoothStatus.ENABLED
}
}
@@ -0,0 +1,297 @@
package com.bitchat.android.onboarding
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
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.TextAlign
import androidx.compose.ui.unit.dp
/**
* Screen shown when checking location services status or requesting location services enable
*/
@Composable
fun LocationCheckScreen(
status: LocationStatus,
onEnableLocation: () -> Unit,
onRetry: () -> Unit,
isLoading: Boolean = false
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
when (status) {
LocationStatus.DISABLED -> {
LocationDisabledContent(
onEnableLocation = onEnableLocation,
onRetry = onRetry,
colorScheme = colorScheme,
isLoading = isLoading
)
}
LocationStatus.NOT_AVAILABLE -> {
LocationNotAvailableContent(
colorScheme = colorScheme
)
}
LocationStatus.ENABLED -> {
LocationCheckingContent(
colorScheme = colorScheme
)
}
}
}
}
@Composable
private fun LocationDisabledContent(
onEnableLocation: () -> Unit,
onRetry: () -> Unit,
colorScheme: ColorScheme,
isLoading: Boolean
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Location icon - using LocationOn outlined icon in app's green color
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = "Location Services",
modifier = Modifier.size(64.dp),
tint = Color(0xFF00C851) // App's main green color
)
Text(
text = "Location Services Required",
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Privacy assurance section
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Privacy",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Privacy First",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
)
}
Text(
text = "bitchat does NOT track your location or use GPS.\n\nLocation services are required by Android for Bluetooth scanning to work properly. This is an Android system requirement.",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "bitchat needs location services for:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "• Bluetooth device scanning (Android requirement)\n" +
"• Discovering nearby users on mesh network\n" +
"• Creating connections without internet\n" +
"• No GPS tracking or location collection",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
)
}
}
if (isLoading) {
LocationLoadingIndicator()
} else {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onEnableLocation,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851) // App's main green color
)
) {
Text(
text = "Open Location Settings",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
OutlinedButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Check Again",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
}
@Composable
private fun LocationNotAvailableContent(
colorScheme: ColorScheme
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Error icon
Icon(
imageVector = Icons.Filled.ErrorOutline,
contentDescription = "Error",
modifier = Modifier.size(64.dp),
tint = colorScheme.error
)
Text(
text = "Location Services Unavailable",
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.error
),
textAlign = TextAlign.Center
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.errorContainer.copy(alpha = 0.1f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = "Location services are not available on this device. This is unusual as location services are standard on Android devices.\n\nbitchat needs location services for Bluetooth scanning to work properly (Android requirement). Without this, the app cannot discover nearby users.",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface
),
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun LocationCheckingContent(
colorScheme: ColorScheme
) {
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat*",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
LocationLoadingIndicator()
Text(
text = "Checking location services...",
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
)
}
}
@Composable
private fun LocationLoadingIndicator() {
// Animated rotation for the loading indicator
val infiniteTransition = rememberInfiniteTransition(label = "location_loading")
val rotationAngle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
Box(
modifier = Modifier.size(60.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.rotate(rotationAngle),
color = Color(0xFF4CAF50), // Location green
strokeWidth = 3.dp
)
}
}
@@ -0,0 +1,247 @@
package com.bitchat.android.onboarding
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
/**
* Manages Location Services enable/disable state and user prompts
* Checks location services status on every app startup
* Note: This is for system location services, not location permissions
*/
class LocationStatusManager(
private val activity: ComponentActivity,
private val context: Context,
private val onLocationEnabled: () -> Unit,
private val onLocationDisabled: (String) -> Unit
) {
companion object {
private const val TAG = "LocationStatusManager"
}
private var locationSettingsLauncher: ActivityResultLauncher<Intent>? = null
private var locationManager: LocationManager? = null
private var locationStateReceiver: BroadcastReceiver? = null
init {
setupLocationManager()
setupLocationSettingsLauncher()
setupLocationStateReceiver()
}
/**
* Setup LocationManager reference
*/
private fun setupLocationManager() {
try {
locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
Log.d(TAG, "LocationManager initialized: ${locationManager != null}")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize LocationManager", e)
locationManager = null
}
}
/**
* Setup launcher for location settings request
*/
private fun setupLocationSettingsLauncher() {
locationSettingsLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val isEnabled = isLocationEnabled()
Log.d(TAG, "Location settings request result: $isEnabled (result code: ${result.resultCode})")
if (isEnabled) {
onLocationEnabled()
} else {
onLocationDisabled("Location services are required for Bluetooth scanning on Android. Please enable location services to continue.")
}
}
}
/**
* Setup broadcast receiver to listen for location settings changes
*/
private fun setupLocationStateReceiver() {
locationStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == LocationManager.MODE_CHANGED_ACTION ||
intent.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
Log.d(TAG, "Location settings changed, checking status")
val isEnabled = isLocationEnabled()
if (isEnabled) {
onLocationEnabled()
} else {
onLocationDisabled("Location services have been disabled.")
}
}
}
}
// Register receiver for location changes
val filter = IntentFilter().apply {
addAction(LocationManager.MODE_CHANGED_ACTION)
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
}
context.registerReceiver(locationStateReceiver, filter)
}
/**
* Check if location services are enabled (system-wide setting)
* Uses proper API depending on Android version
*/
fun isLocationEnabled(): Boolean {
return try {
locationManager?.let { lm ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// API 28+ (Android 9) - Modern approach
lm.isLocationEnabled
} else {
// Older devices - Check individual providers
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error checking location enabled state: ${e.message}")
false
}
}
/**
* Check location services status
* This should be called on every app startup
*/
fun checkLocationStatus(): LocationStatus {
Log.d(TAG, "Checking location services status")
return when {
locationManager == null -> {
Log.e(TAG, "LocationManager not available on this device")
LocationStatus.NOT_AVAILABLE
}
!isLocationEnabled() -> {
Log.w(TAG, "Location services are disabled")
LocationStatus.DISABLED
}
else -> {
Log.d(TAG, "Location services are enabled and ready")
LocationStatus.ENABLED
}
}
}
/**
* Request user to enable location services
* Opens system location settings screen
*/
fun requestEnableLocation() {
Log.d(TAG, "Requesting user to enable location services")
try {
val enableLocationIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
locationSettingsLauncher?.launch(enableLocationIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to request location enable", e)
onLocationDisabled("Failed to open location settings: ${e.message}")
}
}
/**
* Handle location status check result
*/
fun handleLocationStatus(status: LocationStatus) {
when (status) {
LocationStatus.ENABLED -> {
Log.d(TAG, "Location services enabled, proceeding")
onLocationEnabled()
}
LocationStatus.DISABLED -> {
Log.d(TAG, "Location services disabled, requesting enable")
requestEnableLocation()
}
LocationStatus.NOT_AVAILABLE -> {
Log.e(TAG, "Location services not available")
onLocationDisabled("Location services are not available on this device.")
}
}
}
/**
* Get user-friendly status message
*/
fun getStatusMessage(status: LocationStatus): String {
return when (status) {
LocationStatus.ENABLED -> "Location services are enabled and ready"
LocationStatus.DISABLED -> "Location services are disabled. Please enable location services for Bluetooth scanning."
LocationStatus.NOT_AVAILABLE -> "Location services are not available on this device."
}
}
/**
* Get detailed diagnostics
*/
fun getDiagnostics(): String {
return buildString {
appendLine("Location Services Status Diagnostics:")
appendLine("LocationManager available: ${locationManager != null}")
appendLine("Location services enabled: ${isLocationEnabled()}")
appendLine("Current status: ${checkLocationStatus()}")
appendLine("Android version: ${Build.VERSION.SDK_INT}")
locationManager?.let { lm ->
try {
appendLine("GPS provider enabled: ${lm.isProviderEnabled(LocationManager.GPS_PROVIDER)}")
appendLine("Network provider enabled: ${lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)}")
} catch (e: Exception) {
appendLine("Provider details: [Error: ${e.message}]")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
appendLine("Using modern isLocationEnabled() API")
} else {
appendLine("Using legacy provider check API")
}
}
}
}
/**
* Log current location status for debugging
*/
fun logLocationStatus() {
Log.d(TAG, getDiagnostics())
}
/**
* Cleanup resources - call this when activity is destroyed
*/
fun cleanup() {
locationStateReceiver?.let { receiver ->
try {
context.unregisterReceiver(receiver)
Log.d(TAG, "Location state receiver unregistered")
} catch (e: Exception) {
Log.w(TAG, "Error unregistering location state receiver: ${e.message}")
}
}
}
}
/**
* Location services status enum
*/
enum class LocationStatus {
ENABLED,
DISABLED,
NOT_AVAILABLE
}
@@ -21,119 +21,130 @@ import androidx.compose.ui.unit.sp
@Composable
fun PermissionExplanationScreen(
permissionCategories: List<PermissionCategory>,
onContinue: () -> Unit,
onCancel: () -> Unit
onContinue: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
Box(
modifier = Modifier.fillMaxSize()
) {
// Header
// Scrollable content
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Welcome to bitchat*",
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Decentralized mesh messaging over Bluetooth",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
// Privacy assurance section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.padding(bottom = 88.dp) // Leave space for the fixed button
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(24.dp))
// Header
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
Text(
text = "Welcome to bitchat*",
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Decentralized mesh messaging over Bluetooth",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
// Privacy assurance section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "🔒",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.size(20.dp)
)
Text(
text = "Your Privacy is Protected",
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
)
}
Text(
text = "🔒",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.size(20.dp)
)
Text(
text = "Your Privacy is Protected",
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
text = "• bitchat doesn't track you or collect personal data\n" +
"• No servers, no internet required, no data logging\n" +
"• Location permission is only used by Android for Bluetooth scanning\n" +
"• Your messages stay on your device and peer devices only",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
)
}
Text(
text = "• bitchat doesn't track you or collect personal data\n" +
"• No servers, no internet required, no data logging\n" +
"• Location permission is only used by Android for Bluetooth scanning\n" +
"• Your messages stay on your device and peer devices only",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "To work properly, bitchat needs these permissions:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
)
// Permission categories
permissionCategories.forEach { category ->
PermissionCategoryCard(
category = category,
colorScheme = colorScheme
)
}
Spacer(modifier = Modifier.height(24.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "To work properly, bitchat needs these permissions:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
)
// Permission categories
permissionCategories.forEach { category ->
PermissionCategoryCard(
category = category,
colorScheme = colorScheme
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
// Fixed button at bottom
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
color = colorScheme.surface,
shadowElevation = 8.dp
) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary
)
@@ -147,22 +158,6 @@ fun PermissionExplanationScreen(
modifier = Modifier.padding(vertical = 4.dp)
)
}
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colorScheme.onSurface.copy(alpha = 0.7f)
)
) {
Text(
text = "Exit App",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
@@ -188,14 +183,14 @@ private fun PermissionCategoryCard(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = getPermissionEmoji(category.name),
text = getPermissionEmoji(category.type),
style = MaterialTheme.typography.titleLarge,
color = getPermissionIconColor(category.name),
color = getPermissionIconColor(category.type),
modifier = Modifier.size(24.dp)
)
Text(
text = category.name,
text = category.type.nameValue,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
@@ -212,7 +207,7 @@ private fun PermissionCategoryCard(
)
)
if (category.name == "Precise Location") {
if (category.type == PermissionType.PRECISE_LOCATION) {
// Extra emphasis for location permission
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -237,20 +232,20 @@ private fun PermissionCategoryCard(
}
}
private fun getPermissionEmoji(categoryName: String): String {
return when (categoryName) {
"Nearby Devices" -> "📱"
"Precise Location" -> "📍"
"Notifications" -> "🔔"
else -> "🔧"
private fun getPermissionEmoji(permissionType: PermissionType): String {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> "📱"
PermissionType.PRECISE_LOCATION -> "📍"
PermissionType.NOTIFICATIONS -> "🔔"
PermissionType.OTHER -> "🔧"
}
}
private fun getPermissionIconColor(categoryName: String): Color {
return when (categoryName) {
"Nearby Devices" -> Color(0xFF2196F3) // Blue
"Precise Location" -> Color(0xFFFF9800) // Orange
"Notifications" -> Color(0xFF4CAF50) // Green
else -> Color(0xFF9C27B0) // Purple
private fun getPermissionIconColor(permissionType: PermissionType): Color {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Color(0xFF2196F3) // Blue
PermissionType.PRECISE_LOCATION -> Color(0xFFFF9800) // Orange
PermissionType.NOTIFICATIONS -> Color(0xFF4CAF50) // Green
PermissionType.OTHER -> Color(0xFF9C27B0) // Purple
}
}
@@ -115,8 +115,8 @@ class PermissionManager(private val context: Context) {
categories.add(
PermissionCategory(
name = "Nearby Devices",
description = "Required to discover and connect to other bitchat users via Bluetooth",
type = PermissionType.NEARBY_DEVICES,
description = "Required to discover bitchat users via Bluetooth",
permissions = bluetoothPermissions,
isGranted = bluetoothPermissions.all { isPermissionGranted(it) },
systemDescription = "Allow bitchat to connect to nearby devices"
@@ -131,11 +131,11 @@ class PermissionManager(private val context: Context) {
categories.add(
PermissionCategory(
name = "Precise Location",
description = "Required by Android for Bluetooth scanning.",
type = PermissionType.PRECISE_LOCATION,
description = "Required by Android to discover nearby bitchat users via Bluetooth",
permissions = locationPermissions,
isGranted = locationPermissions.all { isPermissionGranted(it) },
systemDescription = "Allow bitchat to access this device's location"
systemDescription = "bitchat needs this to scan for nearby devices"
)
)
@@ -143,8 +143,8 @@ class PermissionManager(private val context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add(
PermissionCategory(
name = "Notifications",
description = "Show notifications when you receive private messages while the app is in background",
type = PermissionType.NOTIFICATIONS,
description = "Receive notifications when you receive private messages",
permissions = listOf(Manifest.permission.POST_NOTIFICATIONS),
isGranted = isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS),
systemDescription = "Allow bitchat to send you notifications"
@@ -167,7 +167,7 @@ class PermissionManager(private val context: Context) {
appendLine()
getCategorizedPermissions().forEach { category ->
appendLine("${category.name}: ${if (category.isGranted) "✅ GRANTED" else "❌ MISSING"}")
appendLine("${category.type.nameValue}: ${if (category.isGranted) "✅ GRANTED" else "❌ MISSING"}")
category.permissions.forEach { permission ->
val granted = isPermissionGranted(permission)
appendLine(" - ${permission.substringAfterLast(".")}: ${if (granted) "✅" else "❌"}")
@@ -197,9 +197,16 @@ class PermissionManager(private val context: Context) {
* Data class representing a category of related permissions
*/
data class PermissionCategory(
val name: String,
val type: PermissionType,
val description: String,
val permissions: List<String>,
val isGranted: Boolean,
val systemDescription: String
)
enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"),
NOTIFICATIONS("Notifications"),
OTHER("Other")
}
@@ -0,0 +1,219 @@
package com.bitchat.android.services
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.util.*
/**
* Message retention service for saving channel messages locally
* Matches iOS MessageRetentionService functionality
*/
class MessageRetentionService private constructor(private val context: Context) {
companion object {
private const val TAG = "MessageRetentionService"
private const val PREF_NAME = "message_retention"
private const val KEY_FAVORITE_CHANNELS = "favorite_channels"
@Volatile
private var INSTANCE: MessageRetentionService? = null
fun getInstance(context: Context): MessageRetentionService {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: MessageRetentionService(context.applicationContext).also { INSTANCE = it }
}
}
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
private val retentionDir = File(context.filesDir, "retained_messages")
init {
if (!retentionDir.exists()) {
retentionDir.mkdirs()
}
}
// MARK: - Channel Bookmarking (Favorites)
fun getFavoriteChannels(): Set<String> {
return prefs.getStringSet(KEY_FAVORITE_CHANNELS, emptySet()) ?: emptySet()
}
fun toggleFavoriteChannel(channel: String): Boolean {
val currentFavorites = getFavoriteChannels().toMutableSet()
val wasAdded = if (currentFavorites.contains(channel)) {
currentFavorites.remove(channel)
false
} else {
currentFavorites.add(channel)
true
}
prefs.edit().putStringSet(KEY_FAVORITE_CHANNELS, currentFavorites).apply()
if (!wasAdded) {
// Channel removed from favorites - delete saved messages in background
Thread {
try {
val channelFile = getChannelFile(channel)
if (channelFile.exists()) {
channelFile.delete()
Log.d(TAG, "Deleted saved messages for channel $channel")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to delete messages for channel $channel", e)
}
}.start()
}
Log.d(TAG, "Channel $channel ${if (wasAdded) "bookmarked" else "unbookmarked"}")
return wasAdded
}
fun isChannelBookmarked(channel: String): Boolean {
return getFavoriteChannels().contains(channel)
}
// MARK: - Message Storage
suspend fun saveMessage(message: BitchatMessage, forChannel: String) = withContext(Dispatchers.IO) {
if (!isChannelBookmarked(forChannel)) {
Log.w(TAG, "Attempted to save message for non-bookmarked channel: $forChannel")
return@withContext
}
try {
val channelFile = getChannelFile(forChannel)
val existingMessages = loadMessagesFromFile(channelFile).toMutableList()
// Check if message already exists (by ID)
if (existingMessages.any { it.id == message.id }) {
Log.d(TAG, "Message ${message.id} already saved for channel $forChannel")
return@withContext
}
// Add new message
existingMessages.add(message)
// Sort by timestamp
existingMessages.sortBy { it.timestamp }
// Save back to file
saveMessagesToFile(channelFile, existingMessages)
Log.d(TAG, "Saved message ${message.id} for channel $forChannel")
} catch (e: Exception) {
Log.e(TAG, "Failed to save message for channel $forChannel", e)
}
}
suspend fun loadMessagesForChannel(channel: String): List<BitchatMessage> = withContext(Dispatchers.IO) {
if (!isChannelBookmarked(channel)) {
Log.d(TAG, "Channel $channel not bookmarked, returning empty list")
return@withContext emptyList()
}
try {
val channelFile = getChannelFile(channel)
val messages = loadMessagesFromFile(channelFile)
Log.d(TAG, "Loaded ${messages.size} messages for channel $channel")
return@withContext messages
} catch (e: Exception) {
Log.e(TAG, "Failed to load messages for channel $channel", e)
return@withContext emptyList()
}
}
suspend fun deleteMessagesForChannel(channel: String): Unit = withContext(Dispatchers.IO) {
try {
val channelFile = getChannelFile(channel)
if (channelFile.exists()) {
channelFile.delete()
Log.d(TAG, "Deleted saved messages for channel $channel")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to delete messages for channel $channel", e)
}
}
suspend fun deleteAllStoredMessages(): Unit = withContext(Dispatchers.IO) {
try {
if (retentionDir.exists()) {
retentionDir.listFiles()?.forEach { file ->
file.delete()
}
Log.d(TAG, "Deleted all stored messages")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to delete all stored messages", e)
}
}
// MARK: - File Operations
private fun getChannelFile(channel: String): File {
// Sanitize channel name for filename
val sanitizedChannel = channel.replace("[^a-zA-Z0-9_-]".toRegex(), "_")
return File(retentionDir, "channel_${sanitizedChannel}.dat")
}
private fun loadMessagesFromFile(file: File): List<BitchatMessage> {
if (!file.exists()) {
return emptyList()
}
return try {
FileInputStream(file).use { fis ->
ObjectInputStream(fis).use { ois ->
@Suppress("UNCHECKED_CAST")
ois.readObject() as List<BitchatMessage>
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load messages from ${file.name}, returning empty list", e)
emptyList()
}
}
private fun saveMessagesToFile(file: File, messages: List<BitchatMessage>) {
FileOutputStream(file).use { fos ->
ObjectOutputStream(fos).use { oos ->
oos.writeObject(messages)
}
}
}
// MARK: - Statistics
fun getBookmarkedChannelsCount(): Int {
return getFavoriteChannels().size
}
suspend fun getTotalStoredMessagesCount(): Int = withContext(Dispatchers.IO) {
var totalCount = 0
try {
retentionDir.listFiles()?.forEach { file ->
if (file.name.startsWith("channel_") && file.name.endsWith(".dat")) {
val messages = loadMessagesFromFile(file)
totalCount += messages.size
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to count stored messages", e)
}
totalCount
}
}
@@ -284,9 +284,21 @@ class ChannelManager(
state.setShowPasswordPrompt(false)
state.setPasswordPromptChannel(null)
}
fun setChannelPassword(channel: String, password: String): Boolean {
return verifyChannelPassword(channel, password)
fun setChannelPassword(channel: String, password: String) {
channelPasswords[channel] = password
channelKeys[channel] = deriveChannelKey(password, channel)
state.setPasswordProtectedChannels(
state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) }
)
dataManager.saveChannelData(
state.getJoinedChannelsValue(),
state.getPasswordProtectedChannelsValue()
)
}
// MARK: - Emergency Clear
@@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
@@ -103,7 +104,7 @@ fun PeerCounter(
imageVector = Icons.Filled.Email,
contentDescription = "Unread private messages",
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF8C00) // Orange to match private message theme
tint = Color(0xFFFF9500) // Orange to match private message theme
)
Spacer(modifier = Modifier.width(6.dp))
}
@@ -151,11 +152,17 @@ fun ChatHeaderContent(
when {
selectedPrivatePeer != null -> {
// Private chat header
// Private chat header - ensure state synchronization
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val fingerprint = viewModel.privateChatManager.getPeerFingerprint(selectedPrivatePeer)
val isFavorite = favoritePeers.contains(fingerprint)
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, fingerprint=$fingerprint, isFav=$isFavorite")
PrivateChatHeader(
peerID = selectedPrivatePeer,
peerNicknames = viewModel.meshService.getPeerNicknames(),
isFavorite = viewModel.isFavorite(selectedPrivatePeer),
isFavorite = isFavorite,
onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
)
@@ -201,19 +208,18 @@ private fun PrivateChatHeader(
val colorScheme = MaterialTheme.colorScheme
val peerNickname = peerNicknames[peerID] ?: peerID
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Fixed: Make back button wider to prevent text cropping
Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin
Button(
onClick = onBackClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
modifier = Modifier
.align(Alignment.CenterStart)
.offset(x = (-8).dp) // Move even further left to minimize margin
) {
Row(
verticalAlignment = Alignment.CenterVertically
@@ -233,27 +239,33 @@ private fun PrivateChatHeader(
}
}
Spacer(modifier = Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
// Title - perfectly centered regardless of other elements
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.Center)
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = "Private chat",
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF8C00) // Orange to match private message theme
tint = Color(0xFFFF9500) // Orange to match private message theme
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = peerNickname,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF8C00) // Orange
color = Color(0xFFFF9500) // Orange
)
}
Spacer(modifier = Modifier.weight(1f))
// Favorite button with proper filled/outlined star
IconButton(onClick = onToggleFavorite) {
// Favorite button - positioned on the right
IconButton(
onClick = {
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
onToggleFavorite()
},
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
@@ -273,12 +285,19 @@ private fun ChannelHeader(
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBackClick) {
Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin
Button(
onClick = onBackClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
modifier = Modifier
.align(Alignment.CenterStart)
.offset(x = (-8).dp) // Move even further left to minimize margin
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
@@ -297,18 +316,21 @@ private fun ChannelHeader(
}
}
Spacer(modifier = Modifier.weight(1f))
// Title - perfectly centered regardless of other elements
Text(
text = "channel: $channel",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF0080FF), // Blue
modifier = Modifier.clickable { onSidebarClick() }
color = Color(0xFFFF9500), // Orange to match input field
modifier = Modifier
.align(Alignment.Center)
.clickable { onSidebarClick() }
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onLeaveChannel) {
// Leave button - positioned on the right
TextButton(
onClick = onLeaveChannel,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(
text = "leave",
style = MaterialTheme.typography.bodySmall,
@@ -60,15 +60,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
var showSidebar by remember { mutableStateOf(false) }
val showSidebar by viewModel.showSidebar.observeAsState(false)
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
var messageText by remember { mutableStateOf("") }
var showPasswordPrompt by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") }
var showAppInfo by remember { mutableStateOf(false) }
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
@@ -142,8 +142,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
nickname = nickname,
viewModel = viewModel,
colorScheme = colorScheme,
onSidebarToggle = { showSidebar = true },
onShowAppInfo = { showAppInfo = true },
onSidebarToggle = { viewModel.showSidebar() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() }
)
@@ -162,7 +162,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
) {
SidebarOverlay(
viewModel = viewModel,
onDismiss = { showSidebar = false },
onDismiss = { viewModel.hideSidebar() },
modifier = Modifier.fillMaxSize()
)
}
@@ -188,7 +188,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
passwordInput = ""
},
showAppInfo = showAppInfo,
onAppInfoDismiss = { showAppInfo = false }
onAppInfoDismiss = { viewModel.hideAppInfo() }
)
}
@@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
@@ -84,6 +85,10 @@ class ChatState {
val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
// Navigation state
private val _showAppInfo = MutableLiveData<Boolean>(false)
val showAppInfo: LiveData<Boolean> = _showAppInfo
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
@@ -117,6 +122,7 @@ class ChatState {
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
fun getShowAppInfoValue() = _showAppInfo.value ?: false
// Setters for state updates
fun setMessages(messages: List<BitchatMessage>) {
@@ -188,7 +194,21 @@ class ChatState {
}
fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet()
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Log.d("ChatState", "Current value: $currentValue")
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
Log.d("ChatState", "Setting on thread: ${Thread.currentThread().name}")
// Always set the value - even if equal, this ensures observers are triggered
_favoritePeers.value = favorites
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
}
fun setShowAppInfo(show: Boolean) {
_showAppInfo.value = show
}
}
@@ -139,7 +139,7 @@ private fun appendFormattedContent(
}
"mention" -> {
builder.pushStyle(SpanStyle(
color = Color(0xFFFF8C00), // Orange
color = Color(0xFFFF9500), // Orange
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
))
@@ -2,6 +2,7 @@ package com.bitchat.android.ui
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
@@ -19,13 +20,13 @@ import kotlin.random.Random
* Refactored ChatViewModel - Main coordinator for bitchat functionality
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
*/
class ChatViewModel(application: Application) : AndroidViewModel(application), BluetoothMeshDelegate {
class ChatViewModel(
application: Application,
val meshService: BluetoothMeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
private val context: Context = application.applicationContext
// Core services
val meshService = BluetoothMeshService(context)
// State management
private val state = ChatState()
@@ -70,9 +71,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
val showAppInfo: LiveData<Boolean> = state.showAppInfo
init {
meshService.delegate = this
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
}
@@ -100,8 +102,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
state.setFavoritePeers(dataManager.favoritePeers)
dataManager.loadBlockedUsers()
// Start mesh service
meshService.startServices()
// Log all favorites at startup
dataManager.logAllFavorites()
logCurrentFavoriteState()
// Note: Mesh service is now started by MainActivity
// Show welcome message if no peers after delay
viewModelScope.launch {
@@ -120,7 +125,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
override fun onCleared() {
super.onCleared()
meshService.stopServices()
// Note: Mesh service lifecycle is now managed by MainActivity
}
// MARK: - Nickname Management
@@ -250,11 +255,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
}
fun toggleFavorite(peerID: String) {
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
privateChatManager.toggleFavorite(peerID)
// Log current state after toggle
logCurrentFavoriteState()
}
fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
privateChatManager.registerPeerPublicKey(peerID, publicKeyData)
private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
Log.i("ChatViewModel", "==============================")
}
// MARK: - Debug and Troubleshooting
@@ -263,18 +276,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
return meshService.getDebugStatus()
}
fun restartMeshServices() {
viewModelScope.launch {
meshService.stopServices()
delay(1000)
meshService.startServices()
}
}
// Note: Mesh service restart is now handled by MainActivity
// This function is no longer needed
fun setAppBackgroundState(inBackground: Boolean) {
// Forward to connection manager for power optimization
meshService.connectionManager.setAppBackgroundState(inBackground)
// Forward to notification manager for notification logic
notificationManager.setAppBackgroundState(inBackground)
}
@@ -341,6 +346,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
return meshDelegateHandler.isFavorite(peerID)
}
override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
privateChatManager.registerPeerPublicKey(peerID, publicKeyData)
}
// MARK: - Emergency Clear
fun panicClearAllData() {
@@ -355,13 +364,62 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
// Disconnect from mesh
meshService.stopServices()
// Restart services with new identity
viewModelScope.launch {
delay(500)
meshService.startServices()
// Note: Mesh service restart is now handled by MainActivity
// This method now only clears data, not mesh service lifecycle
}
// MARK: - Navigation Management
fun showAppInfo() {
state.setShowAppInfo(true)
}
fun hideAppInfo() {
state.setShowAppInfo(false)
}
fun showSidebar() {
state.setShowSidebar(true)
}
fun hideSidebar() {
state.setShowSidebar(false)
}
/**
* Handle Android back navigation
* Returns true if the back press was handled, false if it should be passed to the system
*/
fun handleBackPressed(): Boolean {
return when {
// Close app info dialog
state.getShowAppInfoValue() -> {
hideAppInfo()
true
}
// Close sidebar
state.getShowSidebarValue() -> {
hideSidebar()
true
}
// Close password dialog
state.getShowPasswordPromptValue() -> {
state.setShowPasswordPrompt(false)
state.setPasswordPromptChannel(null)
true
}
// Exit private chat
state.getSelectedPrivateChatPeerValue() != null -> {
endPrivateChat()
true
}
// Exit channel view
state.getCurrentChannelValue() != null -> {
switchToChannel(null)
true
}
// No special navigation state - let system handle (usually exits app)
else -> false
}
}
}
@@ -37,8 +37,9 @@ class CommandProcessor(
when (cmd) {
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
"/m", "/msg" -> handleMessageCommand(parts, meshService)
"/w" -> handleWhoCommand()
"/w" -> handleWhoCommand(meshService)
"/clear" -> handleClearCommand()
"/pass" -> handlePassCommand(parts, myPeerID)
"/block" -> handleBlockCommand(parts, meshService)
"/unblock" -> handleUnblockCommand(parts, meshService)
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
@@ -54,7 +55,8 @@ class CommandProcessor(
if (parts.size > 1) {
val channelName = parts[1]
val channel = if (channelName.startsWith("#")) channelName else "#$channelName"
val success = channelManager.joinChannel(channel, null, myPeerID)
val password = if (parts.size > 2) parts[2] else null
val success = channelManager.joinChannel(channel, password, myPeerID)
if (success) {
val systemMessage = BitchatMessage(
sender = "system",
@@ -127,11 +129,11 @@ class CommandProcessor(
}
}
private fun handleWhoCommand() {
private fun handleWhoCommand(meshService: Any) {
val connectedPeers = state.getConnectedPeersValue()
val peerList = connectedPeers.joinToString(", ") { peerID ->
// This would need mesh service access for nicknames
peerID // For now just use peer ID
// Convert peerID to nickname using the mesh service
getPeerNickname(peerID, meshService)
}
val systemMessage = BitchatMessage(
@@ -165,6 +167,52 @@ class CommandProcessor(
}
}
}
private fun handlePassCommand(parts: List<String>, peerID: String) {
val currentChannel = state.getCurrentChannelValue()
if (currentChannel == null) {
val systemMessage = BitchatMessage(
sender = "system",
content = "you must be in a channel to set a password.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return
}
if (parts.size == 2){
if(!channelManager.isChannelCreator(channel = currentChannel, peerID = peerID)){
val systemMessage = BitchatMessage(
sender = "system",
content = "you must be the channel creator to set a password.",
timestamp = Date(),
isRelay = false
)
channelManager.addChannelMessage(currentChannel,systemMessage,null)
return
}
val newPassword = parts[1]
channelManager.setChannelPassword(currentChannel, newPassword)
val systemMessage = BitchatMessage(
sender = "system",
content = "password changed for channel $currentChannel",
timestamp = Date(),
isRelay = false
)
channelManager.addChannelMessage(currentChannel,systemMessage,null)
}
else{
val systemMessage = BitchatMessage(
sender = "system",
content = "usage: /pass <password>",
timestamp = Date(),
isRelay = false
)
channelManager.addChannelMessage(currentChannel,systemMessage,null)
}
}
private fun handleBlockCommand(parts: List<String>, meshService: Any) {
if (parts.size > 1) {
@@ -2,6 +2,7 @@ package com.bitchat.android.ui
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.google.gson.Gson
import kotlin.random.Random
@@ -10,6 +11,10 @@ import kotlin.random.Random
*/
class DataManager(private val context: Context) {
companion object {
private const val TAG = "DataManager"
}
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
private val gson = Gson()
@@ -126,24 +131,41 @@ class DataManager(private val context: Context) {
fun loadFavorites() {
val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet()
_favoritePeers.addAll(savedFavorites)
Log.d(TAG, "Loaded ${savedFavorites.size} favorite users from storage: $savedFavorites")
}
fun saveFavorites() {
prefs.edit().putStringSet("favorites", _favoritePeers).apply()
Log.d(TAG, "Saved ${_favoritePeers.size} favorite users to storage: $_favoritePeers")
}
fun addFavorite(fingerprint: String) {
_favoritePeers.add(fingerprint)
val wasAdded = _favoritePeers.add(fingerprint)
Log.d(TAG, "addFavorite: fingerprint=$fingerprint, wasAdded=$wasAdded")
saveFavorites()
logAllFavorites()
}
fun removeFavorite(fingerprint: String) {
_favoritePeers.remove(fingerprint)
val wasRemoved = _favoritePeers.remove(fingerprint)
Log.d(TAG, "removeFavorite: fingerprint=$fingerprint, wasRemoved=$wasRemoved")
saveFavorites()
logAllFavorites()
}
fun isFavorite(fingerprint: String): Boolean {
return _favoritePeers.contains(fingerprint)
val result = _favoritePeers.contains(fingerprint)
Log.d(TAG, "isFavorite check: fingerprint=$fingerprint, result=$result")
return result
}
fun logAllFavorites() {
Log.i(TAG, "=== ALL FAVORITE USERS ===")
Log.i(TAG, "Total favorites: ${_favoritePeers.size}")
_favoritePeers.forEach { fingerprint ->
Log.i(TAG, "Favorite fingerprint: $fingerprint")
}
Log.i(TAG, "========================")
}
// MARK: - Blocked Users Management
@@ -44,20 +44,17 @@ fun MessageInput(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
verticalAlignment = Alignment.CenterVertically
) {
// Fixed: Remove arrow from private message input
// Remove arrow from both private and channel inputs to match DM style
Text(
text = when {
selectedPrivatePeer != null -> "<@$nickname>" // Removed arrow for private
currentChannel != null -> "<@$nickname> →" // Keep arrow for channels
else -> "<@$nickname>"
},
text = "<@$nickname>", // No arrow for both private and channel
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
selectedPrivatePeer != null -> Color(0xFFFF9500) // Orange for private
currentChannel != null -> Color(0xFFFF9500) // Orange for channels too
else -> colorScheme.primary
},
fontFamily = FontFamily.Monospace
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
)
Spacer(modifier = Modifier.width(8.dp))
@@ -78,7 +75,7 @@ fun MessageInput(
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Fixed: Make send button orange in private mode to match nickname color
// Update send button to match input field colors
IconButton(
onClick = onSend,
modifier = Modifier.size(32.dp)
@@ -87,9 +84,9 @@ fun MessageInput(
modifier = Modifier
.size(30.dp)
.background(
color = if (selectedPrivatePeer != null) {
// Orange for private messages to match nickname color
Color(0xFFFF8C00).copy(alpha = 0.75f)
color = if (selectedPrivatePeer != null || currentChannel != null) {
// Orange for both private messages and channels to match nickname color
Color(0xFFFF9500).copy(alpha = 0.75f)
} else if (colorScheme.background == Color.Black) {
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
} else {
@@ -100,11 +97,11 @@ fun MessageInput(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = "Send message",
modifier = Modifier.size(20.dp),
tint = if (selectedPrivatePeer != null) {
// Black arrow on orange in private mode
tint = if (selectedPrivatePeer != null || currentChannel != null) {
// Black arrow on orange for both private and channel modes
Color.Black
} else if (colorScheme.background == Color.Black) {
Color.Black // Black arrow on bright green in dark theme
@@ -154,4 +154,8 @@ class MeshDelegateHandler(
override fun isFavorite(peerID: String): Boolean {
return privateChatManager.isFavorite(peerID)
}
override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
privateChatManager.registerPeerPublicKey(peerID, publicKeyData)
}
}
@@ -3,6 +3,7 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import java.util.*
import android.util.Log
/**
* Handles private chat functionality including peer management and blocking
@@ -13,6 +14,10 @@ class PrivateChatManager(
private val dataManager: DataManager
) {
companion object {
private const val TAG = "PrivateChatManager"
}
// Peer identification mapping
private val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
@@ -99,17 +104,43 @@ class PrivateChatManager(
fun toggleFavorite(peerID: String) {
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return
if (dataManager.isFavorite(fingerprint)) {
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
val wasFavorite = dataManager.isFavorite(fingerprint)
Log.d(TAG, "Current favorite status: $wasFavorite")
val currentFavorites = state.getFavoritePeersValue()
Log.d(TAG, "Current UI state favorites: $currentFavorites")
if (wasFavorite) {
dataManager.removeFavorite(fingerprint)
Log.d(TAG, "Removed from favorites: $fingerprint")
} else {
dataManager.addFavorite(fingerprint)
Log.d(TAG, "Added to favorites: $fingerprint")
}
state.setFavoritePeers(dataManager.favoritePeers)
// Always update state to trigger UI refresh - create new set to ensure change detection
val newFavorites = dataManager.favoritePeers.toSet()
state.setFavoritePeers(newFavorites)
Log.d(TAG, "Force updated favorite peers state. New favorites: $newFavorites")
Log.d(TAG, "All peer fingerprints: $peerIDToPublicKeyFingerprint")
}
fun isFavorite(peerID: String): Boolean {
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return false
return dataManager.isFavorite(fingerprint)
val isFav = dataManager.isFavorite(fingerprint)
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav")
return isFav
}
fun getPeerFingerprint(peerID: String): String? {
return peerIDToPublicKeyFingerprint[peerID]
}
fun getPeerFingerprints(): Map<String, String> {
return peerIDToPublicKeyFingerprint.toMap()
}
// MARK: - Block/Unblock Operations
@@ -262,10 +293,6 @@ class PrivateChatManager(
// MARK: - Public Getters
fun getPeerFingerprint(peerID: String): String? {
return peerIDToPublicKeyFingerprint[peerID]
}
fun getAllPeerFingerprints(): Map<String, String> {
return peerIDToPublicKeyFingerprint.toMap()
}
@@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
@@ -193,7 +194,7 @@ fun ChannelsSection(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "#$channel",
text = channel, // Channel already contains the # prefix
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
@@ -263,28 +264,40 @@ fun PeopleSection(
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
// Pre-calculate all favorite states to ensure proper state synchronization
val peerFavoriteStates = remember(favoritePeers, connectedPeers) {
connectedPeers.associateWith { peerID ->
val fingerprint = viewModel.privateChatManager.getPeerFingerprint(peerID)
favoritePeers.contains(fingerprint)
}
}
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
val sortedPeers = connectedPeers.sortedWith(
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
.thenBy {
val fingerprint = viewModel.privateChatManager.getPeerFingerprint(it)
fingerprint == null || !favoritePeers.contains(fingerprint)
} // Favorites
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
)
sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false
PeerItem(
peerID = peerID,
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
signalStrength = peerRSSI[peerID] ?: 0,
isSelected = peerID == selectedPrivatePeer,
isFavorite = favoritePeers.contains(viewModel.privateChatManager.getPeerFingerprint(peerID)),
isFavorite = isFavorite,
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
colorScheme = colorScheme,
onItemClick = { onPrivateChatStart(peerID) },
onToggleFavorite = { viewModel.toggleFavorite(peerID) }
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
viewModel.toggleFavorite(peerID)
}
)
}
}
@@ -320,7 +333,7 @@ private fun PeerItem(
imageVector = Icons.Filled.Email,
contentDescription = "Unread messages",
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF8C00) // Orange to match private message theme
tint = Color(0xFFFF9500) // Orange to match private message theme
)
} else {
// Signal strength indicators
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
android:height="108dp">
<path
android:fillColor="#2C2C2E"
android:pathData="M0,0h108v108h-108z" />
android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z"
android:fillColor="#000000" />
</vector>
@@ -1,51 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Centered chat terminal window - safe zone is 66dp circle centered at 54,54 -->
<!-- Outer terminal border (28dp wide, 18dp tall, centered) -->
<path
android:fillColor="#00FF00"
android:pathData="M40,45h28c2.2,0 4,1.8 4,4v10c0,2.2 -1.8,4 -4,4H40c-2.2,0 -4,-1.8 -4,-4V49C36,46.8 37.8,45 40,45Z"
android:strokeWidth="0" />
<!-- Inner terminal background -->
<path
android:fillColor="#1C1C1E"
android:pathData="M41,47h26c1.1,0 2,0.9 2,2v8c0,1.1 -0.9,2 -2,2H41c-1.1,0 -2,-0.9 -2,-2v-8C39,47.9 39.9,47 41,47Z"
android:strokeWidth="0" />
<!-- Terminal cursor block (centered left) -->
<path
android:fillColor="#00FF00"
android:pathData="M43,51h3v4h-3z" />
<!-- Text indicators (representing chat messages) -->
<path
android:fillColor="#00AA00"
android:pathData="M48,52h16v1.5h-16z" />
<path
android:fillColor="#00AA00"
android:pathData="M48,54.5h12v1.5h-12z" />
<!-- Connection indicator dots (mesh network) - positioned in top right -->
<path
android:fillColor="#00CC00"
android:pathData="M62,38m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
<path
android:fillColor="#00CC00"
android:pathData="M58.5,35m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path
android:fillColor="#00CC00"
android:pathData="M65.5,35m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<!-- Additional connection lines to show mesh network -->
<path
android:fillColor="#00AA00"
android:pathData="M59.5,36l3,2l3,-2"
android:strokeColor="#00AA00"
android:strokeWidth="0.5"
android:fillType="nonZero" />
android:height="108dp">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#FFFFFF" />
</group>
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:name="vector"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#000000" />
</group>
</vector>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB