diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3cc22c28d..1f8374679 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,11 @@ + + + + + @@ -50,5 +55,17 @@ + + + + + + diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2e428a285..a5286a7a8 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -50,7 +50,7 @@ class MainActivity : ComponentActivity() { private lateinit var locationStatusManager: LocationStatusManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager - // Core mesh service - managed at app level + // Core mesh service - managed via shared holder for persistence private lateinit var meshService: BluetoothMeshService private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { @@ -67,8 +67,8 @@ class MainActivity : ComponentActivity() { // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + // Initialize core mesh service from shared holder + meshService = com.bitchat.android.mesh.MeshServiceHolder.get(this) bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -104,6 +104,32 @@ class MainActivity : ComponentActivity() { } } } + + // If persistent mesh is enabled and permissions are in place, skip onboarding + val persistentEnabled = getSharedPreferences("bitchat_prefs", MODE_PRIVATE) + .getBoolean("persistent_mesh_enabled", false) + if (persistentEnabled && permissionManager.areAllPermissionsGranted()) { + try { + meshService.delegate = chatViewModel + // Ensure background service is running (idempotent) + com.bitchat.android.services.PersistentMeshService.start(applicationContext) + // Go straight to chat + mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) + // Push current peers to UI immediately for continuity + try { meshService.delegate?.didUpdatePeerList(meshService.getActivePeers()) } catch (_: Exception) {} + // Optionally refresh presence + meshService.sendBroadcastAnnounce() + // Drain any buffered background messages into UI (memory only) + try { + val buffered = com.bitchat.android.services.InMemoryMessageBuffer.drain() + buffered.forEach { msg -> chatViewModel.didReceiveMessage(msg) } + } catch (_: Exception) {} + // Handle any notification intent immediately + handleNotificationIntent(intent) + } catch (e: Exception) { + android.util.Log.w("MainActivity", "Failed fast-path attach to persistent mesh: ${e.message}") + } + } // Collect state changes in a lifecycle-aware manner lifecycleScope.launch { @@ -603,6 +629,12 @@ class MainActivity : ComponentActivity() { delay(500) Log.d("MainActivity", "App initialization complete") mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) + + // Honor persistent mesh setting: ensure foreground service is running if enabled + val prefs = getSharedPreferences("bitchat_prefs", MODE_PRIVATE) + if (prefs.getBoolean("persistent_mesh_enabled", false)) { + com.bitchat.android.services.PersistentMeshService.start(applicationContext) + } } catch (e: Exception) { Log.e("MainActivity", "Failed to initialize app", e) handleOnboardingFailed("Failed to initialize the app: ${e.message}") @@ -622,28 +654,45 @@ class MainActivity : ComponentActivity() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { + // Ensure UI delegate is attached (reclaim from background if needed) + try { meshService.delegate = chatViewModel } catch (_: Exception) {} // Set app foreground state meshService.connectionManager.setAppBackgroundState(false) chatViewModel.setAppBackgroundState(false) + val persistentEnabled = getSharedPreferences("bitchat_prefs", MODE_PRIVATE) + .getBoolean("persistent_mesh_enabled", false) + // Check if Bluetooth was disabled while app was backgrounded - val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() - if (currentBluetoothStatus != BluetoothStatus.ENABLED) { - Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") - mainViewModel.updateBluetoothStatus(currentBluetoothStatus) - mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) - mainViewModel.updateBluetoothLoading(false) - return + if (!persistentEnabled) { + val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() + if (currentBluetoothStatus != BluetoothStatus.ENABLED) { + Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") + mainViewModel.updateBluetoothStatus(currentBluetoothStatus) + mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) + mainViewModel.updateBluetoothLoading(false) + return + } } // Check if location services were disabled while app was backgrounded - val currentLocationStatus = locationStatusManager.checkLocationStatus() - if (currentLocationStatus != LocationStatus.ENABLED) { - Log.w("MainActivity", "Location services disabled while app was backgrounded") - mainViewModel.updateLocationStatus(currentLocationStatus) - mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) - mainViewModel.updateLocationLoading(false) + if (!persistentEnabled) { + val currentLocationStatus = locationStatusManager.checkLocationStatus() + if (currentLocationStatus != LocationStatus.ENABLED) { + Log.w("MainActivity", "Location services disabled while app was backgrounded") + mainViewModel.updateLocationStatus(currentLocationStatus) + mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) + mainViewModel.updateLocationLoading(false) + } } + + // Sync current peers with UI after resume + try { meshService.delegate?.didUpdatePeerList(meshService.getActivePeers()) } catch (_: Exception) {} + // Drain any buffered background messages into UI (memory only) + try { + val buffered = com.bitchat.android.services.InMemoryMessageBuffer.drain() + buffered.forEach { msg -> chatViewModel.didReceiveMessage(msg) } + } catch (_: Exception) {} } } @@ -694,11 +743,13 @@ class MainActivity : ComponentActivity() { Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") } - // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { + // Stop mesh services if not in persistent mode + val prefs = getSharedPreferences("bitchat_prefs", MODE_PRIVATE) + val persistentEnabled = prefs.getBoolean("persistent_mesh_enabled", false) + if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE && !persistentEnabled) { try { meshService.stopServices() - Log.d("MainActivity", "Mesh services stopped successfully") + Log.d("MainActivity", "Mesh services stopped (not persistent)") } catch (e: Exception) { Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") } 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 45b20d526..59943588c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -686,6 +686,11 @@ class BluetoothMeshService(private val context: Context) { * Get peer RSSI values */ fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + + /** + * Get current active peer IDs for immediate UI sync on attach + */ + fun getActivePeers(): List = peerManager.getActivePeerIDs() /** * Check if we have an established Noise session with a peer diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/mesh/MeshServiceHolder.kt new file mode 100644 index 000000000..d5c451854 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshServiceHolder.kt @@ -0,0 +1,19 @@ +package com.bitchat.android.mesh + +import android.content.Context + +/** + * Holds a single shared instance of BluetoothMeshService so the app UI + * and background service can operate on the same mesh without duplication. + */ +object MeshServiceHolder { + @Volatile + private var instance: BluetoothMeshService? = null + + fun get(context: Context): BluetoothMeshService { + return instance ?: synchronized(this) { + instance ?: BluetoothMeshService(context.applicationContext).also { instance = it } + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/AppVisibilityState.kt b/app/src/main/java/com/bitchat/android/services/AppVisibilityState.kt new file mode 100644 index 000000000..5e0c460be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/AppVisibilityState.kt @@ -0,0 +1,15 @@ +package com.bitchat.android.services + +/** + * Process-wide visibility + focus state used by background service + * to avoid duplicate notifications when the app is foregrounded or + * the user is already viewing a specific private chat. + */ +object AppVisibilityState { + @Volatile + var isAppInBackground: Boolean = true + + @Volatile + var currentPrivateChatPeer: String? = null +} + diff --git a/app/src/main/java/com/bitchat/android/services/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/services/BootCompletedReceiver.kt new file mode 100644 index 000000000..79288d496 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/BootCompletedReceiver.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.content.ContextCompat + +/** + * Starts the mesh foreground service on device boot if enabled and permissions are satisfied. + */ +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + + val prefs = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) + val persistentEnabled = prefs.getBoolean("persistent_mesh_enabled", false) + val startOnBoot = prefs.getBoolean("start_on_boot_enabled", false) + + if (!persistentEnabled || !startOnBoot) { + return + } + + if (!hasRequiredPermissions(context)) { + Log.w("BootCompletedReceiver", "Missing permissions; not starting mesh on boot") + return + } + + PersistentMeshService.start(context) + } + + private fun hasRequiredPermissions(context: Context): Boolean { + val required = listOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT, + android.Manifest.permission.BLUETOOTH_ADVERTISE, + android.Manifest.permission.ACCESS_FINE_LOCATION + ) + return required.all { perm -> + ContextCompat.checkSelfPermission(context, perm) == PackageManager.PERMISSION_GRANTED + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/InMemoryMessageBuffer.kt b/app/src/main/java/com/bitchat/android/services/InMemoryMessageBuffer.kt new file mode 100644 index 000000000..1b074eb5e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/InMemoryMessageBuffer.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import java.util.LinkedList + +/** + * Process-local, in-memory message buffer used while the UI is closed. + * - Never touches disk + * - Cleared on process death, reboot, or panic + */ +object InMemoryMessageBuffer { + private const val MAX_MESSAGES = 500 + private val lock = Any() + private val queue: LinkedList = LinkedList() + + fun add(message: BitchatMessage) { + synchronized(lock) { + // Deduplicate by id + if (queue.any { it.id == message.id }) return + queue.addLast(message) + while (queue.size > MAX_MESSAGES) { + queue.removeFirst() + } + } + } + + fun drain(): List { + synchronized(lock) { + if (queue.isEmpty()) return emptyList() + val copy = ArrayList(queue) + queue.clear() + return copy + } + } + + fun clear() { + synchronized(lock) { queue.clear() } + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/PersistentMeshService.kt b/app/src/main/java/com/bitchat/android/services/PersistentMeshService.kt new file mode 100644 index 000000000..b8ab366ed --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PersistentMeshService.kt @@ -0,0 +1,214 @@ +package com.bitchat.android.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.mesh.BluetoothMeshDelegate +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshServiceHolder +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.NotificationManager as DMNotificationManager + +/** + * Foreground service that keeps the Bluetooth mesh alive in the background + * and delivers PM notifications when the app UI is not active. + */ +class PersistentMeshService : Service() { + + companion object { + private const val TAG = "PersistentMeshService" + private const val CHANNEL_ID = "bitchat_mesh_foreground" + private const val NOTIFICATION_ID = 1337 + + fun start(context: Context) { + val intent = Intent(context, PersistentMeshService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, PersistentMeshService::class.java)) + } + } + + private lateinit var mesh: BluetoothMeshService + private lateinit var dmNotifications: DMNotificationManager + + // Minimal headless delegate to surface PMs as notifications when UI is not attached + private val backgroundDelegate = object : BluetoothMeshDelegate { + override fun didReceiveMessage(message: BitchatMessage) { + // Buffer messages in-memory while UI is closed (no disk persistence) + try { InMemoryMessageBuffer.add(message) } catch (_: Exception) {} + + // Only show notifications when app is in background and not focused on this PM + if (message.isPrivate) { + val senderPeer = message.senderPeerID ?: return + val isBg = AppVisibilityState.isAppInBackground + val focusedPeer = AppVisibilityState.currentPrivateChatPeer + if (isBg || (focusedPeer != senderPeer)) { + val senderName = if (message.senderPeerID == message.sender) senderPeer else message.sender + dmNotifications.setAppBackgroundState(isBg) + dmNotifications.setCurrentPrivateChatPeer(focusedPeer) + dmNotifications.showPrivateMessageNotification(senderPeer, senderName, message.content) + } + } + } + + override fun didUpdatePeerList(peers: List) {} + override fun didReceiveChannelLeave(channel: String, fromPeer: String) {} + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {} + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {} + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? = null + override fun getNickname(): String? = loadNickname() + override fun isFavorite(peerID: String): Boolean = false + } + + override fun onCreate() { + super.onCreate() + dmNotifications = DMNotificationManager(applicationContext) + mesh = MeshServiceHolder.get(applicationContext) + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildOngoingNotification()) + + // Ensure mesh is running and delegate includes background notifications without + // disrupting any existing UI delegate. + try { + val existing = mesh.delegate + if (existing != null && existing !== backgroundDelegate) { + mesh.delegate = CombinedDelegate(existing, backgroundDelegate) + } else { + mesh.delegate = backgroundDelegate + } + // App may be in background; let PowerManager manage based on state updates + mesh.startServices() + // Immediately broadcast with the proper nickname so others see us correctly + mesh.sendBroadcastAnnounce() + Log.i(TAG, "Mesh started in foreground service") + } catch (e: Exception) { + Log.e(TAG, "Failed to start mesh in service", e) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + // Do not stop mesh here; UI or settings control lifecycle. + Log.i(TAG, "Foreground service destroyed") + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + // App task removed; ensure background delegate handles callbacks + try { + mesh.delegate = backgroundDelegate + AppVisibilityState.isAppInBackground = true + Log.i(TAG, "Task removed: reattached background delegate") + } catch (_: Exception) {} + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Mesh Background", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps the bitchat mesh running in the background" + setShowBadge(false) + } + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel) + } + } + + private fun buildOngoingNotification(): Notification { + val intent = Intent(this, MainActivity::class.java) + val pi = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Mesh running in background") + .setContentIntent(pi) + .setOngoing(true) + .build() + } + + /** + * Forwards all callbacks to two delegates. + */ + private class CombinedDelegate( + private val a: BluetoothMeshDelegate?, + private val b: BluetoothMeshDelegate? + ) : BluetoothMeshDelegate { + override fun didReceiveMessage(message: BitchatMessage) { + a?.didReceiveMessage(message) + b?.didReceiveMessage(message) + } + + override fun didUpdatePeerList(peers: List) { + a?.didUpdatePeerList(peers) + b?.didUpdatePeerList(peers) + } + + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { + a?.didReceiveChannelLeave(channel, fromPeer) + b?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { + a?.didReceiveDeliveryAck(messageID, recipientPeerID) + b?.didReceiveDeliveryAck(messageID, recipientPeerID) + } + + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { + a?.didReceiveReadReceipt(messageID, recipientPeerID) + b?.didReceiveReadReceipt(messageID, recipientPeerID) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + // Prefer result from a; fall back to b + return a?.decryptChannelMessage(encryptedContent, channel) + ?: b?.decryptChannelMessage(encryptedContent, channel) + } + + override fun getNickname(): String? { + return a?.getNickname() ?: b?.getNickname() + } + + override fun isFavorite(peerID: String): Boolean { + return (a?.isFavorite(peerID) == true) || (b?.isFavorite(peerID) == true) + } + } + + private fun loadNickname(): String { + return try { + val prefs = applicationContext.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) + prefs.getString("nickname", null) ?: mesh.myPeerID + } catch (_: Exception) { + mesh.myPeerID + } + } +} 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 8d56dbdc7..8a5b3065a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -88,6 +88,12 @@ class ChatViewModel( val peerNicknames: LiveData> = state.peerNicknames val peerRSSI: LiveData> = state.peerRSSI val showAppInfo: LiveData = state.showAppInfo + + // Persistent mesh settings + private val _persistentMeshEnabled = androidx.lifecycle.MutableLiveData() + val persistentMeshEnabled: LiveData = _persistentMeshEnabled + private val _startOnBootEnabled = androidx.lifecycle.MutableLiveData() + val startOnBootEnabled: LiveData = _startOnBootEnabled init { // Note: Mesh service delegate is now set by MainActivity @@ -124,6 +130,10 @@ class ChatViewModel( // Initialize session state monitoring initializeSessionStateMonitoring() + + // Load persistent mesh settings + _persistentMeshEnabled.value = dataManager.isPersistentMeshEnabled() + _startOnBootEnabled.value = dataManager.isStartOnBootEnabled() // Note: Mesh service is now started by MainActivity @@ -335,11 +345,15 @@ class ChatViewModel( fun setAppBackgroundState(inBackground: Boolean) { // Forward to notification manager for notification logic notificationManager.setAppBackgroundState(inBackground) + // Update process-wide visibility state for background service + try { com.bitchat.android.services.AppVisibilityState.isAppInBackground = inBackground } catch (_: Exception) {} } fun setCurrentPrivateChatPeer(peerID: String?) { // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) + // Update process-wide visibility state for background service + try { com.bitchat.android.services.AppVisibilityState.currentPrivateChatPeer = peerID } catch (_: Exception) {} } fun clearNotificationsForSender(peerID: String) { @@ -422,11 +436,21 @@ class ChatViewModel( // Clear all notifications notificationManager.clearAllNotifications() + + // Clear any buffered background messages (memory only) + try { com.bitchat.android.services.InMemoryMessageBuffer.clear() } catch (_: Exception) {} // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) + + // Immediately re-announce identity so others see the new nickname + try { + meshService.sendBroadcastAnnounce() + } catch (e: Exception) { + Log.w(TAG, "Failed to broadcast announce after panic: ${e.message}") + } Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared") @@ -480,6 +504,26 @@ class ChatViewModel( fun hideAppInfo() { state.setShowAppInfo(false) } + + // MARK: - Persistent mesh controls + + fun setPersistentMeshEnabled(enabled: Boolean) { + _persistentMeshEnabled.value = enabled + dataManager.setPersistentMeshEnabled(enabled) + val ctx = getApplication().applicationContext + if (enabled) { + // Start foreground service to keep mesh alive + com.bitchat.android.services.PersistentMeshService.start(ctx) + } else { + // Stop foreground service + com.bitchat.android.services.PersistentMeshService.stop(ctx) + } + } + + fun setStartOnBootEnabled(enabled: Boolean) { + _startOnBootEnabled.value = enabled + dataManager.setStartOnBootEnabled(enabled) + } fun showSidebar() { state.setShowSidebar(true) 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 0f16a1e09..9a13c2941 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -202,4 +202,22 @@ class DataManager(private val context: Context) { _channelMembers.clear() prefs.edit().clear().apply() } + + // MARK: - Persistent Mesh Settings + + fun isPersistentMeshEnabled(): Boolean { + return prefs.getBoolean("persistent_mesh_enabled", false) + } + + fun setPersistentMeshEnabled(enabled: Boolean) { + prefs.edit().putBoolean("persistent_mesh_enabled", enabled).apply() + } + + fun isStartOnBootEnabled(): Boolean { + return prefs.getBoolean("start_on_boot_enabled", false) + } + + fun setStartOnBootEnabled(enabled: Boolean) { + prefs.edit().putBoolean("start_on_boot_enabled", enabled).apply() + } } 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 6f8939e9b..d976c741f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -76,58 +76,122 @@ fun SidebarOverlay( SidebarHeader() HorizontalDivider() - - // Scrollable content - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Channels section - if (joinedChannels.isNotEmpty()) { + // Content + bottom toggles + Column(modifier = Modifier.fillMaxSize()) { + // Scroll area fills remaining space above toggles + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Channels section + if (joinedChannels.isNotEmpty()) { + item { + ChannelsSection( + channels = joinedChannels.toList(), // Convert Set to List + currentChannel = currentChannel, + colorScheme = colorScheme, + onChannelClick = { channel -> + viewModel.switchToChannel(channel) + onDismiss() + }, + onLeaveChannel = { channel -> + viewModel.leaveChannel(channel) + }, + unreadChannelMessages = unreadChannelMessages + ) + } + + item { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) } + } + + // People section item { - ChannelsSection( - channels = joinedChannels.toList(), // Convert Set to List - currentChannel = currentChannel, + PeopleSection( + connectedPeers = connectedPeers, + peerNicknames = peerNicknames, + peerRSSI = peerRSSI, + nickname = nickname, colorScheme = colorScheme, - onChannelClick = { channel -> - viewModel.switchToChannel(channel) + selectedPrivatePeer = selectedPrivatePeer, + viewModel = viewModel, + onPrivateChatStart = { peerID -> + viewModel.startPrivateChat(peerID) onDismiss() - }, - onLeaveChannel = { channel -> - viewModel.leaveChannel(channel) - }, - unreadChannelMessages = unreadChannelMessages + } ) } - - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - } - } - - // People section - item { - PeopleSection( - connectedPeers = connectedPeers, - peerNicknames = peerNicknames, - peerRSSI = peerRSSI, - nickname = nickname, - colorScheme = colorScheme, - selectedPrivatePeer = selectedPrivatePeer, - viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.startPrivateChat(peerID) - onDismiss() - } - ) } + + // Bottom persistent options + PersistentOptionsSection(viewModel = viewModel) } } } } } +@Composable +private fun PersistentOptionsSection(viewModel: ChatViewModel) { + val colorScheme = MaterialTheme.colorScheme + val persistentEnabled by viewModel.persistentMeshEnabled.observeAsState(false) + val startOnBootEnabled by viewModel.startOnBootEnabled.observeAsState(false) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Divider(color = colorScheme.outline.copy(alpha = 0.2f)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Persistent mesh", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + Text( + text = "Run network in background", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + Switch(checked = persistentEnabled, onCheckedChange = { viewModel.setPersistentMeshEnabled(it) }) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Start on boot", + style = MaterialTheme.typography.bodyMedium, + color = if (persistentEnabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.5f) + ) + Text( + text = "Launch mesh after device restarts", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + Switch( + checked = startOnBootEnabled && persistentEnabled, + onCheckedChange = { viewModel.setStartOnBootEnabled(it) }, + enabled = persistentEnabled + ) + } + } +} + @Composable private fun SidebarHeader() { val colorScheme = MaterialTheme.colorScheme