Merge branch 'main' into fix/android-ios-fragmentation-compatibility
@@ -2,6 +2,7 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +13,8 @@ android {
|
||||
applicationId = "com.bitchat.android"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.6"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -23,7 +24,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@@ -40,9 +42,6 @@ android {
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 628 B After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 765 B After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,78 @@
|
||||
package com.bitchat
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Generate a consistent color for a username based on their peer ID or nickname
|
||||
* Returns colors that work well on both light and dark backgrounds
|
||||
*/
|
||||
|
||||
class ColorTest {
|
||||
fun getUsernameColor(identifier: String): Color {
|
||||
// Hash the identifier to get a consistent number
|
||||
val hash = identifier.hashCode().toUInt()
|
||||
|
||||
// Terminal-friendly colors that work on both black and white backgrounds
|
||||
val colors = listOf(
|
||||
Color(0xFF00FF00), // Bright Green
|
||||
Color(0xFF00FFFF), // Cyan
|
||||
Color(0xFFFFFF00), // Yellow
|
||||
Color(0xFFFF00FF), // Magenta
|
||||
Color(0xFF0080FF), // Bright Blue
|
||||
Color(0xFFFF8000), // Orange
|
||||
Color(0xFF80FF00), // Lime Green
|
||||
Color(0xFF8000FF), // Purple
|
||||
Color(0xFFFF0080), // Pink
|
||||
Color(0xFF00FF80), // Spring Green
|
||||
Color(0xFF80FFFF), // Light Cyan
|
||||
Color(0xFFFF8080), // Light Red
|
||||
Color(0xFF8080FF), // Light Blue
|
||||
Color(0xFFFFFF80), // Light Yellow
|
||||
Color(0xFFFF80FF), // Light Magenta
|
||||
Color(0xFF80FF80), // Light Green
|
||||
)
|
||||
|
||||
// Use modulo to get consistent color for same identifier
|
||||
return colors[(hash % colors.size.toUInt()).toInt()]
|
||||
}
|
||||
|
||||
@Test
|
||||
fun is_username_derived_color_consistent() {
|
||||
|
||||
println("Testing username color function:")
|
||||
|
||||
val testUsers = listOf("alice", "bob", "charlie", "diana", "eve")
|
||||
|
||||
testUsers.forEach { user ->
|
||||
val color = getUsernameColor(user)
|
||||
println("User '$user' gets color: ${color.value.toString(16).uppercase()}")
|
||||
}
|
||||
|
||||
val `alice'sColor` = getUsernameColor(testUsers[0])
|
||||
val `bob'sColor` = getUsernameColor(testUsers[1])
|
||||
val `charlie'sColor` = getUsernameColor(testUsers[2])
|
||||
val `diana'sColor` = getUsernameColor(testUsers[3])
|
||||
val `eve'sColor` = getUsernameColor(testUsers[4])
|
||||
|
||||
// Test consistency - same user should always get same color
|
||||
println("\nTesting consistency:")
|
||||
repeat(3) {
|
||||
val `alice's_color` = getUsernameColor(testUsers[0])
|
||||
val `bob's_color` = getUsernameColor(testUsers[1])
|
||||
val `charlie's_color` = getUsernameColor(testUsers[2])
|
||||
val `diana's_color` = getUsernameColor(testUsers[3])
|
||||
val `eve's_color` = getUsernameColor(testUsers[4])
|
||||
|
||||
|
||||
assertEquals(`alice'sColor`, `alice's_color`)
|
||||
assertEquals(`bob'sColor`, `bob's_color`)
|
||||
assertEquals(`charlie'sColor`, `charlie's_color`)
|
||||
assertEquals(`diana'sColor`, `diana's_color`)
|
||||
assertEquals(`eve'sColor`, `eve's_color`)
|
||||
|
||||
println("Alice color (test ${it + 1}): ${`alice'sColor`.value.toString(16).uppercase()}")
|
||||
}
|
||||
}
|
||||
}
|
||||