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