diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8e9533233..b228fd345 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,6 +19,13 @@
+
+
+
+
+
+
+
@@ -77,5 +84,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index 06fb33c77..b120b2162 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -41,6 +41,12 @@ class BitchatApplication : Application() {
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
+ // Initialize mesh service preferences
+ try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
+
+ // Proactively start the foreground service to keep mesh alive
+ try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
+
// TorManager already initialized above
}
}
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 28d672802..6e9fbd396 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
- // Core mesh service - managed at app level
+ // Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
@@ -66,13 +66,21 @@ class MainActivity : OrientationAwareActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ // Check if this is a quit request from the notification
+ if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
+ android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
+ finish()
+ return
+ }
+
// Enable edge-to-edge display for modern Android look
enableEdgeToEdge()
// Initialize permission management
permissionManager = PermissionManager(this)
- // Initialize core mesh service first
- meshService = BluetoothMeshService(this)
+ // Ensure foreground service is running and get mesh instance from holder
+ try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
+ meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@@ -630,6 +638,15 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
+ setIntent(intent)
+
+ // Check if this is a quit request from the notification
+ if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
+ android.util.Log.d("MainActivity", "Quit request received, finishing activity")
+ finish()
+ return
+ }
+
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
@@ -640,6 +657,8 @@ class MainActivity : OrientationAwareActivity() {
super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
+ // Reattach mesh delegate to new ChatViewModel instance after Activity recreation
+ try { meshService.delegate = chatViewModel } catch (_: Exception) { }
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
@@ -665,13 +684,15 @@ class MainActivity : OrientationAwareActivity() {
}
}
- override fun onPause() {
+ override fun onPause() {
super.onPause()
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app background state
meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
+ // Detach UI delegate so the foreground service can own DM notifications while UI is closed
+ try { meshService.delegate = null } catch (_: Exception) { }
}
}
@@ -746,14 +767,6 @@ class MainActivity : OrientationAwareActivity() {
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) {
- try {
- meshService.stopServices()
- Log.d("MainActivity", "Mesh services stopped successfully")
- } catch (e: Exception) {
- Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
- }
- }
+ // Do not stop mesh here; ForegroundService owns lifecycle for background reliability
}
}
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 3ac87764e..8e0fa15b4 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
@@ -149,6 +149,7 @@ class BluetoothConnectionManager(
try {
isActive = true
+ Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try {
@@ -179,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
+ Log.d(TAG, "GATT Server started")
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
@@ -189,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
+ Log.d(TAG, "GATT Client started")
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
@@ -214,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false
connectionScope.launch {
+ Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers
clientManager.stop()
serverManager.stop()
@@ -230,6 +234,18 @@ class BluetoothConnectionManager(
Log.i(TAG, "All Bluetooth services stopped")
}
}
+
+ /**
+ * Indicates whether this instance can be safely reused for a future start.
+ * Returns false if its coroutine scope has been cancelled.
+ */
+ fun isReusable(): Boolean {
+ val active = connectionScope.isActive
+ if (!active) {
+ Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
+ }
+ return active
+ }
/**
* Set app background state for power optimization
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 dd12138d0..3de484072 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -52,6 +52,12 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
+ // Service-level notification manager for background (no-UI) DMs
+ private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
+ context.applicationContext,
+ androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
+ com.bitchat.android.util.NotificationIntervalManager()
+ )
// Service state management
private var isActive = false
@@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ // Tracks whether this instance has been terminated via stopServices()
+ private var terminated = false
init {
+ Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -98,6 +107,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet)
}
}
+ Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
/**
@@ -105,6 +115,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun startPeriodicDebugLogging() {
serviceScope.launch {
+ Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) {
try {
delay(10000) // 10 seconds
@@ -116,6 +127,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
}
}
+ Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
}
}
@@ -124,6 +136,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch {
+ Log.d(TAG, "Starting periodic announce loop")
while (isActive) {
try {
delay(30000) // 30 seconds
@@ -132,6 +145,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
}
}
+ Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
}
}
@@ -139,6 +153,7 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components
*/
private fun setupDelegates() {
+ Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs
try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +161,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List) {
+ // Update process-wide state first
+ try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
+ // Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
@@ -165,6 +183,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange
serviceScope.launch {
+ Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100)
sendAnnouncementToPeer(peerID)
@@ -352,7 +371,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
+ // Always reflect into process-wide store so UI can hydrate after recreation
+ try {
+ when {
+ message.isPrivate -> {
+ val peer = message.senderPeerID ?: ""
+ if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
+ }
+ message.channel != null -> {
+ com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
+ }
+ else -> {
+ com.bitchat.android.services.AppStateStore.addPublicMessage(message)
+ }
+ }
+ } catch (_: Exception) { }
+ // And forward to UI delegate if attached
delegate?.didReceiveMessage(message)
+
+ // If no UI delegate attached (app closed), show DM notification via service manager
+ if (delegate == null && message.isPrivate) {
+ try {
+ val senderPeerID = message.senderPeerID
+ if (senderPeerID != null) {
+ val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
+ val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
+ serviceNotificationManager.setAppBackgroundState(true)
+ serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
+ }
+ } catch (_: Exception) { }
+ }
}
override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -477,12 +525,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
+ // Log incoming for debug graphs (do not double-count anywhere else)
+ try {
+ val nick = getPeerNicknames()[peerID]
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
+ packetType = packet.type.toString(),
+ fromPeerID = peerID,
+ fromNickname = nick,
+ fromDeviceAddress = device?.address
+ )
+ } catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
serviceScope.launch {
+ Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200)
sendBroadcastAnnounce()
}
@@ -497,6 +556,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
+ Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr]
@@ -535,6 +595,11 @@ class BluetoothMeshService(private val context: Context) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
+ if (terminated) {
+ // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
+ Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
+ return
+ }
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
@@ -546,6 +611,7 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
gossipSyncManager.start()
+ Log.d(TAG, "GossipSyncManager started")
} else {
Log.e(TAG, "Failed to start Bluetooth services")
}
@@ -567,11 +633,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement()
serviceScope.launch {
+ Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send
// Stop all components
gossipSyncManager.stop()
+ Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
+ Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -579,8 +648,23 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown()
packetProcessor.shutdown()
+ // Mark this instance as terminated and cancel its scope so it won't be reused
+ terminated = true
serviceScope.cancel()
+ Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
+ }
+ }
+
+ /**
+ * Whether this instance can be safely reused. Returns false after stopServices() or if
+ * any critical internal scope has been cancelled.
+ */
+ fun isReusable(): Boolean {
+ val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
+ if (!reusable) {
+ Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
}
+ return reusable
}
/**
@@ -801,7 +885,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
-
+
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
@@ -812,8 +896,15 @@ class BluetoothMeshService(private val context: Context) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
-
+
try {
+ // Avoid duplicate read receipts: check persistent store first
+ val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
+ if (seenStore?.hasRead(messageID) == true) {
+ Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
+ return@launch
+ }
+
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -839,7 +930,10 @@ class BluetoothMeshService(private val context: Context) {
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
-
+
+ // Persist as read after successful send
+ try { seenStore?.markRead(messageID) } catch (_: Exception) { }
+
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
}
@@ -852,7 +946,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce")
serviceScope.launch {
- val nickname = delegate?.getNickname() ?: myPeerID
+ val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -901,7 +995,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return
- val nickname = delegate?.getNickname() ?: myPeerID
+ val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -1000,6 +1094,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID)
}
+ /**
+ * Get current active peer count (for status/notifications)
+ */
+ fun getActivePeerCount(): Int {
+ return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
+ }
+
/**
* Get peer info for verification purposes
*/
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
index b34742177..04e5d7564 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
- val isRelay = (incomingAddr != null || incomingPeer != null)
-
- com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
+ val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
+ // Always log outgoing for the actual transmission target
+ manager.logOutgoing(
+ packetType = typeName,
+ toPeerID = toPeer,
+ toNickname = toNick,
+ toDeviceAddress = toDeviceAddress,
+ previousHopPeerID = incomingPeer
+ )
+ // Keep the verbose relay message for human readability
+ manager.logPacketRelayDetailed(
packetType = typeName,
senderPeerID = senderPeerID,
senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
- isRelay = isRelay
+ isRelay = true
)
} catch (_: Exception) {
// Silently ignore debug logging failures
diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
index 9db444e6f..46bc394fb 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
@@ -224,26 +224,29 @@ class PowerManager(private val context: Context) {
}
private fun updatePowerMode() {
- val newMode = when {
- // Always use performance mode when charging (unless in background too long)
+ // Determine the base mode from battery/charging state only
+ val baseMode = when {
+ // Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
-
- // Critical battery - use ultra low power
+
+ // Critical battery - force ultra low power regardless of foreground/background
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
-
- // Low battery - use power saver
+
+ // Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
-
- // Background app with medium battery - use power saver
- isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
-
- // Background app with good battery - use balanced
- isAppInBackground -> PowerMode.BALANCED
-
- // Foreground with good battery - use balanced
+
+ // Otherwise balanced
else -> PowerMode.BALANCED
}
-
+
+ // If app is in background (including when running as a foreground service),
+ // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
+ val newMode = if (isAppInBackground) {
+ if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
+ } else {
+ baseMode
+ }
+
if (newMode != currentMode) {
val oldMode = currentMode
currentMode = newMode
diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
new file mode 100644
index 000000000..d4e001048
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
@@ -0,0 +1,67 @@
+package com.bitchat.android.service
+
+import android.app.Application
+import android.os.Process
+import androidx.core.app.NotificationManagerCompat
+import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.net.ArtiTorManager
+import com.bitchat.android.net.TorMode
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Coordinates a full application shutdown:
+ * - Stop mesh cleanly
+ * - Stop Tor without changing persistent setting
+ * - Clear in-memory AppState
+ * - Stop foreground service/notification
+ * - Kill the process after completion or after a 5s timeout
+ */
+object AppShutdownCoordinator {
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+
+ fun requestFullShutdownAndKill(
+ app: Application,
+ mesh: BluetoothMeshService?,
+ notificationManager: NotificationManagerCompat,
+ stopForeground: () -> Unit,
+ stopService: () -> Unit
+ ) {
+ scope.launch {
+ // Stop mesh (best-effort)
+ try { mesh?.stopServices() } catch (_: Exception) { }
+
+ // Stop Tor temporarily (do not change user setting)
+ val torProvider = ArtiTorManager.getInstance()
+ val torStop = async {
+ try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
+ }
+
+ // Clear AppState in-memory store
+ try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
+
+ // Stop foreground and clear notification
+ try { stopForeground() } catch (_: Exception) { }
+ try { notificationManager.cancel(10001) } catch (_: Exception) { }
+
+ // Wait up to 5 seconds for shutdown tasks
+ withTimeoutOrNull(5000) {
+ try { torStop.await() } catch (_: Exception) { }
+ delay(100)
+ }
+
+ // Stop the service itself
+ try { stopService() } catch (_: Exception) { }
+
+ // Hard kill the app process
+ try { Process.killProcess(Process.myPid()) } catch (_: Exception) { }
+ try { System.exit(0) } catch (_: Exception) { }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt
new file mode 100644
index 000000000..03a9dcd81
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt
@@ -0,0 +1,16 @@
+package com.bitchat.android.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+class BootCompletedReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ // Ensure preferences are initialized on cold boot before reading values
+ try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { }
+
+ if (MeshServicePreferences.isAutoStartEnabled(true)) {
+ MeshForegroundService.start(context.applicationContext)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
new file mode 100644
index 000000000..7510fa06a
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -0,0 +1,324 @@
+package com.bitchat.android.service
+
+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 androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.bitchat.android.MainActivity
+import com.bitchat.android.R
+import com.bitchat.android.mesh.BluetoothMeshService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+class MeshForegroundService : Service() {
+
+ companion object {
+ private const val CHANNEL_ID = "bitchat_mesh_service"
+ private const val NOTIFICATION_ID = 10001
+
+ const val ACTION_START = "com.bitchat.android.service.START"
+ const val ACTION_STOP = "com.bitchat.android.service.STOP"
+ const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
+ const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
+ const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
+
+ fun start(context: Context) {
+ val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
+
+ // On API >= 26, avoid background-service start restrictions by using startForegroundService
+ // only when we can actually post a notification (Android 13+ requires runtime notif permission)
+ val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true)
+ val hasNotifPerm = hasNotificationPermissionStatic(context)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (bgEnabled && hasNotifPerm) {
+ context.startForegroundService(intent)
+ } else {
+ // Do not attempt to start a background service from headless context without notif permission
+ // or when background is disabled, to avoid BackgroundServiceStartNotAllowedException.
+ android.util.Log.i(
+ "MeshForegroundService",
+ "Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)"
+ )
+ }
+ } else {
+ if (bgEnabled) {
+ context.startService(intent)
+ } else {
+ android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)")
+ }
+ }
+ }
+
+ /**
+ * Helper to be invoked right after POST_NOTIFICATIONS is granted to try
+ * promoting/starting the foreground service immediately without polling.
+ */
+ fun onNotificationPermissionGranted(context: Context) {
+ // If background is enabled and permission now granted, start/promo service
+ val hasNotifPerm = hasNotificationPermissionStatic(context)
+ if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return
+
+ val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // Safe now that we can show a notification
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
+ context.startService(intent)
+ }
+
+ private fun shouldStartAsForeground(context: Context): Boolean {
+ return MeshServicePreferences.isBackgroundEnabled(true) &&
+ hasBluetoothPermissionsStatic(context) &&
+ hasNotificationPermissionStatic(context)
+ }
+
+ private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else {
+ val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ fine || coarse
+ }
+ }
+
+ private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= 33) {
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else true
+ }
+ }
+
+ private lateinit var notificationManager: NotificationManagerCompat
+ private var updateJob: Job? = null
+ private var meshService: BluetoothMeshService? = null
+ private val serviceJob = Job()
+ private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
+ private var isInForeground: Boolean = false
+
+ override fun onCreate() {
+ super.onCreate()
+ notificationManager = NotificationManagerCompat.from(this)
+ createChannel()
+
+ // Adopt or create the mesh service
+ val existing = MeshServiceHolder.meshService
+ meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
+ if (existing != null) {
+ android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
+ } else {
+ android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
+ }
+ MeshServiceHolder.attach(meshService!!)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> {
+ // Stop FGS and mesh cleanly
+ try { meshService?.stopServices() } catch (_: Exception) { }
+ try { MeshServiceHolder.clear() } catch (_: Exception) { }
+ try { stopForeground(true) } catch (_: Exception) { }
+ notificationManager.cancel(NOTIFICATION_ID)
+ isInForeground = false
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ ACTION_QUIT -> {
+ // Fully stop all background activity, stop Tor (without changing setting), then kill the app
+ AppShutdownCoordinator.requestFullShutdownAndKill(
+ app = application,
+ mesh = meshService,
+ notificationManager = notificationManager,
+ stopForeground = {
+ try { stopForeground(true) } catch (_: Exception) { }
+ isInForeground = false
+ },
+ stopService = { stopSelf() }
+ )
+ return START_NOT_STICKY
+ }
+ ACTION_UPDATE_NOTIFICATION -> {
+ // If we became eligible and are not in foreground yet, promote once
+ if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
+ val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
+ startForeground(NOTIFICATION_ID, n)
+ isInForeground = true
+ } else {
+ updateNotification(force = true)
+ }
+ }
+ else -> { /* ACTION_START or null */ }
+ }
+
+ // Ensure mesh is running (only after permissions are granted)
+ ensureMeshStarted()
+
+ // Promote exactly once when eligible, otherwise stay background (or stop)
+ if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
+ val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
+ startForeground(NOTIFICATION_ID, notification)
+ isInForeground = true
+ }
+
+ // Periodically refresh the notification with live network size
+ if (updateJob == null) {
+ updateJob = scope.launch {
+ while (isActive) {
+ // Retry enabling mesh/foreground once permissions become available
+ ensureMeshStarted()
+ val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()
+ if (eligible) {
+ // Only update the notification; do not re-call startForeground()
+ updateNotification(force = false)
+ } else {
+ // If disabled or perms missing, ensure we are not in foreground and clear notif
+ if (isInForeground) {
+ try { stopForeground(false) } catch (_: Exception) { }
+ isInForeground = false
+ }
+ notificationManager.cancel(NOTIFICATION_ID)
+ }
+ delay(5000)
+ }
+ }
+ }
+
+ return START_STICKY
+ }
+
+ private fun ensureMeshStarted() {
+ if (!hasBluetoothPermissions()) return
+ try {
+ android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
+ meshService?.startServices()
+ } catch (e: Exception) {
+ android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
+ }
+ }
+
+ private fun updateNotification(force: Boolean) {
+ val count = meshService?.getActivePeerCount() ?: 0
+ val notification = buildNotification(count)
+ if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ } else if (force) {
+ // If disabled and forced, make sure to remove any prior foreground state
+ try { stopForeground(false) } catch (_: Exception) { }
+ notificationManager.cancel(NOTIFICATION_ID)
+ isInForeground = false
+ }
+ }
+
+ private fun hasAllRequiredPermissions(): Boolean {
+ // For starting FGS with connectedDevice|dataSync, we need:
+ // - Foreground service permissions (declared in manifest)
+ // - One of the device-related permissions (we request BL perms at runtime)
+ // - On Android 13+, POST_NOTIFICATIONS to actually show notification
+ return hasBluetoothPermissions() && hasNotificationPermission()
+ }
+
+ private fun hasBluetoothPermissions(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else {
+ // Prior to S, scanning requires location permissions
+ val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ fine || coarse
+ }
+ }
+
+ private fun hasNotificationPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= 33) {
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else true
+ }
+
+ private fun buildNotification(activeUsers: Int): Notification {
+ val openIntent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, openIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
+ )
+
+ // Action: Quit Bitchat
+ val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT }
+ val quitPendingIntent = PendingIntent.getService(
+ this, 1, quitIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
+ )
+
+ val title = getString(R.string.app_name)
+ val content = getString(R.string.mesh_service_notification_content, activeUsers)
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setContentIntent(pendingIntent)
+ // Add an action button that appears when notification is expanded
+ .addAction(
+ android.R.drawable.ic_menu_close_clear_cancel,
+ getString(R.string.notification_action_quit_bitchat),
+ quitPendingIntent
+ )
+ .build()
+ }
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.mesh_service_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.mesh_service_channel_desc)
+ setShowBadge(false)
+ }
+ (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
+ .createNotificationChannel(channel)
+ }
+ }
+
+ override fun onDestroy() {
+ updateJob?.cancel()
+ updateJob = null
+ // Cancel the service coroutine scope to prevent leaks
+ try { serviceJob.cancel() } catch (_: Exception) { }
+ // Best-effort ensure we are not marked foreground
+ if (isInForeground) {
+ try { stopForeground(true) } catch (_: Exception) { }
+ isInForeground = false
+ }
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
new file mode 100644
index 000000000..d271ab295
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
@@ -0,0 +1,60 @@
+package com.bitchat.android.service
+
+import android.content.Context
+import com.bitchat.android.mesh.BluetoothMeshService
+
+/**
+ * Process-wide holder to share a single BluetoothMeshService instance
+ * between the foreground service and UI (MainActivity/ViewModels).
+ */
+object MeshServiceHolder {
+ private const val TAG = "MeshServiceHolder"
+ @Volatile
+ var meshService: BluetoothMeshService? = null
+ private set
+
+ @Synchronized
+ fun getOrCreate(context: Context): BluetoothMeshService {
+ val existing = meshService
+ if (existing != null) {
+ // If the existing instance is healthy, reuse it; otherwise, replace it.
+ return try {
+ if (existing.isReusable()) {
+ android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance")
+ existing
+ } else {
+ android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance")
+ // Best-effort stop before replacing
+ try { existing.stopServices() } catch (e: Exception) {
+ android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}")
+ }
+ val created = BluetoothMeshService(context.applicationContext)
+ android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
+ meshService = created
+ created
+ }
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
+ val created = BluetoothMeshService(context.applicationContext)
+ meshService = created
+ created
+ }
+ }
+ val created = BluetoothMeshService(context.applicationContext)
+ android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
+ meshService = created
+ return created
+ }
+
+ @Synchronized
+ fun attach(service: BluetoothMeshService) {
+ android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
+ meshService = service
+ }
+
+ @Synchronized
+ fun clear() {
+ android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
+ meshService = null
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt
new file mode 100644
index 000000000..47335ee3d
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt
@@ -0,0 +1,32 @@
+package com.bitchat.android.service
+
+import android.content.Context
+import android.content.SharedPreferences
+
+object MeshServicePreferences {
+ private const val PREFS_NAME = "bitchat_mesh_service_prefs"
+ private const val KEY_AUTO_START = "auto_start_on_boot"
+ private const val KEY_BACKGROUND_ENABLED = "background_enabled"
+
+ private lateinit var prefs: SharedPreferences
+
+ fun init(context: Context) {
+ prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ fun isAutoStartEnabled(default: Boolean = true): Boolean {
+ return prefs.getBoolean(KEY_AUTO_START, default)
+ }
+
+ fun setAutoStartEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply()
+ }
+
+ fun isBackgroundEnabled(default: Boolean = true): Boolean {
+ return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default)
+ }
+
+ fun setBackgroundEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
new file mode 100644
index 000000000..07f146bd2
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -0,0 +1,109 @@
+package com.bitchat.android.services
+
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.DeliveryStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Process-wide in-memory state store that survives Activity recreation.
+ * The foreground Mesh service updates this store; UI subscribes/hydrates from it.
+ */
+object AppStateStore {
+ // Global de-dup set by message id to avoid duplicate keys in Compose lists
+ private val seenMessageIds = mutableSetOf()
+ // Connected peer IDs (mesh ephemeral IDs)
+ private val _peers = MutableStateFlow>(emptyList())
+ val peers: StateFlow> = _peers.asStateFlow()
+
+ // Public mesh timeline messages (non-channel)
+ private val _publicMessages = MutableStateFlow>(emptyList())
+ val publicMessages: StateFlow> = _publicMessages.asStateFlow()
+
+ // Private messages by peerID
+ private val _privateMessages = MutableStateFlow