Fix/foreground permissions scanning (#556)

* try catch the foreground location service if not available

* ask for background permissions

* background permissions in onboarding

* small improvements
This commit is contained in:
callebtc
2026-01-05 15:09:04 +07:00
committed by GitHub
parent 9733806610
commit 423feb8b77
11 changed files with 481 additions and 53 deletions
+4 -1
View File
@@ -16,6 +16,7 @@
<!-- Location permission required for BLE scanning --> <!-- Location permission required for BLE scanning -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Notification permissions --> <!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -27,6 +28,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Data sync foreground service type (required when declaring dataSync) --> <!-- Data sync foreground service type (required when declaring dataSync) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Location foreground service type required for BLE scanning in background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Microphone for voice notes --> <!-- Microphone for voice notes -->
@@ -104,7 +107,7 @@
<service <service
android:name=".service.MeshForegroundService" android:name=".service.MeshForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" android:foregroundServiceType="connectedDevice|dataSync|location"
tools:ignore="DataExtractionRules"> tools:ignore="DataExtractionRules">
</service> </service>
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen import com.bitchat.android.onboarding.LocationCheckScreen
@@ -135,6 +136,9 @@ class MainActivity : OrientationAwareActivity() {
activity = this, activity = this,
permissionManager = permissionManager, permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete, onOnboardingComplete = ::handleOnboardingComplete,
onBackgroundLocationRequired = {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
},
onOnboardingFailed = ::handleOnboardingFailed 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 -> { OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen // Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) { val backCallback = object : OnBackPressedCallback(true) {
@@ -380,10 +399,17 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) { if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation") Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areAllPermissionsGranted()) { } else if (permissionManager.areRequiredPermissionsGranted()) {
Log.d("MainActivity", "Existing user with permissions, initializing app") Log.d("MainActivity", "Existing user with required permissions")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) if (permissionManager.needsBackgroundLocationPermission() &&
initializeApp() !permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
) {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
} else {
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
} else { } else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation") Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
@@ -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)
)
}
}
@@ -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)
}
}
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
private val activity: ComponentActivity, private val activity: ComponentActivity,
private val permissionManager: PermissionManager, private val permissionManager: PermissionManager,
private val onOnboardingComplete: () -> Unit, private val onOnboardingComplete: () -> Unit,
private val onBackgroundLocationRequired: () -> Unit,
private val onOnboardingFailed: (String) -> Unit private val onOnboardingFailed: (String) -> Unit
) { ) {
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
} }
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
init { init {
setupPermissionLauncher() setupPermissionLauncher()
setupBackgroundLocationLauncher()
} }
/** /**
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
} }
} }
private fun setupBackgroundLocationLauncher() {
backgroundLocationLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
handleBackgroundLocationResult(granted)
}
}
/** /**
* Start the onboarding process * Start the onboarding process
*/ */
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
Log.d(TAG, "Starting onboarding process") Log.d(TAG, "Starting onboarding process")
permissionManager.logPermissionStatus() permissionManager.logPermissionStatus()
if (permissionManager.areAllPermissionsGranted()) { if (permissionManager.areRequiredPermissionsGranted()) {
Log.d(TAG, "All permissions already granted, completing onboarding") if (shouldRequestBackgroundLocation()) {
completeOnboarding() Log.d(TAG, "Foreground permissions granted; background location recommended")
onBackgroundLocationRequired()
} else {
Log.d(TAG, "Required permissions already granted, completing onboarding")
completeOnboarding()
}
} else { } else {
Log.d(TAG, "Missing permissions, need to start explanation flow") Log.d(TAG, "Missing permissions, need to start explanation flow")
// The explanation screen will be shown by the calling activity // The explanation screen will be shown by the calling activity
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
val missingPermissions = (missingRequired + optionalToRequest).distinct() val missingPermissions = (missingRequired + optionalToRequest).distinct()
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
completeOnboarding() if (shouldRequestBackgroundLocation()) {
onBackgroundLocationRequired()
} else {
completeOnboarding()
}
return return
} }
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
val criticalGranted = criticalPermissions.all { permissions[it] == true } val criticalGranted = criticalPermissions.all { permissions[it] == true }
when { when {
allGranted -> {
Log.d(TAG, "All permissions granted successfully")
completeOnboarding()
}
criticalGranted -> { criticalGranted -> {
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") if (shouldRequestBackgroundLocation()) {
showPartialPermissionWarning(permissions) 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 -> { else -> {
Log.d(TAG, "Critical permissions denied") 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 * Get the list of critical permissions that are absolutely required
*/ */
private fun getCriticalPermissions(): List<String> { private fun getCriticalPermissions(): List<String> {
// For bitchat, Bluetooth and location permissions are critical // For bitchat, Bluetooth and location permissions are critical
// Notifications are nice-to-have but not critical // Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
return permissionManager.getRequiredPermissions().filter { permission -> return permissionManager.getRequiredPermissions()
!permission.contains("POST_NOTIFICATIONS")
}
} }
/** /**
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
private fun getPermissionDisplayName(permission: String): String { private fun getPermissionDisplayName(permission: String): String {
return when { return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NOTIFICATION") -> "Notifications" permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".") else -> permission.substringAfterLast(".")
@@ -6,6 +6,7 @@ enum class OnboardingState {
LOCATION_CHECK, LOCATION_CHECK,
BATTERY_OPTIMIZATION_CHECK, BATTERY_OPTIMIZATION_CHECK,
PERMISSION_EXPLANATION, PERMISSION_EXPLANATION,
BACKGROUND_LOCATION_EXPLANATION,
PERMISSION_REQUESTING, PERMISSION_REQUESTING,
INITIALIZING, INITIALIZING,
COMPLETE, COMPLETE,
@@ -12,12 +12,10 @@ import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -209,29 +207,6 @@ private fun PermissionCategoryCard(
color = colorScheme.onBackground.copy(alpha = 0.8f) 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) { return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.bitchat.android.R
/** /**
* Centralized permission management for bitchat app * 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, * Note: Notification permission is optional and not included here,
* so the app works without notification access. * so the app works without notification access.
*/ */
@@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) {
return permissions 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. * Get optional permissions that improve the experience but aren't required.
* Currently includes POST_NOTIFICATIONS on Android 13+. * 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 { fun areAllPermissionsGranted(): Boolean {
return areRequiredPermissionsGranted()
}
fun areRequiredPermissionsGranted(): Boolean {
return getRequiredPermissions().all { isPermissionGranted(it) } return getRequiredPermissions().all { isPermissionGranted(it) }
} }
@@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) {
return getRequiredPermissions().filter { !isPermissionGranted(it) } return getRequiredPermissions().filter { !isPermissionGranted(it) }
} }
fun getMissingBackgroundLocationPermission(): List<String> {
val permission = getBackgroundLocationPermission() ?: return emptyList()
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
}
/** /**
* Get categorized permission information for display * 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) // Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add( categories.add(
@@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) {
appendLine("Permission Diagnostics:") appendLine("Permission Diagnostics:")
appendLine("Android SDK: ${Build.VERSION.SDK_INT}") appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
appendLine("First time launch: ${isFirstTimeLaunch()}") appendLine("First time launch: ${isFirstTimeLaunch()}")
appendLine("All permissions granted: ${areAllPermissionsGranted()}") appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
appendLine() appendLine()
getCategorizedPermissions().forEach { category -> getCategorizedPermissions().forEach { category ->
@@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) {
appendLine() appendLine()
} }
val missing = getMissingPermissions() val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
if (missing.isNotEmpty()) { if (missing.isNotEmpty()) {
appendLine("Missing permissions:") appendLine("Missing permissions:")
missing.forEach { permission -> missing.forEach { permission ->
@@ -260,6 +305,7 @@ data class PermissionCategory(
enum class PermissionType(val nameValue: String) { enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"), NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"), PRECISE_LOCATION("Precise Location"),
BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"), MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"), NOTIFICATIONS("Notifications"),
BATTERY_OPTIMIZATION("Battery Optimization"), BATTERY_OPTIMIZATION("Battery Optimization"),
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat 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 we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0) val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForeground(NOTIFICATION_ID, n) startForegroundCompat(n)
isInForeground = true isInForeground = true
} else { } else {
updateNotification(force = true) updateNotification(force = true)
@@ -191,7 +192,7 @@ class MeshForegroundService : Service() {
// Promote exactly once when eligible, otherwise stay background (or stop) // Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForeground(NOTIFICATION_ID, notification) startForegroundCompat(notification)
isInForeground = true 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() { override fun onDestroy() {
updateJob?.cancel() updateJob?.cancel()
updateJob = null updateJob = null
+12
View File
@@ -320,6 +320,15 @@
<string name="location_explanation">bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature.</string> <string name="location_explanation">bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature.</string>
<string name="location_needs_for">bitchat needs location services for:</string> <string name="location_needs_for">bitchat needs location services for:</string>
<string name="location_needs_bullets">• Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection</string> <string name="location_needs_bullets">• Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection</string>
<string name="background_location_required_title">Background Location Recommended</string>
<string name="background_location_required_subtitle">optional, improves mesh reliability</string>
<string name="background_location_explanation">Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot.</string>
<string name="background_location_settings_tip">When settings opens, choose "Allow all the time".</string>
<string name="background_location_needs_for">bitchat uses background location for:</string>
<string name="background_location_needs_bullets">- scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background</string>
<string name="background_location_privacy_note">We NEVER collect or store your location. Your privacy is safe.</string>
<string name="grant_background_location">Allow Background Location</string>
<string name="skip_background_location">Continue without background location</string>
<string name="open_location_settings">Open Location Settings</string> <string name="open_location_settings">Open Location Settings</string>
<string name="check_again">Check Again</string> <string name="check_again">Check Again</string>
<string name="location_services_unavailable">Location Services Unavailable</string> <string name="location_services_unavailable">Location Services Unavailable</string>
@@ -405,6 +414,8 @@
<string name="perm_nearby_devices_system">Allow bitchat to connect to nearby devices</string> <string name="perm_nearby_devices_system">Allow bitchat to connect to nearby devices</string>
<string name="perm_location_desc">Required by Android to discover nearby bitchat users via Bluetooth</string> <string name="perm_location_desc">Required by Android to discover nearby bitchat users via Bluetooth</string>
<string name="perm_location_system">bitchat needs this to scan for nearby devices</string> <string name="perm_location_system">bitchat needs this to scan for nearby devices</string>
<string name="perm_background_location_desc">Recommended to scan for nearby devices while the app is in the background</string>
<string name="perm_background_location_system">Allow background location access</string>
<string name="perm_notifications_desc">Receive notifications when you receive private messages</string> <string name="perm_notifications_desc">Receive notifications when you receive private messages</string>
<string name="perm_notifications_system">Allow bitchat to send you notifications</string> <string name="perm_notifications_system">Allow bitchat to send you notifications</string>
<string name="perm_battery_desc">Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections</string> <string name="perm_battery_desc">Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections</string>
@@ -413,6 +424,7 @@
<!-- Permission types --> <!-- Permission types -->
<string name="perm_type_nearby_devices">Nearby Devices</string> <string name="perm_type_nearby_devices">Nearby Devices</string>
<string name="perm_type_precise_location">Precise Location</string> <string name="perm_type_precise_location">Precise Location</string>
<string name="perm_type_background_location">Background Location</string>
<string name="perm_type_microphone">Microphone</string> <string name="perm_type_microphone">Microphone</string>
<string name="perm_type_notifications">Notifications</string> <string name="perm_type_notifications">Notifications</string>
<string name="perm_type_battery_optimization">Battery Optimization</string> <string name="perm_type_battery_optimization">Battery Optimization</string>