Merge pull request #28 from permissionlesstech/onboarding

onboarding
This commit is contained in:
callebtc
2025-07-10 13:16:54 +02:00
committed by GitHub
5 changed files with 1089 additions and 58 deletions
@@ -17,6 +17,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import com.bitchat.android.onboarding.*
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.theme.BitchatTheme
@@ -25,28 +26,34 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator
private val chatViewModel: ChatViewModel by viewModels()
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allPermissionsGranted = permissions.values.all { it }
if (allPermissionsGranted) {
// Permissions granted, the mesh service should start automatically
} else {
// Handle permission denial
finish()
}
// UI state for onboarding flow
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
private var errorMessage by mutableStateOf("")
enum class OnboardingState {
CHECKING,
PERMISSION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
COMPLETE,
ERROR
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Request necessary permissions
requestPermissions()
// Handle notification intent if app was opened from a notification
handleNotificationIntent(intent)
// Initialize permission management
permissionManager = PermissionManager(this)
onboardingCoordinator = OnboardingCoordinator(
activity = this,
permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete,
onOnboardingFailed = ::handleOnboardingFailed
)
setContent {
BitchatTheme {
@@ -54,28 +61,155 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ChatScreen(viewModel = chatViewModel)
OnboardingFlowScreen()
}
}
}
// Start the onboarding process
checkOnboardingStatus()
}
@Composable
private fun OnboardingFlowScreen() {
when (onboardingState) {
OnboardingState.CHECKING -> {
InitializingScreen()
}
OnboardingState.PERMISSION_EXPLANATION -> {
PermissionExplanationScreen(
permissionCategories = permissionManager.getCategorizedPermissions(),
onContinue = {
onboardingState = OnboardingState.PERMISSION_REQUESTING
onboardingCoordinator.requestPermissions()
},
onCancel = {
finish()
}
)
}
OnboardingState.PERMISSION_REQUESTING -> {
InitializingScreen()
}
OnboardingState.INITIALIZING -> {
InitializingScreen()
}
OnboardingState.COMPLETE -> {
ChatScreen(viewModel = chatViewModel)
}
OnboardingState.ERROR -> {
InitializationErrorScreen(
errorMessage = errorMessage,
onRetry = {
onboardingState = OnboardingState.CHECKING
checkOnboardingStatus()
},
onOpenSettings = {
onboardingCoordinator.openAppSettings()
}
)
}
}
}
private fun checkOnboardingStatus() {
android.util.Log.d("MainActivity", "Checking onboarding status")
lifecycleScope.launch {
// Small delay to show the checking state
delay(500)
if (permissionManager.isFirstTimeLaunch()) {
android.util.Log.d("MainActivity", "First time launch, showing permission explanation")
onboardingState = OnboardingState.PERMISSION_EXPLANATION
} else if (permissionManager.areAllPermissionsGranted()) {
android.util.Log.d("MainActivity", "Existing user with permissions, initializing app")
onboardingState = OnboardingState.INITIALIZING
initializeApp()
} else {
android.util.Log.d("MainActivity", "Existing user missing permissions, showing explanation")
onboardingState = OnboardingState.PERMISSION_EXPLANATION
}
}
}
private fun handleOnboardingComplete() {
android.util.Log.d("MainActivity", "Onboarding completed, initializing app")
onboardingState = OnboardingState.INITIALIZING
initializeApp()
}
private fun handleOnboardingFailed(message: String) {
android.util.Log.e("MainActivity", "Onboarding failed: $message")
errorMessage = message
onboardingState = OnboardingState.ERROR
}
private fun initializeApp() {
android.util.Log.d("MainActivity", "Starting app initialization")
lifecycleScope.launch {
try {
// Initialize the app with a proper delay to ensure Bluetooth stack is ready
// This solves the issue where app needs restart to work on first install
delay(1000) // Give the system time to process permission grants
android.util.Log.d("MainActivity", "Permissions verified, starting mesh service")
// Ensure all permissions are still granted (user might have revoked in settings)
if (!permissionManager.areAllPermissionsGranted()) {
val missing = permissionManager.getMissingPermissions()
android.util.Log.w("MainActivity", "Permissions revoked during initialization: $missing")
handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.")
return@launch
}
// Initialize chat view model - this will start the mesh service
chatViewModel.meshService.startServices()
// Handle any notification intent
handleNotificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
android.util.Log.d("MainActivity", "App initialization complete")
onboardingState = OnboardingState.COMPLETE
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to initialize app", e)
handleOnboardingFailed("Failed to initialize the app: ${e.message}")
}
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// Handle notification intents when app is already running
intent?.let { handleNotificationIntent(it) }
if (onboardingState == OnboardingState.COMPLETE) {
intent?.let { handleNotificationIntent(it) }
}
}
override fun onResume() {
super.onResume()
// Notify that app is in foreground for power optimization and notifications
chatViewModel.setAppBackgroundState(false)
// Only set background state if app is fully initialized
if (onboardingState == OnboardingState.COMPLETE) {
chatViewModel.setAppBackgroundState(false)
}
}
override fun onPause() {
super.onPause()
// Notify that app is in background for power optimization and notifications
chatViewModel.setAppBackgroundState(true)
// Only set background state if app is fully initialized
if (onboardingState == OnboardingState.COMPLETE) {
chatViewModel.setAppBackgroundState(true)
}
}
/**
@@ -103,45 +237,15 @@ class MainActivity : ComponentActivity() {
}
}
private fun requestPermissions() {
val permissions = mutableListOf<String>()
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
permissions.addAll(listOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
))
} else {
permissions.addAll(listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
))
}
permissions.addAll(listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
permissionLauncher.launch(permissions.toTypedArray())
}
override fun onDestroy() {
super.onDestroy()
// FIXED: Ensure Bluetooth resources are cleaned up when the activity is destroyed
// This addresses the issue where stale Bluetooth advertisements interfere with
// restart discovery times. This is more reliable than relying solely on
// ChatViewModel.onCleared() which may not be called when the app is swiped away.
try {
chatViewModel.meshService.stopServices()
} catch (e: Exception) {
// Log error, but don't crash the app on exit
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
// Only stop mesh services if they were started
if (onboardingState == OnboardingState.COMPLETE) {
try {
chatViewModel.meshService.stopServices()
} catch (e: Exception) {
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
}
}
}
}
@@ -0,0 +1,246 @@
package com.bitchat.android.onboarding
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Loading screen shown during app initialization after permissions are granted
*/
@Composable
fun InitializingScreen() {
val colorScheme = MaterialTheme.colorScheme
// Animated rotation for the loading indicator
val infiniteTransition = rememberInfiniteTransition(label = "loading")
val rotationAngle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
// Animated dots for loading text
val dotCount = 3
val animationDelay = 300
val dots = (0 until dotCount).map { index ->
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = animationDelay * dotCount),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(animationDelay * index)
),
label = "dot_$index"
)
alpha
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// App title
Text(
text = "bitchat*",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
// Loading indicator
Box(
modifier = Modifier.size(60.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.rotate(rotationAngle),
color = colorScheme.primary,
strokeWidth = 3.dp
)
}
// Loading text with animated dots
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Initializing mesh network",
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
)
// Animated dots
dots.forEach { alpha ->
Text(
text = ".",
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = alpha)
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Status message
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Setting up Bluetooth mesh networking...",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
Text(
text = "This should only take a few seconds",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
),
textAlign = TextAlign.Center
)
}
}
}
}
}
/**
* Error screen shown if initialization fails
*/
@Composable
fun InitializationErrorScreen(
errorMessage: String,
onRetry: () -> Unit,
onOpenSettings: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Error indicator
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFEBEE)
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(16.dp)
)
}
Text(
text = "Setup Not Complete",
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.error
),
textAlign = TextAlign.Center
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.errorContainer.copy(alpha = 0.1f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface
),
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center
)
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Try Again",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
OutlinedButton(
onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Open Settings",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
}
@@ -0,0 +1,220 @@
package com.bitchat.android.onboarding
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
/**
* Coordinates the complete onboarding flow including permission explanation,
* permission requests, and initialization of the mesh service
*/
class OnboardingCoordinator(
private val activity: ComponentActivity,
private val permissionManager: PermissionManager,
private val onOnboardingComplete: () -> Unit,
private val onOnboardingFailed: (String) -> Unit
) {
companion object {
private const val TAG = "OnboardingCoordinator"
}
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
init {
setupPermissionLauncher()
}
/**
* Setup the permission request launcher
*/
private fun setupPermissionLauncher() {
permissionLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
handlePermissionResults(permissions)
}
}
/**
* Start the onboarding process
*/
fun startOnboarding() {
Log.d(TAG, "Starting onboarding process")
permissionManager.logPermissionStatus()
if (permissionManager.areAllPermissionsGranted()) {
Log.d(TAG, "All 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
}
}
/**
* Called when user accepts the permission explanation
*/
fun requestPermissions() {
Log.d(TAG, "User accepted permission explanation, requesting permissions")
val missingPermissions = permissionManager.getMissingPermissions()
if (missingPermissions.isEmpty()) {
completeOnboarding()
return
}
Log.d(TAG, "Requesting ${missingPermissions.size} permissions")
permissionLauncher?.launch(missingPermissions.toTypedArray())
}
/**
* Handle permission request results
*/
private fun handlePermissionResults(permissions: Map<String, Boolean>) {
Log.d(TAG, "Received permission results:")
permissions.forEach { (permission, granted) ->
Log.d(TAG, " $permission: ${if (granted) "GRANTED" else "DENIED"}")
}
val allGranted = permissions.values.all { it }
val criticalPermissions = getCriticalPermissions()
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)
}
else -> {
Log.d(TAG, "Critical permissions denied")
handlePermissionDenial(permissions)
}
}
}
/**
* Get the list of critical permissions that are absolutely required
*/
private fun getCriticalPermissions(): List<String> {
// 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")
}
}
/**
* Show warning when some permissions are granted but others are denied
*/
private fun showPartialPermissionWarning(permissions: Map<String, Boolean>) {
val deniedPermissions = permissions.filter { !it.value }.keys
val message = buildString {
append("Some permissions were denied:\n")
deniedPermissions.forEach { permission ->
append("- ${getPermissionDisplayName(permission)}\n")
}
append("\nbitchat may not work properly without all permissions.")
}
Log.w(TAG, "Partial permissions granted: $message")
// For now, we'll proceed anyway and let the user experience the limitations
// In a production app, you might want to show a dialog explaining the limitations
completeOnboarding()
}
/**
* Handle permission denial scenarios
*/
private fun handlePermissionDenial(permissions: Map<String, Boolean>) {
val deniedCritical = permissions.filter { !it.value && getCriticalPermissions().contains(it.key) }
if (deniedCritical.isNotEmpty()) {
val message = buildString {
append("Critical permissions were denied. bitchat requires these permissions to function:\n")
deniedCritical.keys.forEach { permission ->
append("- ${getPermissionDisplayName(permission)}\n")
}
append("\nPlease grant these permissions in Settings to use bitchat.")
}
Log.e(TAG, "Critical permissions denied: $deniedCritical")
onOnboardingFailed(message)
} else {
// Shouldn't happen given our logic above, but handle gracefully
completeOnboarding()
}
}
/**
* Complete the onboarding process and initialize the app
*/
private fun completeOnboarding() {
Log.d(TAG, "Completing onboarding process")
// Mark onboarding as complete
permissionManager.markOnboardingComplete()
// Log final permission status
permissionManager.logPermissionStatus()
// Notify completion with a small delay to ensure everything is ready
activity.lifecycleScope.launch {
kotlinx.coroutines.delay(100) // Small delay for UI state to settle
onOnboardingComplete()
}
}
/**
* Open app settings for manual permission management
*/
fun openAppSettings() {
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.fromParts("package", activity.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(intent)
Log.d(TAG, "Opened app settings for manual permission management")
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
}
}
/**
* Convert permission string to user-friendly display name
*/
private fun getPermissionDisplayName(permission: String): String {
return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".")
}
}
/**
* Get diagnostic information for troubleshooting
*/
fun getDiagnostics(): String {
return buildString {
appendLine("Onboarding Coordinator Diagnostics:")
appendLine("Activity: ${activity::class.simpleName}")
appendLine("Permission launcher: ${permissionLauncher != null}")
appendLine()
append(permissionManager.getPermissionDiagnostics())
}
}
}
@@ -0,0 +1,256 @@
package com.bitchat.android.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Permission explanation screen shown before requesting permissions
* Explains why bitchat needs each permission and reassures users about privacy
*/
@Composable
fun PermissionExplanationScreen(
permissionCategories: List<PermissionCategory>,
onContinue: () -> Unit,
onCancel: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Welcome to bitchat*",
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Decentralized mesh messaging over Bluetooth",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
// Privacy assurance section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "🔒",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.size(20.dp)
)
Text(
text = "Your Privacy is Protected",
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
)
}
Text(
text = "• bitchat doesn't track you or collect personal data\n" +
"• No servers, no internet required, no data logging\n" +
"• Location permission is only used by Android for Bluetooth scanning\n" +
"• Your messages stay on your device and peer devices only",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "To work properly, bitchat needs these permissions:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
)
// Permission categories
permissionCategories.forEach { category ->
PermissionCategoryCard(
category = category,
colorScheme = colorScheme
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary
)
) {
Text(
text = "Grant Permissions",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colorScheme.onSurface.copy(alpha = 0.7f)
)
) {
Text(
text = "Exit App",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
@Composable
private fun PermissionCategoryCard(
category: PermissionCategory,
colorScheme: ColorScheme
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = getPermissionEmoji(category.name),
style = MaterialTheme.typography.titleLarge,
color = getPermissionIconColor(category.name),
modifier = Modifier.size(24.dp)
)
Text(
text = category.name,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
)
}
Text(
text = category.description,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f),
lineHeight = 18.sp
)
)
if (category.name == "Precise Location") {
// Extra emphasis for location permission
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.size(16.dp)
)
Text(
text = "bitchat does NOT use GPS or track location",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF9800)
)
)
}
}
}
}
}
private fun getPermissionEmoji(categoryName: String): String {
return when (categoryName) {
"Nearby Devices" -> "📱"
"Precise Location" -> "📍"
"Notifications" -> "🔔"
else -> "🔧"
}
}
private fun getPermissionIconColor(categoryName: String): Color {
return when (categoryName) {
"Nearby Devices" -> Color(0xFF2196F3) // Blue
"Precise Location" -> Color(0xFFFF9800) // Orange
"Notifications" -> Color(0xFF4CAF50) // Green
else -> Color(0xFF9C27B0) // Purple
}
}
@@ -0,0 +1,205 @@
package com.bitchat.android.onboarding
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
/**
* Centralized permission management for bitchat app
* Handles all Bluetooth and notification permissions required for the app to function
*/
class PermissionManager(private val context: Context) {
companion object {
private const val TAG = "PermissionManager"
private const val PREFS_NAME = "bitchat_permissions"
private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete"
}
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* Check if this is the first time the user is launching the app
*/
fun isFirstTimeLaunch(): Boolean {
return !sharedPrefs.getBoolean(KEY_FIRST_TIME_COMPLETE, false)
}
/**
* Mark the first-time onboarding as complete
*/
fun markOnboardingComplete() {
sharedPrefs.edit()
.putBoolean(KEY_FIRST_TIME_COMPLETE, true)
.apply()
Log.d(TAG, "First-time onboarding marked as complete")
}
/**
* Get all permissions required by the app
*/
fun getRequiredPermissions(): List<String> {
val permissions = mutableListOf<String>()
// Bluetooth permissions (API level dependent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions.addAll(listOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
))
} else {
permissions.addAll(listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
))
}
// Location permissions (required for Bluetooth LE scanning)
permissions.addAll(listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
// Notification permission (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
return permissions
}
/**
* Check if a specific permission is granted
*/
fun isPermissionGranted(permission: String): Boolean {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
/**
* Check if all required permissions are granted
*/
fun areAllPermissionsGranted(): Boolean {
return getRequiredPermissions().all { isPermissionGranted(it) }
}
/**
* Get the list of permissions that are missing
*/
fun getMissingPermissions(): List<String> {
return getRequiredPermissions().filter { !isPermissionGranted(it) }
}
/**
* Get categorized permission information for display
*/
fun getCategorizedPermissions(): List<PermissionCategory> {
val categories = mutableListOf<PermissionCategory>()
// Bluetooth/Nearby Devices category
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
)
} else {
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
)
}
categories.add(
PermissionCategory(
name = "Nearby Devices",
description = "Required to discover and connect to other bitchat users via Bluetooth",
permissions = bluetoothPermissions,
isGranted = bluetoothPermissions.all { isPermissionGranted(it) },
systemDescription = "Allow bitchat to connect to nearby devices"
)
)
// Location category
val locationPermissions = listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
categories.add(
PermissionCategory(
name = "Precise Location",
description = "Required by Android for Bluetooth scanning.",
permissions = locationPermissions,
isGranted = locationPermissions.all { isPermissionGranted(it) },
systemDescription = "Allow bitchat to access this device's location"
)
)
// Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add(
PermissionCategory(
name = "Notifications",
description = "Show notifications when you receive private messages while the app is in background",
permissions = listOf(Manifest.permission.POST_NOTIFICATIONS),
isGranted = isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS),
systemDescription = "Allow bitchat to send you notifications"
)
)
}
return categories
}
/**
* Get detailed diagnostic information about permission status
*/
fun getPermissionDiagnostics(): String {
return buildString {
appendLine("Permission Diagnostics:")
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
appendLine("First time launch: ${isFirstTimeLaunch()}")
appendLine("All permissions granted: ${areAllPermissionsGranted()}")
appendLine()
getCategorizedPermissions().forEach { category ->
appendLine("${category.name}: ${if (category.isGranted) "✅ GRANTED" else "❌ MISSING"}")
category.permissions.forEach { permission ->
val granted = isPermissionGranted(permission)
appendLine(" - ${permission.substringAfterLast(".")}: ${if (granted) "✅" else "❌"}")
}
appendLine()
}
val missing = getMissingPermissions()
if (missing.isNotEmpty()) {
appendLine("Missing permissions:")
missing.forEach { permission ->
appendLine("- $permission")
}
}
}
}
/**
* Log permission status for debugging
*/
fun logPermissionStatus() {
Log.d(TAG, getPermissionDiagnostics())
}
}
/**
* Data class representing a category of related permissions
*/
data class PermissionCategory(
val name: String,
val description: String,
val permissions: List<String>,
val isGranted: Boolean,
val systemDescription: String
)