diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 14a01098..8c957240 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,6 +16,7 @@
+
@@ -27,6 +28,8 @@
+
+
@@ -104,7 +107,7 @@
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index a87c4a25..3b2d1857 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus
+import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen
@@ -135,6 +136,9 @@ class MainActivity : OrientationAwareActivity() {
activity = this,
permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete,
+ onBackgroundLocationRequired = {
+ mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
+ },
onOnboardingFailed = ::handleOnboardingFailed
)
@@ -267,6 +271,21 @@ class MainActivity : OrientationAwareActivity() {
)
}
+ OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
+ BackgroundLocationPermissionScreen(
+ modifier = modifier,
+ onContinue = {
+ onboardingCoordinator.requestBackgroundLocation()
+ },
+ onRetry = {
+ onboardingCoordinator.checkBackgroundLocationAndProceed()
+ },
+ onSkip = {
+ onboardingCoordinator.skipBackgroundLocation()
+ }
+ )
+ }
+
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
@@ -380,10 +399,17 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
- } else if (permissionManager.areAllPermissionsGranted()) {
- Log.d("MainActivity", "Existing user with permissions, initializing app")
- mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
- initializeApp()
+ } else if (permissionManager.areRequiredPermissionsGranted()) {
+ Log.d("MainActivity", "Existing user with required permissions")
+ if (permissionManager.needsBackgroundLocationPermission() &&
+ !permissionManager.isBackgroundLocationGranted() &&
+ !com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
+ ) {
+ mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
+ } else {
+ mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
+ initializeApp()
+ }
} else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt
index 917de66e..45d48ac6 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
-
+
return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
new file mode 100644
index 00000000..1346cb23
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
@@ -0,0 +1,251 @@
+package com.bitchat.android.onboarding
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.res.stringResource
+import com.bitchat.android.R
+
+/**
+ * Explanation screen shown before requesting background location permission.
+ */
+@Composable
+fun BackgroundLocationPermissionScreen(
+ modifier: Modifier,
+ onContinue: () -> Unit,
+ onRetry: () -> Unit,
+ onSkip: () -> Unit
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val scrollState = rememberScrollState()
+
+ Box(modifier = modifier) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 88.dp)
+ .verticalScroll(scrollState),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HeaderSection(colorScheme)
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Filled.LocationOn,
+ contentDescription = stringResource(R.string.cd_location_services),
+ tint = colorScheme.primary,
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(20.dp)
+ )
+ Column {
+ Text(
+ text = stringResource(R.string.background_location_required_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.onBackground
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.background_location_explanation),
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onBackground.copy(alpha = 0.8f)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.background_location_settings_tip),
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontFamily = FontFamily.Monospace
+ ),
+ color = colorScheme.onBackground.copy(alpha = 0.8f)
+ )
+ }
+ }
+ }
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Security,
+ contentDescription = stringResource(R.string.cd_privacy_protected),
+ tint = colorScheme.primary,
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(20.dp)
+ )
+ Column {
+ Text(
+ text = stringResource(R.string.background_location_needs_for),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.onBackground
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.background_location_needs_bullets),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = colorScheme.onBackground.copy(alpha = 0.8f)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.background_location_privacy_note),
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Medium
+ ),
+ color = colorScheme.onBackground
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(),
+ color = colorScheme.surface,
+ shadowElevation = 8.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = onContinue,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.grant_background_location),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold
+ ),
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onRetry,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = stringResource(R.string.check_again),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.Monospace
+ )
+ )
+ }
+
+ TextButton(
+ onClick = onSkip,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = stringResource(R.string.battery_optimization_skip),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.Monospace
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HeaderSection(colorScheme: ColorScheme) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold,
+ fontSize = 32.sp
+ ),
+ color = colorScheme.onBackground
+ )
+
+ Text(
+ text = stringResource(R.string.background_location_required_subtitle),
+ fontSize = 12.sp,
+ fontFamily = FontFamily.Monospace,
+ color = colorScheme.onBackground.copy(alpha = 0.7f)
+ )
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt
new file mode 100644
index 00000000..0a73e346
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt
@@ -0,0 +1,21 @@
+package com.bitchat.android.onboarding
+
+import android.content.Context
+
+/**
+ * Preference manager for background location skip choice.
+ */
+object BackgroundLocationPreferenceManager {
+ private const val PREFS_NAME = "bitchat_settings"
+ private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped"
+
+ fun setSkipped(context: Context, skipped: Boolean) {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply()
+ }
+
+ fun isSkipped(context: Context): Boolean {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
index 871cc892..701e9d19 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
private val activity: ComponentActivity,
private val permissionManager: PermissionManager,
private val onOnboardingComplete: () -> Unit,
+ private val onBackgroundLocationRequired: () -> Unit,
private val onOnboardingFailed: (String) -> Unit
) {
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
}
private var permissionLauncher: ActivityResultLauncher>? = null
+ private var backgroundLocationLauncher: ActivityResultLauncher? = null
init {
setupPermissionLauncher()
+ setupBackgroundLocationLauncher()
}
/**
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
}
}
+ private fun setupBackgroundLocationLauncher() {
+ backgroundLocationLauncher = activity.registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ handleBackgroundLocationResult(granted)
+ }
+ }
+
/**
* Start the onboarding process
*/
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
Log.d(TAG, "Starting onboarding process")
permissionManager.logPermissionStatus()
- if (permissionManager.areAllPermissionsGranted()) {
- Log.d(TAG, "All permissions already granted, completing onboarding")
- completeOnboarding()
+ if (permissionManager.areRequiredPermissionsGranted()) {
+ if (shouldRequestBackgroundLocation()) {
+ Log.d(TAG, "Foreground permissions granted; background location recommended")
+ onBackgroundLocationRequired()
+ } else {
+ Log.d(TAG, "Required permissions already granted, completing onboarding")
+ completeOnboarding()
+ }
} else {
Log.d(TAG, "Missing permissions, need to start explanation flow")
// The explanation screen will be shown by the calling activity
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
val missingPermissions = (missingRequired + optionalToRequest).distinct()
if (missingPermissions.isEmpty()) {
- completeOnboarding()
+ if (shouldRequestBackgroundLocation()) {
+ onBackgroundLocationRequired()
+ } else {
+ completeOnboarding()
+ }
return
}
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
val criticalGranted = criticalPermissions.all { permissions[it] == true }
when {
- allGranted -> {
- Log.d(TAG, "All permissions granted successfully")
- completeOnboarding()
- }
criticalGranted -> {
- Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
- showPartialPermissionWarning(permissions)
+ if (shouldRequestBackgroundLocation()) {
+ Log.d(TAG, "Foreground permissions granted; requesting background location next")
+ onBackgroundLocationRequired()
+ return
+ }
+ if (allGranted) {
+ Log.d(TAG, "All permissions granted successfully")
+ completeOnboarding()
+ } else {
+ Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
+ showPartialPermissionWarning(permissions)
+ }
}
else -> {
Log.d(TAG, "Critical permissions denied")
@@ -113,15 +139,50 @@ class OnboardingCoordinator(
}
}
+ fun requestBackgroundLocation() {
+ val permission = permissionManager.getBackgroundLocationPermission()
+ if (permission == null) {
+ completeOnboarding()
+ return
+ }
+ Log.d(TAG, "Requesting background location permission")
+ backgroundLocationLauncher?.launch(permission)
+ }
+
+ private fun handleBackgroundLocationResult(granted: Boolean) {
+ if (granted) {
+ Log.d(TAG, "Background location permission granted")
+ } else {
+ Log.w(TAG, "Background location permission denied; continuing without it")
+ }
+ completeOnboarding()
+ }
+
+ fun skipBackgroundLocation() {
+ Log.d(TAG, "User skipped background location permission")
+ BackgroundLocationPreferenceManager.setSkipped(activity, true)
+ completeOnboarding()
+ }
+
+ fun checkBackgroundLocationAndProceed() {
+ if (!shouldRequestBackgroundLocation()) {
+ completeOnboarding()
+ }
+ }
+
+ private fun shouldRequestBackgroundLocation(): Boolean {
+ return permissionManager.needsBackgroundLocationPermission() &&
+ !permissionManager.isBackgroundLocationGranted() &&
+ !BackgroundLocationPreferenceManager.isSkipped(activity)
+ }
+
/**
* Get the list of critical permissions that are absolutely required
*/
private fun getCriticalPermissions(): List {
// For bitchat, Bluetooth and location permissions are critical
- // Notifications are nice-to-have but not critical
- return permissionManager.getRequiredPermissions().filter { permission ->
- !permission.contains("POST_NOTIFICATIONS")
- }
+ // Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
+ return permissionManager.getRequiredPermissions()
}
/**
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
private fun getPermissionDisplayName(permission: String): String {
return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
+ permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".")
diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt
index f06ddd08..7fa07817 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt
@@ -6,8 +6,9 @@ enum class OnboardingState {
LOCATION_CHECK,
BATTERY_OPTIMIZATION_CHECK,
PERMISSION_EXPLANATION,
+ BACKGROUND_LOCATION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
COMPLETE,
ERROR
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
index 2b84aefa..e982d2e1 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
@@ -12,12 +12,10 @@ import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -209,29 +207,6 @@ private fun PermissionCategoryCard(
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
- if (category.type == PermissionType.PRECISE_LOCATION) {
- // Extra emphasis for location permission
- Spacer(modifier = Modifier.height(4.dp))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- Icon(
- imageVector = Icons.Filled.Warning,
- contentDescription = stringResource(R.string.cd_warning),
- tint = Color(0xFFFF9800),
- modifier = Modifier.size(16.dp)
- )
- Text(
- text = stringResource(R.string.location_tracking_warning),
- style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.Medium,
- color = Color(0xFFFF9800)
- )
- )
- }
- }
}
}
}
@@ -240,6 +215,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
+ PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
index ff0a160f..c32f855b 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
+import com.bitchat.android.R
/**
* Centralized permission management for bitchat app
@@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) {
}
/**
- * Get all permissions required by the app
+ * Get required permissions that can be requested together.
+ * Background location is handled separately to ensure correct request order.
* Note: Notification permission is optional and not included here,
* so the app works without notification access.
*/
@@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) {
return permissions
}
+ /**
+ * Background location permission is required on Android 10+ for background BLE scanning.
+ * Must be requested after foreground location permissions are granted.
+ */
+ fun needsBackgroundLocationPermission(): Boolean {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ }
+
+ fun getBackgroundLocationPermission(): String? {
+ return if (needsBackgroundLocationPermission()) {
+ Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ } else {
+ null
+ }
+ }
+
+ fun isBackgroundLocationGranted(): Boolean {
+ val permission = getBackgroundLocationPermission() ?: return true
+ return isPermissionGranted(permission)
+ }
+
/**
* Get optional permissions that improve the experience but aren't required.
* Currently includes POST_NOTIFICATIONS on Android 13+.
@@ -93,9 +116,13 @@ class PermissionManager(private val context: Context) {
}
/**
- * Check if all required permissions are granted
+ * Check if all required permissions are granted (background location is optional).
*/
fun areAllPermissionsGranted(): Boolean {
+ return areRequiredPermissionsGranted()
+ }
+
+ fun areRequiredPermissionsGranted(): Boolean {
return getRequiredPermissions().all { isPermissionGranted(it) }
}
@@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) {
return getRequiredPermissions().filter { !isPermissionGranted(it) }
}
+ fun getMissingBackgroundLocationPermission(): List {
+ val permission = getBackgroundLocationPermission() ?: return emptyList()
+ return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
+ }
+
/**
* Get categorized permission information for display
*/
@@ -177,6 +209,19 @@ class PermissionManager(private val context: Context) {
)
)
+ if (needsBackgroundLocationPermission()) {
+ val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+ categories.add(
+ PermissionCategory(
+ type = PermissionType.BACKGROUND_LOCATION,
+ description = context.getString(R.string.perm_background_location_desc),
+ permissions = backgroundPermission,
+ isGranted = backgroundPermission.all { isPermissionGranted(it) },
+ systemDescription = context.getString(R.string.perm_background_location_system)
+ )
+ )
+ }
+
// Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add(
@@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) {
appendLine("Permission Diagnostics:")
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
appendLine("First time launch: ${isFirstTimeLaunch()}")
- appendLine("All permissions granted: ${areAllPermissionsGranted()}")
+ appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
appendLine()
getCategorizedPermissions().forEach { category ->
@@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) {
appendLine()
}
- val missing = getMissingPermissions()
+ val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
if (missing.isNotEmpty()) {
appendLine("Missing permissions:")
missing.forEach { permission ->
@@ -260,6 +305,7 @@ data class PermissionCategory(
enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"),
+ BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
BATTERY_OPTIMIZATION("Battery Optimization"),
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
index 998c03b2..bf1f236f 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
+import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
@@ -176,7 +177,7 @@ class MeshForegroundService : Service() {
// 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)
+ startForegroundCompat(n)
isInForeground = true
} else {
updateNotification(force = true)
@@ -191,7 +192,7 @@ class MeshForegroundService : Service() {
// 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)
+ startForegroundCompat(notification)
isInForeground = true
}
@@ -326,6 +327,35 @@ class MeshForegroundService : Service() {
}
}
+ private fun hasLocationPermission(): Boolean {
+ 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
+ return fine || coarse
+ }
+
+ private fun startForegroundCompat(notification: Notification) {
+ if (Build.VERSION.SDK_INT >= 34) {
+ val type = if (hasLocationPermission()) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+ } else {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
+ }
+ try {
+ startForeground(NOTIFICATION_ID, notification, type)
+ } catch (e: SecurityException) {
+ // Fallback for cases where "While In Use" permission exists but background start is restricted
+ if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) {
+ android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}")
+ startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
+ } else {
+ throw e
+ }
+ }
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
override fun onDestroy() {
updateJob?.cancel()
updateJob = null
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6fe23b49..9845fd85 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -320,6 +320,15 @@
bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature.
bitchat needs location services for:
• Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection
+ Background Location Recommended
+ optional, improves mesh reliability
+ Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot.
+ When settings opens, choose "Allow all the time".
+ bitchat uses background location for:
+ - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background
+ We NEVER collect or store your location. Your privacy is safe.
+ Allow Background Location
+ Continue without background location
Open Location Settings
Check Again
Location Services Unavailable
@@ -405,6 +414,8 @@
Allow bitchat to connect to nearby devices
Required by Android to discover nearby bitchat users via Bluetooth
bitchat needs this to scan for nearby devices
+ Recommended to scan for nearby devices while the app is in the background
+ Allow background location access
Receive notifications when you receive private messages
Allow bitchat to send you notifications
Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections
@@ -413,6 +424,7 @@
Nearby Devices
Precise Location
+ Background Location
Microphone
Notifications
Battery Optimization