Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Foreground + boot -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<!-- Haptic feedback permission -->
<uses-permission android:name="android.permission.VIBRATE" />
Expand Down Expand Up @@ -50,5 +55,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".services.PersistentMeshService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<receiver
android:name=".services.BootCompletedReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
89 changes: 70 additions & 19 deletions app/src/main/java/com/bitchat/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}")
Expand All @@ -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) {}
}
}

Expand Down Expand Up @@ -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}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,11 @@ class BluetoothMeshService(private val context: Context) {
* Get peer RSSI values
*/
fun getPeerRSSI(): Map<String, Int> = peerManager.getAllPeerRSSI()

/**
* Get current active peer IDs for immediate UI sync on attach
*/
fun getActivePeers(): List<String> = peerManager.getActivePeerIDs()

/**
* Check if we have an established Noise session with a peer
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/bitchat/android/mesh/MeshServiceHolder.kt
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}

Original file line number Diff line number Diff line change
@@ -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
}

Original file line number Diff line number Diff line change
@@ -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
}
}
}

Original file line number Diff line number Diff line change
@@ -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<BitchatMessage> = 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<BitchatMessage> {
synchronized(lock) {
if (queue.isEmpty()) return emptyList()
val copy = ArrayList<BitchatMessage>(queue)
queue.clear()
return copy
}
}

fun clear() {
synchronized(lock) { queue.clear() }
}
}

Loading