diff --git a/CHANGELOG.md b/CHANGELOG.md index c586165d..6e8bd3aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Permission onboarding screen UX: removed "Exit App" button and fixed "Grant Permissions" button positioning to always be visible + +## [0.6] + +### Added +- Channel password management with `/pass` command for channel owners +- Monochrome/themed launcher icon for Android 12+ dynamic theming support +- Unit tests package with initial testing infrastructure +- Production build optimization with code minification and shrinking +- Native back gesture/button handling for all app views + +### Fixed +- Favorite peer functionality completely restored and improved + - Enhanced favorite system with fallback mechanism for peers without key exchange + - Fixed UI state updates for favorite stars in both header and sidebar + - Improved favorite persistence across app sessions - `/w` command now displays user nicknames instead of peer IDs +- Button styling and layout improvements across the app + - Enhanced back button positioning and styling + - Improved private chat and channel header button layouts + - Fixed button padding and alignment issues +- Color scheme consistency updates + - Updated orange color throughout the app to match iOS version + - Consistent color usage for private messages and UI elements +- App startup reliability improvements + - Better initialization sequence handling + - Fixed null pointer exceptions during startup + - Enhanced error handling and logging +- Input field styling and behavior improvements +- Sidebar user interaction enhancements +- Permission explanation screen layout fixes with proper vertical padding + +### Changed +- Updated GitHub organization references in project files +- Improved README documentation with updated clone URLs +- Enhanced logging throughout the application for better debugging ## [0.5.1] - 2025-07-10 diff --git a/CHAT_REFACTORING_PLAN.md b/CHAT_REFACTORING_PLAN.md deleted file mode 100644 index 934366cb..00000000 --- a/CHAT_REFACTORING_PLAN.md +++ /dev/null @@ -1,64 +0,0 @@ -# ChatScreen.kt Refactoring Plan - -## Current State -- Single file: `ChatScreen.kt` (~1,100+ lines) -- Multiple UI responsibilities mixed together -- Hard to maintain and test individual components - -## Proposed Component Structure - -### 1. Main Screen (ChatScreen.kt) -**Responsibilities:** -- Main layout orchestration -- State management delegation -- Window insets handling -- Component coordination - -### 2. Header Components (ChatHeader.kt) -**Responsibilities:** -- TopAppBar with different states (main, private, channel) -- Nickname editor -- Peer counter with status indicators -- Navigation controls - -### 3. Message Components (MessageComponents.kt) -**Responsibilities:** -- MessagesList composable -- MessageItem with formatting -- Message text parsing and styling -- Delivery status indicators -- RSSI-based coloring - -### 4. Input Components (InputComponents.kt) -**Responsibilities:** -- MessageInput with different modes -- Command suggestions box -- Command suggestion items -- Input validation and handling - -### 5. Sidebar Components (SidebarComponents.kt) -**Responsibilities:** -- SidebarOverlay with navigation -- ChannelsSection for channel management -- PeopleSection for peer list -- Sidebar state management - -### 6. Dialog Components (DialogComponents.kt) -**Responsibilities:** -- Password prompt dialog -- App info dialog -- Other modal dialogs - -### 7. UI Utils (ChatUIUtils.kt) -**Responsibilities:** -- RSSI color mapping -- Text formatting utilities -- Common styling constants -- Helper functions - -## Benefits -- Each file has a single, clear responsibility -- Components are easier to test in isolation -- Better code organization and navigation -- Simplified debugging -- Easier to add new UI features diff --git a/REFACTORING_PROGRESS.md b/REFACTORING_PROGRESS.md deleted file mode 100644 index 147a91d0..00000000 --- a/REFACTORING_PROGRESS.md +++ /dev/null @@ -1,103 +0,0 @@ -# BluetoothMeshService Refactoring - Progress Report - -## ✅ COMPLETED: Extracted Components (All compile successfully) - -### 1. PeerManager.kt (161 lines) -**Responsibilities:** -- Active peer tracking and lifecycle management -- Peer nickname management and stale peer cleanup -- RSSI tracking and peer list updates -- **Interface:** `PeerManagerDelegate` - -### 2. FragmentManager.kt (194 lines) -**Responsibilities:** -- Message fragmentation for large messages (>500 bytes) -- Fragment reassembly and cleanup -- Fragment timeout management (30 seconds) -- **Interface:** `FragmentManagerDelegate` - -### 3. SecurityManager.kt (236 lines) -**Responsibilities:** -- Duplicate detection and replay attack protection -- Key exchange handling and validation -- Message encryption/decryption operations -- Packet signature verification -- **Interface:** `SecurityManagerDelegate` - -### 4. StoreForwardManager.kt (295 lines) -**Responsibilities:** -- Message caching for offline peers (12 hours regular, unlimited favorites) -- Store-and-forward delivery when peers come online -- Cache cleanup and management -- **Interface:** `StoreForwardManagerDelegate` - -### 5. MessageHandler.kt (284 lines) -**Responsibilities:** -- Processing different message types (ANNOUNCE, MESSAGE, LEAVE, etc.) -- Broadcast vs private message handling -- Message relay logic with adaptive probability -- Delivery acknowledgment sending -- **Interface:** `MessageHandlerDelegate` - -### 6. BluetoothConnectionManager.kt (611 lines) -**Responsibilities:** -- BLE advertising and scanning -- GATT server/client setup and management -- Device connection tracking (both server and client modes) -- Packet broadcasting to all connected devices -- **Interface:** `BluetoothConnectionManagerDelegate` - -## 📊 Size Reduction Analysis - -| Component | Lines | Responsibility | -|-----------|--------|----------------| -| **PeerManager** | 161 | Peer lifecycle & tracking | -| **FragmentManager** | 194 | Message fragmentation | -| **SecurityManager** | 236 | Security & encryption | -| **StoreForwardManager** | 295 | Offline message caching | -| **MessageHandler** | 284 | Message type processing | -| **BluetoothConnectionManager** | 611 | BLE connection management | -| **Original File** | ~1000+ | All responsibilities mixed | - -**Total Extracted:** ~1781 lines (distributed across 6 focused files) -**Reduction Factor:** Original single file → 6 smaller, focused components - -## 🏗️ Next Steps for Integration - -### Phase 1: Refactor Existing BluetoothMeshService -1. **Update BluetoothMeshService.kt** to use the new components -2. **Wire up all delegate interfaces** -3. **Maintain exact same public API** so ChatViewModel doesn't change -4. **Test compilation and functionality** - -### Phase 2: Integration Testing -1. **Verify all existing functionality works** -2. **Test key scenarios:** - - Peer discovery and connection - - Message sending/receiving - - Fragment handling - - Store-and-forward delivery - - Security validation - -### Phase 3: Benefits Validation -1. **Easier unit testing** (each component can be tested independently) -2. **Better code maintainability** (clear separation of concerns) -3. **Improved debugging** (isolated component logs) -4. **Future extensibility** (easier to add new features) - -## 🔧 Current Build Status -✅ **All 6 extracted components compile successfully** -✅ **No breaking changes to existing interfaces** -✅ **Original BluetoothMeshService.kt still intact** -✅ **Ready for integration phase** - -## 💡 Key Design Decisions Made - -1. **Delegate Pattern:** Each component uses a delegate interface for clean separation -2. **Coroutine Scope per Component:** Isolated lifecycle management -3. **Thread-Safe Collections:** Maintained from original implementation -4. **Same UUIDs and Constants:** No protocol changes -5. **Preserved iOS Compatibility:** All timing and logic matches iOS exactly -6. **Error Handling:** Maintained original defensive programming patterns - -The refactoring successfully breaks down a monolithic 1000+ line service into 6 focused, maintainable components while preserving 100% compatibility with the iOS implementation. diff --git a/RSSI_COLOR_FIX_SUMMARY.md b/RSSI_COLOR_FIX_SUMMARY.md deleted file mode 100644 index 78c616c5..00000000 --- a/RSSI_COLOR_FIX_SUMMARY.md +++ /dev/null @@ -1,80 +0,0 @@ -# RSSI Color Change Fix - Implementation Summary - -## Problem Identified -The username colors in the chat were not changing based on RSSI signal strength even though RSSI values were being logged as changing. Investigation revealed that: - -1. **RSSI values were only captured once** during the initial BLE scan discovery -2. **No mechanism existed** to continuously update RSSI values from connected devices -3. **UI was not being notified** of RSSI changes to trigger recomposition -4. **Chat rendering was using stale RSSI values** that never changed after initial connection - -## Root Cause -The `BluetoothMeshService` was only recording RSSI during the scan phase (`handleScanResult`) but never updating it during the connection lifetime. Bluetooth GATT provides `readRemoteRssi()` for connected devices, but this wasn't being used. - -## Solution Implemented - -### 1. Device-to-Peer ID Mapping -- Added `deviceToPeerIDMapping` to link Bluetooth devices to peer IDs -- This allows RSSI updates to be associated with the correct peer ID - -### 2. GATT RSSI Reading Callback -- Added `onReadRemoteRssi()` callback in the GATT client callback -- Updates `peerRSSI` map when new RSSI values are read -- Maps device addresses to peer IDs for proper tracking - -### 3. Periodic RSSI Monitoring -- Added background coroutine that runs every 5 seconds -- Calls `readRemoteRssi()` on all active GATT connections -- Provides continuous RSSI updates during connection lifetime - -### 4. UI Notification System -- Added `didUpdateRSSI()` delegate method to notify UI of RSSI changes -- When RSSI changes, the delegate is called to potentially trigger UI updates -- Chat screen automatically recomposes when RSSI values change - -### 5. Key Exchange Enhancement -- Modified `handleKeyExchange()` to map devices to peer IDs -- Transfers any existing peripheral RSSI data to peer-based tracking -- Ensures proper RSSI association after peer identification - -## Code Changes Made - -### BluetoothMeshService.kt -1. **Added device mapping**: `deviceToPeerIDMapping` concurrent hash map -2. **RSSI callback**: `onReadRemoteRssi()` implementation in GATT callback -3. **Periodic monitoring**: `monitorRSSI()` function called every 5 seconds -4. **Key exchange update**: Links devices to peer IDs for RSSI tracking -5. **Delegate method**: `didUpdateRSSI()` interface method for UI notifications - -### ChatViewModel.kt -1. **Delegate implementation**: Added `didUpdateRSSI()` method -2. **Logging**: Debug output when RSSI values change - -### ChatScreen.kt -- **No changes needed** - existing `getRSSIColor(rssi)` function already works -- UI automatically recomposes when `meshService.getPeerRSSI()` returns updated values - -## How It Works Now - -1. **Initial Discovery**: RSSI captured during BLE scan (as before) -2. **Connection**: Device mapped to peer ID during key exchange -3. **Monitoring**: Every 5 seconds, `readRemoteRssi()` called on connected devices -4. **Update**: RSSI callback updates `peerRSSI` map with new values -5. **Notification**: Delegate notified of RSSI change -6. **Rendering**: Chat screen uses updated RSSI values for color calculation -7. **Recomposition**: UI automatically updates with new colors - -## Expected Behavior -- Username colors now change dynamically as users move closer/farther away -- Colors reflect real-time signal strength: green (strong) → yellow (medium) → red (weak) -- Updates occur every 5 seconds while devices are connected -- Your own username remains green regardless of signal strength -- Works for both client and server GATT connections - -## Testing -- Build successful with `./gradlew assembleDebug` -- No compilation errors -- All existing functionality preserved -- Ready for testing with actual devices - -The fix addresses the core issue where RSSI values were static after connection. Now they continuously update, providing the dynamic color feedback based on signal strength that was originally intended. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce8717bd..be7f1b23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,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 { diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index a1f5390c..5997a9f1 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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 create(modelClass: Class): 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}") } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 87655ef8..8ef97f14 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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) }}") } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 5d4634f0..87d3cb1d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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") diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index f09d9783..4f066096 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -21,120 +21,130 @@ import androidx.compose.ui.unit.sp @Composable fun PermissionExplanationScreen( permissionCategories: List, - 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 } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index 7060fb1c..7d293324 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -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, val isGranted: Boolean, val systemDescription: String ) + +enum class PermissionType(val nameValue: String) { + NEARBY_DEVICES("Nearby Devices"), + PRECISE_LOCATION("Precise Location"), + NOTIFICATIONS("Notifications"), + OTHER("Other") +} diff --git a/app/src/main/java/com/bitchat/android/services/MessageRetentionService.kt b/app/src/main/java/com/bitchat/android/services/MessageRetentionService.kt new file mode 100644 index 00000000..a0651850 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/MessageRetentionService.kt @@ -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 { + 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 = 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 { + if (!file.exists()) { + return emptyList() + } + + return try { + FileInputStream(file).use { fis -> + ObjectInputStream(fis).use { ois -> + @Suppress("UNCHECKED_CAST") + ois.readObject() as List + } + } + } 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) { + 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 + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index d4372157..65e2ee8e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 4fe40704..4c9e30dc 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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() } ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 9dc16cf6..4a2e9bda 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -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() + // Navigation state + private val _showAppInfo = MutableLiveData(false) + val showAppInfo: LiveData = _showAppInfo + // Unread state computed properties val hasUnreadChannels: MediatorLiveData = MediatorLiveData() val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() @@ -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) { @@ -188,7 +194,21 @@ class ChatState { } fun setFavoritePeers(favorites: Set) { + 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 } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4240c1cf..190f696d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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 = state.showCommandSuggestions val commandSuggestions: LiveData> = state.commandSuggestions val favoritePeers: LiveData> = state.favoritePeers + val showAppInfo: LiveData = 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 } } } diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 73ffbcfe..0f16a1e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index cb94ab4e..6c70241e 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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() @@ -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 { + 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 { return peerIDToPublicKeyFingerprint.toMap() } diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index a7ee2c4f..4186a76f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -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 { !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) + } ) } } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index faef2e6f..b63113ef 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,10 +1,10 @@ + android:height="108dp"> + android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z" + android:fillColor="#000000" /> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 91e2961e..dbc4ad64 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,51 +1,15 @@ - - - - - - - - - - - - - - - - - - - - - - + android:height="108dp"> + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml index c531b643..a9e8c8af 100644 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -1,60 +1,16 @@ - - - - - - - - - - + android:height="108dp"> + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index fdc2d7a9..d1284254 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png b/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png new file mode 100644 index 00000000..a44d618b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png b/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 00000000..8d671b3c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index fdc2d7a9..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index fe8d9352..c7771a9e 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png b/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png new file mode 100644 index 00000000..43aaf744 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png b/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 00000000..3a361855 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index fe8d9352..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index f85ced2c..c8d2b212 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 00000000..31dd24c5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 00000000..45a6a5a6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index f85ced2c..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 6516c53c..a6daa087 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 00000000..6422ea47 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 00000000..dba13791 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 6516c53c..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 5c73d4e6..10fa135e 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 00000000..49a37c8e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 00000000..f1e4c104 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 5c73d4e6..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/demo_colors.md b/demo_colors.md deleted file mode 100644 index c463b41c..00000000 --- a/demo_colors.md +++ /dev/null @@ -1,62 +0,0 @@ -# Username Color Feature Demo - -The bitchat Android app already has the username color feature fully implemented! Here's how it works: - -## How It Works - -1. **Your username remains green** - Uses `colorScheme.primary` (bright green in dark mode, dark green in light mode) -2. **Other users get unique colors** - Based on their peer ID using the `getUsernameColor()` function -3. **Colors are consistent** - Same user always gets the same color across sessions -4. **Terminal-friendly palette** - 16 colors that work on both black and white backgrounds - -## Color Palette - -The system uses these 16 terminal-friendly colors for other users: - -- 🟢 Bright Green (#00FF00) -- 🔵 Cyan (#00FFFF) -- 🟡 Yellow (#FFFF00) -- 🔴 Magenta (#FF00FF) -- 🟦 Bright Blue (#0080FF) -- 🟠 Orange (#FF8000) -- 🔶 Lime Green (#80FF00) -- 🟣 Purple (#8000FF) -- 🩷 Pink (#FF0080) -- 💚 Spring Green (#00FF80) -- 🟦 Light Cyan (#80FFFF) -- 🩷 Light Red (#FF8080) -- 🟦 Light Blue (#8080FF) -- 🟡 Light Yellow (#FFFF80) -- 🩷 Light Magenta (#FF80FF) -- 🟢 Light Green (#80FF80) - -## Example Chat Display - -``` -[14:23:45] <@you> hello everyone! ← Your message (green) -[14:23:47] <@alice> hey there! ← Alice (cyan) -[14:23:50] <@bob> how's it going? ← Bob (yellow) -[14:23:52] <@charlie> great to see you all ← Charlie (magenta) -[14:23:55] <@you> having a great time ← Your message (green) -[14:23:58] <@alice> same here @you! ← Alice (cyan again) -``` - -## Code Implementation - -The feature is implemented in `/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt`: - -- Line 342-350: Color assignment logic in `formatMessageAsAnnotatedString()` -- Line 820-855: `getUsernameColor()` function that generates consistent colors -- Uses peer ID for consistency (falls back to nickname if no peer ID available) -- Integrates perfectly with the existing IRC-style chat format - -## Testing - -The feature is already working in the app. When you chat with multiple users, you'll see: -- Your messages in green -- Each other user in their own unique color -- Same user always has the same color -- Colors remain consistent across app restarts -- Works in both light and dark themes - -The feature is **complete and ready to use**! 🎉 diff --git a/refactoring_plan.md b/refactoring_plan.md deleted file mode 100644 index fe61e413..00000000 --- a/refactoring_plan.md +++ /dev/null @@ -1,73 +0,0 @@ -# BluetoothMeshService Refactoring Plan - -## Current State -- Single file: `BluetoothMeshService.kt` (~1000+ lines) -- Multiple responsibilities mixed together -- Hard to test and maintain - -## Proposed Structure - -### 1. Core Service (BluetoothMeshService.kt) -**Responsibilities:** -- Service lifecycle management -- Coordination between components -- Public API for sending messages -- Delegate management - -### 2. Connection Management (BluetoothConnectionManager.kt) -**Responsibilities:** -- BLE scanning and advertising -- GATT server/client setup and management -- Device connection tracking -- Peer discovery and RSSI tracking - -### 3. Packet Processing (PacketProcessor.kt) -**Responsibilities:** -- Incoming packet handling -- Message type routing -- TTL and duplicate detection -- Timestamp validation - -### 4. Message Handler (MessageHandler.kt) -**Responsibilities:** -- Processing specific message types (ANNOUNCE, MESSAGE, LEAVE, etc.) -- Message parsing and validation -- Relay logic - -### 5. Fragment Manager (FragmentManager.kt) -**Responsibilities:** -- Message fragmentation for large messages -- Fragment reassembly -- Fragment cleanup and timeouts - -### 6. Store-and-Forward Manager (StoreForwardManager.kt) -**Responsibilities:** -- Message caching for offline peers -- Delivering cached messages when peers come online -- Cache cleanup and management - -### 7. Peer Manager (PeerManager.kt) -**Responsibilities:** -- Active peer tracking -- Peer nickname management -- Stale peer cleanup -- Peer list updates - -### 8. Security Manager (SecurityManager.kt) -**Responsibilities:** -- Key exchange handling -- Message signing and verification -- Duplicate detection tracking - -## Refactoring Strategy -1. Extract each component while maintaining exact functionality -2. Use dependency injection for component communication -3. Ensure all existing tests pass -4. Maintain the same public API - -## Benefits -- Easier to test individual components -- Better separation of concerns -- More maintainable code -- Easier to add new features -- Better code reuse