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 faaa3a45..7fb2ce14 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -128,15 +128,17 @@ class BluetoothConnectionManager( try { isActive = true + + // Setup GATT server first setupGattServer() // Start power manager and services connectionScope.launch { powerManager.start() - delay(500) // Ensure GATT server is ready + delay(300) // Brief delay to ensure GATT server is ready startAdvertising() - delay(200) + delay(100) if (powerManager.shouldUseDutyCycle()) { Log.i(TAG, "Using power-aware duty cycling") @@ -146,13 +148,14 @@ class BluetoothConnectionManager( startPeriodicCleanup() - Log.i(TAG, "Power-optimized Bluetooth services started successfully (CLIENT ONLY)") + Log.i(TAG, "Power-optimized Bluetooth services started successfully") } return true } catch (e: Exception) { Log.e(TAG, "Failed to start Bluetooth services: ${e.message}") + isActive = false return false } } @@ -355,6 +358,12 @@ class BluetoothConnectionManager( val serverCallback = object : BluetoothGattServerCallback() { override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + // Guard against callbacks after service shutdown + if (!isActive) { + Log.d(TAG, "Server: Ignoring connection state change after shutdown") + return + } + when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.d(TAG, "Server: Device connected ${device.address}") @@ -371,6 +380,20 @@ class BluetoothConnectionManager( } } + override fun onServiceAdded(status: Int, service: BluetoothGattService) { + // Guard against callbacks after service shutdown + if (!isActive) { + Log.d(TAG, "Server: Ignoring service added callback after shutdown") + return + } + + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "Server: Service added successfully: ${service.uuid}") + } else { + Log.e(TAG, "Server: Failed to add service: ${service.uuid}, status: $status") + } + } + override fun onCharacteristicWriteRequest( device: BluetoothDevice, requestId: Int, @@ -380,11 +403,22 @@ class BluetoothConnectionManager( offset: Int, value: ByteArray ) { + // Guard against callbacks after service shutdown + if (!isActive) { + Log.d(TAG, "Server: Ignoring characteristic write after shutdown") + return + } + if (characteristic.uuid == CHARACTERISTIC_UUID) { + Log.d(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes") val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = String(packet.senderID).replace("\u0000", "") + Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID") delegate?.onPacketReceived(packet, peerID, device) + } else { + Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes") + Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") } if (responseNeeded) { @@ -402,13 +436,21 @@ class BluetoothConnectionManager( offset: Int, value: ByteArray ) { + // Guard against callbacks after service shutdown + if (!isActive) { + Log.d(TAG, "Server: Ignoring descriptor write after shutdown") + return + } + if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) { Log.d(TAG, "Device ${device.address} subscribed to notifications") subscribedDevices.add(device) connectionScope.launch { delay(100) - delegate?.onDeviceConnected(device) + if (isActive) { // Check if still active + delegate?.onDeviceConnected(device) + } } } @@ -418,9 +460,25 @@ class BluetoothConnectionManager( } } - // Clean up existing server - gattServer?.close() + // Proper cleanup sequencing to prevent race conditions + gattServer?.let { server -> + Log.d(TAG, "Cleaning up existing GATT server") + try { + server.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing existing GATT server: ${e.message}") + } + } + // Small delay to ensure cleanup is complete + Thread.sleep(100) + + if (!isActive) { + Log.d(TAG, "Service inactive, skipping GATT server creation") + return + } + + // Create new server gattServer = bluetoothManager.openGattServer(context, serverCallback) // Create characteristic with notification support @@ -782,10 +840,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 0a66bcf8..7732fd0f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -47,6 +47,9 @@ class BluetoothMeshService(private val context: Context) { internal val connectionManager = BluetoothConnectionManager(context, myPeerID) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) + // Service state management + private var isActive = false + // Delegate for message callbacks (maintains same interface) var delegate: BluetoothMeshDelegate? = null @@ -66,10 +69,10 @@ class BluetoothMeshService(private val context: Context) { while (isActive) { try { delay(10000) // 10 seconds - val debugInfo = getDebugStatus() - Log.d(TAG, "=== PERIODIC DEBUG STATUS ===") - Log.d(TAG, debugInfo) - Log.d(TAG, "=== END DEBUG STATUS ===") + if (isActive) { // Double-check before logging + val debugInfo = getDebugStatus() + Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===") + } } catch (e: Exception) { Log.e(TAG, "Error in periodic debug logging: ${e.message}") } @@ -98,7 +101,10 @@ class BluetoothMeshService(private val context: Context) { // SecurityManager delegate for key exchange notifications securityManager.delegate = object : SecurityManagerDelegate { - override fun onKeyExchangeCompleted(peerID: String) { + override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + // Notify delegate about key exchange completion so it can register peer fingerprint + delegate?.registerPeerPublicKey(peerID, peerPublicKeyData) + // Send announcement and cached messages after key exchange serviceScope.launch { delay(100) @@ -276,9 +282,16 @@ class BluetoothMeshService(private val context: Context) { * Start the mesh service */ fun startServices() { + // Prevent double starts (defensive programming) + if (isActive) { + Log.w(TAG, "Mesh service already active, ignoring duplicate start request") + return + } + Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") if (connectionManager.startServices()) { + isActive = true Log.i(TAG, "Bluetooth services started successfully") // Send initial announcements after services are ready @@ -295,7 +308,13 @@ class BluetoothMeshService(private val context: Context) { * Stop all mesh services */ fun stopServices() { + if (!isActive) { + Log.w(TAG, "Mesh service not active, ignoring stop request") + return + } + Log.i(TAG, "Stopping Bluetooth mesh service") + isActive = false // Send leave announcement sendLeaveAnnouncement() @@ -547,4 +566,5 @@ interface BluetoothMeshDelegate { fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun getNickname(): String? fun isFavorite(peerID: String): Boolean + fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) } diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 3b655d08..65b74e42 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -113,7 +113,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private Log.d(TAG, "Successfully processed key exchange from $peerID") // Notify delegate - delegate?.onKeyExchangeCompleted(peerID) + delegate?.onKeyExchangeCompleted(peerID, packet.payload) return true @@ -315,5 +315,5 @@ class SecurityManager(private val encryptionService: EncryptionService, private * Delegate interface for security manager callbacks */ interface SecurityManagerDelegate { - fun onKeyExchangeCompleted(peerID: String) + fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) } 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 14470a1d..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)) } } 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 6ef3fc2b..5db01a2d 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -116,7 +116,7 @@ class PermissionManager(private val context: Context) { categories.add( PermissionCategory( type = PermissionType.NEARBY_DEVICES, - description = "Required to discover and connect to other bitchat users via Bluetooth", + description = "Required to discover bitchat users via Bluetooth", permissions = bluetoothPermissions, isGranted = bluetoothPermissions.all { isPermissionGranted(it) }, systemDescription = "Allow bitchat to connect to nearby devices" @@ -132,10 +132,10 @@ class PermissionManager(private val context: Context) { categories.add( PermissionCategory( type = PermissionType.PRECISE_LOCATION, - description = "Required by Android for Bluetooth scanning.", + 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" ) ) @@ -144,7 +144,7 @@ class PermissionManager(private val context: Context) { categories.add( PermissionCategory( type = PermissionType.NOTIFICATIONS, - description = "Show notifications when you receive private messages while the app is in background", + description = "Notifications to keep you updated", permissions = listOf(Manifest.permission.POST_NOTIFICATIONS), isGranted = isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS), systemDescription = "Allow bitchat to send you notifications" 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 7d9a5055..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,11 +255,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B } fun toggleFavorite(peerID: String) { + Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) + + // Log current state after toggle + logCurrentFavoriteState() } - fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) { - privateChatManager.registerPeerPublicKey(peerID, publicKeyData) + private fun logCurrentFavoriteState() { + Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") + Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") + Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") + Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") + Log.i("ChatViewModel", "==============================") } // MARK: - Debug and Troubleshooting @@ -263,18 +276,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B return meshService.getDebugStatus() } - fun restartMeshServices() { - viewModelScope.launch { - meshService.stopServices() - delay(1000) - meshService.startServices() - } - } + // Note: Mesh service restart is now handled by MainActivity + // This function is no longer needed fun setAppBackgroundState(inBackground: Boolean) { - // Forward to connection manager for power optimization - meshService.connectionManager.setAppBackgroundState(inBackground) - // Forward to notification manager for notification logic notificationManager.setAppBackgroundState(inBackground) } @@ -341,6 +346,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B return meshDelegateHandler.isFavorite(peerID) } + override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) { + privateChatManager.registerPeerPublicKey(peerID, publicKeyData) + } + // MARK: - Emergency Clear fun panicClearAllData() { @@ -355,13 +364,62 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B state.setNickname(newNickname) dataManager.saveNickname(newNickname) - // Disconnect from mesh - meshService.stopServices() - - // Restart services with new identity - viewModelScope.launch { - delay(500) - meshService.startServices() + // Note: Mesh service restart is now handled by MainActivity + // This method now only clears data, not mesh service lifecycle + } + + // MARK: - Navigation Management + + fun showAppInfo() { + state.setShowAppInfo(true) + } + + fun hideAppInfo() { + state.setShowAppInfo(false) + } + + fun showSidebar() { + state.setShowSidebar(true) + } + + fun hideSidebar() { + state.setShowSidebar(false) + } + + /** + * Handle Android back navigation + * Returns true if the back press was handled, false if it should be passed to the system + */ + fun handleBackPressed(): Boolean { + return when { + // Close app info dialog + state.getShowAppInfoValue() -> { + hideAppInfo() + true + } + // Close sidebar + state.getShowSidebarValue() -> { + hideSidebar() + true + } + // Close password dialog + state.getShowPasswordPromptValue() -> { + state.setShowPasswordPrompt(false) + state.setPasswordPromptChannel(null) + true + } + // Exit private chat + state.getSelectedPrivateChatPeerValue() != null -> { + endPrivateChat() + true + } + // Exit channel view + state.getCurrentChannelValue() != null -> { + switchToChannel(null) + true + } + // No special navigation state - let system handle (usually exits app) + else -> false } } } 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/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 873d9c56..d8e8503a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -154,4 +154,8 @@ class MeshDelegateHandler( override fun isFavorite(peerID: String): Boolean { return privateChatManager.isFavorite(peerID) } + + override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) { + privateChatManager.registerPeerPublicKey(peerID, publicKeyData) + } } 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/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index fdc2d7a9..096a0094 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_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index fdc2d7a9..096a0094 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png 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..eb98d48b 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_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index fe8d9352..eb98d48b 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png 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..fa171167 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_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index f85ced2c..fa171167 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png 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..7c81e83c 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_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 6516c53c..7c81e83c 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png 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..335769ff 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_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 5c73d4e6..335769ff 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png 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