Merge branch 'main' into routingOpt

This commit is contained in:
callebtc
2025-07-13 17:20:49 +02:00
43 changed files with 746 additions and 738 deletions
@@ -17,6 +17,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.ViewModelProvider
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.onboarding.*
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
@@ -29,7 +33,17 @@ class MainActivity : ComponentActivity() {
private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator
private lateinit var bluetoothStatusManager: BluetoothStatusManager
private val chatViewModel: ChatViewModel by viewModels()
// 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)
@@ -50,6 +64,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(
@@ -107,9 +124,6 @@ class MainActivity : ComponentActivity() {
onContinue = {
onboardingState = OnboardingState.PERMISSION_REQUESTING
onboardingCoordinator.requestPermissions()
},
onCancel = {
finish()
}
)
}
@@ -123,6 +137,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)
}
@@ -289,7 +321,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 +331,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)
@@ -330,6 +365,8 @@ class MainActivity : ComponentActivity() {
super.onResume()
// Check Bluetooth 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
@@ -347,6 +384,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 +415,32 @@ 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
// 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}")
}
@@ -132,16 +132,16 @@ class BluetoothConnectionManager(
try {
isActive = true
// Setup GATT server first
setupGattServer()
// Start power manager and services
connectionScope.launch {
powerManager.start()
// Setup GATT server after power manager is ready
setupGattServer()
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")
@@ -472,10 +472,15 @@ class BluetoothConnectionManager(
}
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) {
@@ -520,48 +525,47 @@ class BluetoothConnectionManager(
// Proper cleanup sequencing to prevent race conditions
gattServer?.let { server ->
Log.d(TAG, "Cleaning up existing GATT server")
connectionScope.launch {
// Give time for pending callbacks to complete
delay(100)
try {
server.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing existing GATT server: ${e.message}")
}
}
// Create new server after cleanup delay
connectionScope.launch {
delay(200) // Allow previous server to fully close
if (!isActive) {
Log.d(TAG, "Service inactive, skipping GATT server creation")
return@launch
}
gattServer = bluetoothManager.openGattServer(context, serverCallback)
// Create characteristic with notification support
characteristic = BluetoothGattCharacteristic(
CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_READ or
BluetoothGattCharacteristic.PERMISSION_WRITE
)
val descriptor = BluetoothGattDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"),
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
characteristic?.addDescriptor(descriptor)
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(characteristic)
gattServer?.addService(service)
Log.i(TAG, "GATT server setup complete")
// 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
characteristic = BluetoothGattCharacteristic(
CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_READ or
BluetoothGattCharacteristic.PERMISSION_WRITE
)
val descriptor = BluetoothGattDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"),
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
characteristic?.addDescriptor(descriptor)
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(characteristic)
gattServer?.addService(service)
Log.i(TAG, "GATT server setup complete")
}
@Suppress("DEPRECATION")
@@ -898,10 +902,15 @@ 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) }}")
}
}
}
@@ -70,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}")
}
@@ -287,7 +287,7 @@ class BluetoothMeshService(private val context: Context) {
* Start the mesh service
*/
fun startServices() {
// Prevent double starts
// Prevent double starts (defensive programming)
if (isActive) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
@@ -302,9 +302,7 @@ class BluetoothMeshService(private val context: Context) {
// Send initial announcements after services are ready
serviceScope.launch {
delay(1000)
if (isActive) { // Check if still active
sendBroadcastAnnounce()
}
sendBroadcastAnnounce()
}
} else {
Log.e(TAG, "Failed to start Bluetooth services")
@@ -21,120 +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(horizontal = 24.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
Box(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(24.dp))
// 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
)
@@ -148,24 +158,7 @@ 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)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@@ -190,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
@@ -214,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,
@@ -239,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
}
}
@@ -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
@@ -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) }
)
@@ -253,7 +260,10 @@ private fun PrivateChatHeader(
// Favorite button - positioned on the right
IconButton(
onClick = onToggleFavorite,
onClick = {
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
onToggleFavorite()
},
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
@@ -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
}
}
@@ -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,7 +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()
}
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
@@ -259,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)
}
@@ -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
}
}
}
@@ -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
@@ -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.*
@@ -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)
}
)
}
}
@@ -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>
@@ -1,60 +1,16 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
<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"
android:viewportWidth="108"
android:viewportHeight="108"
>
<path
android:name="path"
android:pathData="M 40 45 L 68 45 C 70.2 45 72 46.8 72 49 L 72 59 C 72 61.2 70.2 63 68 63 L 40 63 C 37.8 63 36 61.2 36 59 L 36 49 C 36 46.8 37.8 45 40 45 Z"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeWidth="2"
/>
<path
android:name="path_1"
android:pathData="M 41 47 L 67 47 C 68.1 47 69 47.9 69 49 L 69 57 C 69 58.1 68.1 59 67 59 L 41 59 C 39.9 59 39 58.1 39 57 L 39 49 C 39 47.9 39.9 47 41 47 Z"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeWidth="2"
/>
<path
android:name="path_2"
android:pathData="M 43 51 L 46 51 L 46 55 L 43 55 Z"
android:fillColor="#000000"
/>
<path
android:name="path_3"
android:pathData="M 48 52 L 64 52 L 64 53.5 L 48 53.5 Z"
android:fillColor="#000000"
/>
<path
android:name="path_4"
android:pathData="M 48 54.5 L 60 54.5 L 60 56 L 48 56 Z"
android:fillColor="#000000"
/>
<path
android:name="path_5"
android:pathData="M 62 38 M 60.5 38 C 60.5 37.602 60.658 37.221 60.939 36.939 C 61.221 36.658 61.602 36.5 62 36.5 C 62.398 36.5 62.779 36.658 63.061 36.939 C 63.342 37.221 63.5 37.602 63.5 38 C 63.5 38.398 63.342 38.779 63.061 39.061 C 62.779 39.342 62.398 39.5 62 39.5 C 61.602 39.5 61.221 39.342 60.939 39.061 C 60.658 38.779 60.5 38.398 60.5 38"
android:fillColor="#000000"
/>
<path
android:name="path_6"
android:pathData="M 58.5 35 M 57.5 35 C 57.5 34.735 57.605 34.48 57.793 34.293 C 57.98 34.105 58.235 34 58.5 34 C 58.765 34 59.02 34.105 59.207 34.293 C 59.395 34.48 59.5 34.735 59.5 35 C 59.5 35.265 59.395 35.52 59.207 35.707 C 59.02 35.895 58.765 36 58.5 36 C 58.235 36 57.98 35.895 57.793 35.707 C 57.605 35.52 57.5 35.265 57.5 35"
android:fillColor="#000000"
/>
<path
android:name="path_7"
android:pathData="M 65.5 35 M 64.5 35 C 64.5 34.735 64.605 34.48 64.793 34.293 C 64.98 34.105 65.235 34 65.5 34 C 65.765 34 66.02 34.105 66.207 34.293 C 66.395 34.48 66.5 34.735 66.5 35 C 66.5 35.265 66.395 35.52 66.207 35.707 C 66.02 35.895 65.765 36 65.5 36 C 65.235 36 64.98 35.895 64.793 35.707 C 64.605 35.52 64.5 35.265 64.5 35"
android:fillColor="#000000"
/>
<path
android:name="path_8"
android:pathData="M 59 35.5 L 62.5 38 L 65 35.5"
android:fillColor="#55000000"
android:strokeColor="#000000"
android:strokeWidth="1"
/>
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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB