mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 09:05:21 +00:00
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user