From f9f56794d3e02274a9bc5d053b6dedc75a5e3245 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 10 Jul 2025 13:15:28 +0200 Subject: [PATCH] onboarding --- .../java/com/bitchat/android/MainActivity.kt | 220 +++++++++++---- .../android/onboarding/InitializingScreen.kt | 246 +++++++++++++++++ .../onboarding/OnboardingCoordinator.kt | 220 +++++++++++++++ .../onboarding/PermissionExplanationScreen.kt | 256 ++++++++++++++++++ .../android/onboarding/PermissionManager.kt | 205 ++++++++++++++ 5 files changed, 1089 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt create mode 100644 app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt create mode 100644 app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index bf990d64..a3ba5d74 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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() - - 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}") + } } } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt new file mode 100644 index 00000000..a195a594 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt new file mode 100644 index 00000000..c1952c7c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -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>? = 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) { + 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 { + // 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) { + 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) { + 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()) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt new file mode 100644 index 00000000..802e6ab2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -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, + 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 + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt new file mode 100644 index 00000000..7060fb1c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -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 { + val permissions = mutableListOf() + + // 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 { + return getRequiredPermissions().filter { !isPermissionGranted(it) } + } + + /** + * Get categorized permission information for display + */ + fun getCategorizedPermissions(): List { + val categories = mutableListOf() + + // 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, + val isGranted: Boolean, + val systemDescription: String +)