mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 19:25:20 +00:00
onboarding
This commit is contained in:
@@ -17,6 +17,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.bitchat.android.onboarding.*
|
||||||
import com.bitchat.android.ui.ChatScreen
|
import com.bitchat.android.ui.ChatScreen
|
||||||
import com.bitchat.android.ui.ChatViewModel
|
import com.bitchat.android.ui.ChatViewModel
|
||||||
import com.bitchat.android.ui.theme.BitchatTheme
|
import com.bitchat.android.ui.theme.BitchatTheme
|
||||||
@@ -25,28 +26,34 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private lateinit var permissionManager: PermissionManager
|
||||||
|
private lateinit var onboardingCoordinator: OnboardingCoordinator
|
||||||
private val chatViewModel: ChatViewModel by viewModels()
|
private val chatViewModel: ChatViewModel by viewModels()
|
||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
// UI state for onboarding flow
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
|
||||||
) { permissions ->
|
private var errorMessage by mutableStateOf("")
|
||||||
val allPermissionsGranted = permissions.values.all { it }
|
|
||||||
if (allPermissionsGranted) {
|
enum class OnboardingState {
|
||||||
// Permissions granted, the mesh service should start automatically
|
CHECKING,
|
||||||
} else {
|
PERMISSION_EXPLANATION,
|
||||||
// Handle permission denial
|
PERMISSION_REQUESTING,
|
||||||
finish()
|
INITIALIZING,
|
||||||
}
|
COMPLETE,
|
||||||
|
ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Request necessary permissions
|
// Initialize permission management
|
||||||
requestPermissions()
|
permissionManager = PermissionManager(this)
|
||||||
|
onboardingCoordinator = OnboardingCoordinator(
|
||||||
// Handle notification intent if app was opened from a notification
|
activity = this,
|
||||||
handleNotificationIntent(intent)
|
permissionManager = permissionManager,
|
||||||
|
onOnboardingComplete = ::handleOnboardingComplete,
|
||||||
|
onOnboardingFailed = ::handleOnboardingFailed
|
||||||
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
BitchatTheme {
|
BitchatTheme {
|
||||||
@@ -54,28 +61,155 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
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?) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// Handle notification intents when app is already running
|
// Handle notification intents when app is already running
|
||||||
intent?.let { handleNotificationIntent(it) }
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
|
intent?.let { handleNotificationIntent(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Notify that app is in foreground for power optimization and notifications
|
// Only set background state if app is fully initialized
|
||||||
chatViewModel.setAppBackgroundState(false)
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
|
chatViewModel.setAppBackgroundState(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
// Notify that app is in background for power optimization and notifications
|
// Only set background state if app is fully initialized
|
||||||
chatViewModel.setAppBackgroundState(true)
|
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() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
// FIXED: Ensure Bluetooth resources are cleaned up when the activity is destroyed
|
// Only stop mesh services if they were started
|
||||||
// This addresses the issue where stale Bluetooth advertisements interfere with
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
// restart discovery times. This is more reliable than relying solely on
|
try {
|
||||||
// ChatViewModel.onCleared() which may not be called when the app is swiped away.
|
chatViewModel.meshService.stopServices()
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
chatViewModel.meshService.stopServices()
|
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
||||||
} 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}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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