Merge pull request #31 from permissionlesstech/enable-bluetooth

Check for bluetooth on startup
This commit is contained in:
callebtc
2025-07-10 14:16:46 +02:00
committed by GitHub
4 changed files with 631 additions and 4 deletions
+1
View File
@@ -64,6 +64,7 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// AppCompat for theme support
implementation("androidx.appcompat:appcompat:1.6.1")
@@ -28,14 +28,18 @@ class MainActivity : ComponentActivity() {
private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator
private lateinit var bluetoothStatusManager: BluetoothStatusManager
private val chatViewModel: ChatViewModel by viewModels()
// UI state for onboarding flow
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
private var bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED)
private var errorMessage by mutableStateOf("")
private var isBluetoothLoading by mutableStateOf(false)
enum class OnboardingState {
CHECKING,
BLUETOOTH_CHECK,
PERMISSION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
@@ -48,6 +52,12 @@ class MainActivity : ComponentActivity() {
// Initialize permission management
permissionManager = PermissionManager(this)
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
onBluetoothEnabled = ::handleBluetoothEnabled,
onBluetoothDisabled = ::handleBluetoothDisabled
)
onboardingCoordinator = OnboardingCoordinator(
activity = this,
permissionManager = permissionManager,
@@ -77,6 +87,20 @@ class MainActivity : ComponentActivity() {
InitializingScreen()
}
OnboardingState.BLUETOOTH_CHECK -> {
BluetoothCheckScreen(
status = bluetoothStatus,
onEnableBluetooth = {
isBluetoothLoading = true
bluetoothStatusManager.requestEnableBluetooth()
},
onRetry = {
checkBluetoothAndProceed()
},
isLoading = isBluetoothLoading
)
}
OnboardingState.PERMISSION_EXPLANATION -> {
PermissionExplanationScreen(
permissionCategories = permissionManager.getCategorizedPermissions(),
@@ -124,6 +148,58 @@ class MainActivity : ComponentActivity() {
// Small delay to show the checking state
delay(500)
// First check Bluetooth status (always required)
checkBluetoothAndProceed()
}
}
/**
* Check Bluetooth status and proceed with onboarding flow
*/
private fun checkBluetoothAndProceed() {
android.util.Log.d("MainActivity", "Checking Bluetooth status")
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
android.util.Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions")
proceedWithPermissionCheck()
return
}
// For existing users, check Bluetooth status first
bluetoothStatusManager.logBluetoothStatus()
bluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
when (bluetoothStatus) {
BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, proceed with permission/onboarding check
proceedWithPermissionCheck()
}
BluetoothStatus.DISABLED -> {
// Show Bluetooth enable screen (should have permissions as existing user)
android.util.Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
}
BluetoothStatus.NOT_SUPPORTED -> {
// Device doesn't support Bluetooth
android.util.Log.e("MainActivity", "Bluetooth not supported")
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
}
}
}
/**
* Proceed with permission checking
*/
private fun proceedWithPermissionCheck() {
android.util.Log.d("MainActivity", "Proceeding with permission check")
lifecycleScope.launch {
delay(200) // Small delay for smooth transition
if (permissionManager.isFirstTimeLaunch()) {
android.util.Log.d("MainActivity", "First time launch, showing permission explanation")
onboardingState = OnboardingState.PERMISSION_EXPLANATION
@@ -138,10 +214,64 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Handle Bluetooth enabled callback
*/
private fun handleBluetoothEnabled() {
android.util.Log.d("MainActivity", "Bluetooth enabled by user")
isBluetoothLoading = false
bluetoothStatus = BluetoothStatus.ENABLED
proceedWithPermissionCheck()
}
/**
* Handle Bluetooth disabled callback
*/
private fun handleBluetoothDisabled(message: String) {
android.util.Log.w("MainActivity", "Bluetooth disabled or failed: $message")
isBluetoothLoading = false
bluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
when {
bluetoothStatus == BluetoothStatus.NOT_SUPPORTED -> {
// Show permanent error for unsupported devices
errorMessage = message
onboardingState = OnboardingState.ERROR
}
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
// During first-time onboarding, if Bluetooth enable fails due to permissions,
// proceed to permission explanation screen where user will grant permissions first
android.util.Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation")
proceedWithPermissionCheck()
}
message.contains("Permission") -> {
// For existing users, redirect to permission explanation to grant missing permissions
android.util.Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation")
onboardingState = OnboardingState.PERMISSION_EXPLANATION
}
else -> {
// Stay on Bluetooth check screen for retry
onboardingState = OnboardingState.BLUETOOTH_CHECK
}
}
}
private fun handleOnboardingComplete() {
android.util.Log.d("MainActivity", "Onboarding completed, initializing app")
onboardingState = OnboardingState.INITIALIZING
initializeApp()
android.util.Log.d("MainActivity", "Onboarding completed, checking Bluetooth again before initializing app")
// After permissions are granted, re-check Bluetooth status
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
if (currentBluetoothStatus == BluetoothStatus.ENABLED) {
// Bluetooth is enabled, proceed to app initialization
onboardingState = OnboardingState.INITIALIZING
initializeApp()
} else {
// Bluetooth still disabled, but now we have permissions to enable it
android.util.Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
bluetoothStatus = currentBluetoothStatus
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
}
}
private fun handleOnboardingFailed(message: String) {
@@ -198,9 +328,18 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// Only set background state if app is fully initialized
// Check Bluetooth status on resume and handle accordingly
if (onboardingState == OnboardingState.COMPLETE) {
chatViewModel.setAppBackgroundState(false)
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
android.util.Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
bluetoothStatus = currentBluetoothStatus
onboardingState = OnboardingState.BLUETOOTH_CHECK
isBluetoothLoading = false
}
}
}
@@ -0,0 +1,272 @@
package com.bitchat.android.onboarding
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
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
/**
* Screen shown when checking Bluetooth status or requesting Bluetooth enable
*/
@Composable
fun BluetoothCheckScreen(
status: BluetoothStatus,
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
isLoading: Boolean = false
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
when (status) {
BluetoothStatus.DISABLED -> {
BluetoothDisabledContent(
onEnableBluetooth = onEnableBluetooth,
onRetry = onRetry,
colorScheme = colorScheme,
isLoading = isLoading
)
}
BluetoothStatus.NOT_SUPPORTED -> {
BluetoothNotSupportedContent(
colorScheme = colorScheme
)
}
BluetoothStatus.ENABLED -> {
BluetoothCheckingContent(
colorScheme = colorScheme
)
}
}
}
}
@Composable
private fun BluetoothDisabledContent(
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
colorScheme: ColorScheme,
isLoading: Boolean
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Bluetooth icon - using Bluetooth outlined icon in app's green color
Icon(
imageVector = Icons.Outlined.Bluetooth,
contentDescription = "Bluetooth",
modifier = Modifier.size(64.dp),
tint = Color(0xFF00C851) // App's main green color
)
Text(
text = "Bluetooth Required",
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
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)
) {
Text(
text = "bitchat needs Bluetooth to:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "• Discover nearby users\n" +
"• Create mesh network connections\n" +
"• Send and receive messages\n" +
"• Work without internet or servers",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
)
}
}
if (isLoading) {
BluetoothLoadingIndicator()
} else {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onEnableBluetooth,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851) // App's main green color
)
) {
Text(
text = "Enable Bluetooth",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
OutlinedButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Check Again",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
}
@Composable
private fun BluetoothNotSupportedContent(
colorScheme: ColorScheme
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Error icon
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 = "Bluetooth Not Supported",
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 = "This device doesn't support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet.",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface
),
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun BluetoothCheckingContent(
colorScheme: ColorScheme
) {
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat*",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
BluetoothLoadingIndicator()
Text(
text = "Checking Bluetooth status...",
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
)
}
}
@Composable
private fun BluetoothLoadingIndicator() {
// Animated rotation for the loading indicator
val infiniteTransition = rememberInfiniteTransition(label = "bluetooth_loading")
val rotationAngle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
Box(
modifier = Modifier.size(60.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.rotate(rotationAngle),
color = Color(0xFF2196F3), // Bluetooth blue
strokeWidth = 3.dp
)
}
}
@@ -0,0 +1,215 @@
package com.bitchat.android.onboarding
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
/**
* Manages Bluetooth enable/disable state and user prompts
* Checks Bluetooth status on every app startup
*/
class BluetoothStatusManager(
private val activity: ComponentActivity,
private val context: Context,
private val onBluetoothEnabled: () -> Unit,
private val onBluetoothDisabled: (String) -> Unit
) {
companion object {
private const val TAG = "BluetoothStatusManager"
}
private var bluetoothEnableLauncher: ActivityResultLauncher<Intent>? = null
private var bluetoothAdapter: BluetoothAdapter? = null
init {
setupBluetoothAdapter()
setupBluetoothEnableLauncher()
}
/**
* Setup Bluetooth adapter reference
*/
private fun setupBluetoothAdapter() {
try {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothAdapter = bluetoothManager.adapter
Log.d(TAG, "Bluetooth adapter initialized: ${bluetoothAdapter != null}")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Bluetooth adapter", e)
bluetoothAdapter = null
}
}
/**
* Setup launcher for Bluetooth enable request
*/
private fun setupBluetoothEnableLauncher() {
bluetoothEnableLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val isEnabled = bluetoothAdapter?.isEnabled == true
Log.d(TAG, "Bluetooth enable request result: $isEnabled (result code: ${result.resultCode})")
if (isEnabled) {
onBluetoothEnabled()
} else {
onBluetoothDisabled("Bluetooth is required for bitchat to discover and connect to nearby users. Please enable Bluetooth to continue.")
}
}
}
/**
* Check if Bluetooth is supported on this device
*/
fun isBluetoothSupported(): Boolean {
return bluetoothAdapter != null
}
/**
* Check if Bluetooth is currently enabled (permission-safe)
*/
fun isBluetoothEnabled(): Boolean {
return try {
bluetoothAdapter?.isEnabled == true
} catch (securityException: SecurityException) {
// If we can't check due to permissions, assume disabled
Log.w(TAG, "Cannot check Bluetooth enabled state due to missing permissions")
false
} catch (e: Exception) {
Log.w(TAG, "Error checking Bluetooth enabled state: ${e.message}")
false
}
}
/**
* Check Bluetooth status and handle accordingly (permission-safe)
* This should be called on every app startup
*/
fun checkBluetoothStatus(): BluetoothStatus {
Log.d(TAG, "Checking Bluetooth status")
return when {
bluetoothAdapter == null -> {
Log.e(TAG, "Bluetooth not supported on this device")
BluetoothStatus.NOT_SUPPORTED
}
!isBluetoothEnabled() -> {
Log.w(TAG, "Bluetooth is disabled or cannot be checked")
BluetoothStatus.DISABLED
}
else -> {
Log.d(TAG, "Bluetooth is enabled and ready")
BluetoothStatus.ENABLED
}
}
}
/**
* Request user to enable Bluetooth (permission-aware)
*/
fun requestEnableBluetooth() {
Log.d(TAG, "Requesting user to enable Bluetooth")
try {
val enableBluetoothIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
bluetoothEnableLauncher?.launch(enableBluetoothIntent)
} catch (securityException: SecurityException) {
// Permission not granted yet - this is expected during onboarding
Log.w(TAG, "Cannot request Bluetooth enable due to missing BLUETOOTH_CONNECT permission")
onBluetoothDisabled("Bluetooth permissions are required before enabling Bluetooth. Please grant permissions first.")
} catch (e: Exception) {
Log.e(TAG, "Failed to request Bluetooth enable", e)
onBluetoothDisabled("Failed to request Bluetooth enable: ${e.message}")
}
}
/**
* Handle Bluetooth status check result
*/
fun handleBluetoothStatus(status: BluetoothStatus) {
when (status) {
BluetoothStatus.ENABLED -> {
Log.d(TAG, "Bluetooth is enabled, proceeding")
onBluetoothEnabled()
}
BluetoothStatus.DISABLED -> {
Log.d(TAG, "Bluetooth is disabled, requesting enable")
requestEnableBluetooth()
}
BluetoothStatus.NOT_SUPPORTED -> {
Log.e(TAG, "Bluetooth not supported")
onBluetoothDisabled("This device doesn't support Bluetooth, which is required for bitchat to function.")
}
}
}
/**
* Get user-friendly status message
*/
fun getStatusMessage(status: BluetoothStatus): String {
return when (status) {
BluetoothStatus.ENABLED -> "Bluetooth is enabled and ready"
BluetoothStatus.DISABLED -> "Bluetooth is disabled. Please enable Bluetooth to use bitchat."
BluetoothStatus.NOT_SUPPORTED -> "This device doesn't support Bluetooth."
}
}
/**
* Get detailed diagnostics (permission-safe)
*/
fun getDiagnostics(): String {
return buildString {
appendLine("Bluetooth Status Diagnostics:")
appendLine("Adapter available: ${bluetoothAdapter != null}")
appendLine("Bluetooth supported: ${isBluetoothSupported()}")
appendLine("Bluetooth enabled: ${isBluetoothEnabled()}")
appendLine("Current status: ${checkBluetoothStatus()}")
// Only access adapter details if we have permission and adapter is available
bluetoothAdapter?.let { adapter ->
try {
// These calls require BLUETOOTH_CONNECT permission on Android 12+
appendLine("Adapter name: ${adapter.name ?: "Unknown"}")
appendLine("Adapter address: ${adapter.address ?: "Unknown"}")
} catch (securityException: SecurityException) {
// Permission not granted yet, skip detailed info
appendLine("Adapter details: [Permission required]")
} catch (e: Exception) {
appendLine("Adapter details: [Error: ${e.message}]")
}
appendLine("Adapter state: ${getAdapterStateName(adapter.state)}")
}
}
}
private fun getAdapterStateName(state: Int): String {
return when (state) {
BluetoothAdapter.STATE_OFF -> "OFF"
BluetoothAdapter.STATE_TURNING_ON -> "TURNING_ON"
BluetoothAdapter.STATE_ON -> "ON"
BluetoothAdapter.STATE_TURNING_OFF -> "TURNING_OFF"
else -> "UNKNOWN($state)"
}
}
/**
* Log current Bluetooth status for debugging
*/
fun logBluetoothStatus() {
Log.d(TAG, getDiagnostics())
}
}
/**
* Bluetooth status enum
*/
enum class BluetoothStatus {
ENABLED,
DISABLED,
NOT_SUPPORTED
}