diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0f0e3945..e75b92ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.android" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 3 - versionName = "0.7" + versionCode = 4 + versionName = "0.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ea01524..3cc22c28 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,9 @@ + + + diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 335cb6ed..bfe57ca5 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -22,6 +22,9 @@ import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.onboarding.BluetoothCheckScreen import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.BluetoothStatusManager +import com.bitchat.android.onboarding.BatteryOptimizationManager +import com.bitchat.android.onboarding.BatteryOptimizationScreen +import com.bitchat.android.onboarding.BatteryOptimizationStatus import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.LocationCheckScreen @@ -43,6 +46,7 @@ class MainActivity : ComponentActivity() { private lateinit var onboardingCoordinator: OnboardingCoordinator private lateinit var bluetoothStatusManager: BluetoothStatusManager private lateinit var locationStatusManager: LocationStatusManager + private lateinit var batteryOptimizationManager: BatteryOptimizationManager // Core mesh service - managed at app level private lateinit var meshService: BluetoothMeshService @@ -56,6 +60,8 @@ class MainActivity : ComponentActivity() { } } + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -76,6 +82,12 @@ class MainActivity : ComponentActivity() { onLocationEnabled = ::handleLocationEnabled, onLocationDisabled = ::handleLocationDisabled ) + batteryOptimizationManager = BatteryOptimizationManager( + activity = this, + context = this, + onBatteryOptimizationDisabled = ::handleBatteryOptimizationDisabled, + onBatteryOptimizationFailed = ::handleBatteryOptimizationFailed + ) onboardingCoordinator = OnboardingCoordinator( activity = this, permissionManager = permissionManager, @@ -115,9 +127,11 @@ class MainActivity : ComponentActivity() { val onboardingState by mainViewModel.onboardingState.collectAsState() val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState() val locationStatus by mainViewModel.locationStatus.collectAsState() + val batteryOptimizationStatus by mainViewModel.batteryOptimizationStatus.collectAsState() val errorMessage by mainViewModel.errorMessage.collectAsState() val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState() val isLocationLoading by mainViewModel.isLocationLoading.collectAsState() + val isBatteryOptimizationLoading by mainViewModel.isBatteryOptimizationLoading.collectAsState() when (onboardingState) { OnboardingState.CHECKING -> { @@ -152,6 +166,24 @@ class MainActivity : ComponentActivity() { ) } + OnboardingState.BATTERY_OPTIMIZATION_CHECK -> { + BatteryOptimizationScreen( + status = batteryOptimizationStatus, + onDisableBatteryOptimization = { + mainViewModel.updateBatteryOptimizationLoading(true) + batteryOptimizationManager.requestDisableBatteryOptimization() + }, + onRetry = { + checkBatteryOptimizationAndProceed() + }, + onSkip = { + // Skip battery optimization and proceed + proceedWithPermissionCheck() + }, + isLoading = isBatteryOptimizationLoading + ) + } + OnboardingState.PERMISSION_EXPLANATION -> { PermissionExplanationScreen( permissionCategories = permissionManager.getCategorizedPermissions(), @@ -324,8 +356,8 @@ class MainActivity : ComponentActivity() { when (mainViewModel.locationStatus.value) { LocationStatus.ENABLED -> { - // Location services enabled, proceed with permission/onboarding check - proceedWithPermissionCheck() + // Location services enabled, check battery optimization next + checkBatteryOptimizationAndProceed() } LocationStatus.DISABLED -> { // Show location enable screen (should have permissions as existing user) @@ -349,7 +381,7 @@ class MainActivity : ComponentActivity() { Log.d("MainActivity", "Location services enabled by user") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(LocationStatus.ENABLED) - proceedWithPermissionCheck() + checkBatteryOptimizationAndProceed() } /** @@ -408,9 +440,14 @@ class MainActivity : ComponentActivity() { private fun handleOnboardingComplete() { Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app") - // After permissions are granted, re-check both Bluetooth and Location status + // After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentLocationStatus = locationStatusManager.checkLocationStatus() + val currentBatteryOptimizationStatus = when { + !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED + batteryOptimizationManager.isBatteryOptimizationDisabled() -> BatteryOptimizationStatus.DISABLED + else -> BatteryOptimizationStatus.ENABLED + } when { currentBluetoothStatus != BluetoothStatus.ENABLED -> { @@ -427,6 +464,13 @@ class MainActivity : ComponentActivity() { mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } + currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> { + // Battery optimization still enabled, show battery optimization screen + android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.") + mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) + mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) + mainViewModel.updateBatteryOptimizationLoading(false) + } else -> { // Both are enabled, proceed to app initialization Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization") @@ -442,6 +486,70 @@ class MainActivity : ComponentActivity() { mainViewModel.updateOnboardingState(OnboardingState.ERROR) } + /** + * Check Battery Optimization status and proceed with onboarding flow + */ + private fun checkBatteryOptimizationAndProceed() { + android.util.Log.d("MainActivity", "Checking battery optimization status") + + // For first-time users, skip battery optimization check and go straight to permissions + // We'll check battery optimization after permissions are granted + if (permissionManager.isFirstTimeLaunch()) { + android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions") + proceedWithPermissionCheck() + return + } + + // For existing users, check battery optimization status + batteryOptimizationManager.logBatteryOptimizationStatus() + val currentBatteryOptimizationStatus = when { + !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED + batteryOptimizationManager.isBatteryOptimizationDisabled() -> BatteryOptimizationStatus.DISABLED + else -> BatteryOptimizationStatus.ENABLED + } + mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) + + when (currentBatteryOptimizationStatus) { + BatteryOptimizationStatus.DISABLED, BatteryOptimizationStatus.NOT_SUPPORTED -> { + // Battery optimization is disabled or not supported, proceed with permission check + proceedWithPermissionCheck() + } + BatteryOptimizationStatus.ENABLED -> { + // Show battery optimization disable screen + android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen") + mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) + mainViewModel.updateBatteryOptimizationLoading(false) + } + } + } + + /** + * Handle Battery Optimization disabled callback + */ + private fun handleBatteryOptimizationDisabled() { + android.util.Log.d("MainActivity", "Battery optimization disabled by user") + mainViewModel.updateBatteryOptimizationLoading(false) + mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED) + proceedWithPermissionCheck() + } + + /** + * Handle Battery Optimization failed callback + */ + private fun handleBatteryOptimizationFailed(message: String) { + android.util.Log.w("MainActivity", "Battery optimization disable failed: $message") + mainViewModel.updateBatteryOptimizationLoading(false) + val currentStatus = when { + !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED + batteryOptimizationManager.isBatteryOptimizationDisabled() -> BatteryOptimizationStatus.DISABLED + else -> BatteryOptimizationStatus.ENABLED + } + mainViewModel.updateBatteryOptimizationStatus(currentStatus) + + // Stay on battery optimization check screen for retry + mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) + } + private fun initializeApp() { Log.d("MainActivity", "Starting app initialization") diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 62177c18..35125d85 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.LocationStatus import com.bitchat.android.onboarding.OnboardingState +import com.bitchat.android.onboarding.BatteryOptimizationStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,6 +29,12 @@ class MainViewModel : ViewModel() { private val _isLocationLoading = MutableStateFlow(false) val isLocationLoading: StateFlow = _isLocationLoading.asStateFlow() + private val _batteryOptimizationStatus = MutableStateFlow(BatteryOptimizationStatus.ENABLED) + val batteryOptimizationStatus: StateFlow = _batteryOptimizationStatus.asStateFlow() + + private val _isBatteryOptimizationLoading = MutableStateFlow(false) + val isBatteryOptimizationLoading: StateFlow = _isBatteryOptimizationLoading.asStateFlow() + // Public update functions for MainActivity fun updateOnboardingState(state: OnboardingState) { _onboardingState.value = state @@ -52,4 +59,12 @@ class MainViewModel : ViewModel() { fun updateLocationLoading(loading: Boolean) { _isLocationLoading.value = loading } + + fun updateBatteryOptimizationStatus(status: BatteryOptimizationStatus) { + _batteryOptimizationStatus.value = status + } + + fun updateBatteryOptimizationLoading(loading: Boolean) { + _isBatteryOptimizationLoading.value = loading + } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationManager.kt new file mode 100644 index 00000000..77ede503 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationManager.kt @@ -0,0 +1,177 @@ +package com.bitchat.android.onboarding + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts + +/** + * Manages battery optimization settings for the app + * Handles checking if the app is whitelisted from battery optimization + * and requesting the user to disable battery optimization + */ +class BatteryOptimizationManager( + private val activity: ComponentActivity, + private val context: Context, + private val onBatteryOptimizationDisabled: () -> Unit, + private val onBatteryOptimizationFailed: (String) -> Unit +) { + + companion object { + private const val TAG = "BatteryOptimizationManager" + } + + private var batteryOptimizationLauncher: ActivityResultLauncher? = null + + init { + setupBatteryOptimizationLauncher() + } + + /** + * Setup the battery optimization request launcher + */ + private fun setupBatteryOptimizationLauncher() { + batteryOptimizationLauncher = activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + Log.d(TAG, "Battery optimization request result: ${result.resultCode}") + + // Check if battery optimization is now disabled + if (isBatteryOptimizationDisabled()) { + Log.d(TAG, "Battery optimization successfully disabled") + onBatteryOptimizationDisabled() + } else { + Log.w(TAG, "Battery optimization still enabled after user interaction") + // Don't treat this as a failure - user might have chosen not to disable it + // We'll proceed anyway but log the status + onBatteryOptimizationDisabled() + } + } + } + + /** + * Check if battery optimization is disabled for this app + */ + fun isBatteryOptimizationDisabled(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val isIgnoring = powerManager.isIgnoringBatteryOptimizations(context.packageName) + Log.d(TAG, "Battery optimization disabled: $isIgnoring") + isIgnoring + } catch (e: Exception) { + Log.e(TAG, "Error checking battery optimization status", e) + // If we can't check, assume it's enabled (more conservative) + false + } + } else { + // Battery optimization doesn't exist on Android < 6.0 + Log.d(TAG, "Battery optimization not applicable for Android < 6.0") + true + } + } + + /** + * Request to disable battery optimization for this app + */ + fun requestDisableBatteryOptimization() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + Log.d(TAG, "Requesting to disable battery optimization") + + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.packageName}") + } + + // Check if the intent can be resolved + if (intent.resolveActivity(context.packageManager) != null) { + batteryOptimizationLauncher?.launch(intent) + } else { + Log.w(TAG, "Battery optimization settings not available, opening general settings") + openBatteryOptimizationSettings() + } + } catch (e: Exception) { + Log.e(TAG, "Error requesting battery optimization disable", e) + onBatteryOptimizationFailed("Unable to open battery optimization settings: ${e.message}") + } + } else { + Log.d(TAG, "Battery optimization not applicable for Android < 6.0") + onBatteryOptimizationDisabled() + } + } + + /** + * Open general battery optimization settings if direct request fails + */ + private fun openBatteryOptimizationSettings() { + try { + val intent = Intent().apply { + action = Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS + } + + if (intent.resolveActivity(context.packageManager) != null) { + batteryOptimizationLauncher?.launch(intent) + } else { + // Fallback to general application settings + openAppSettings() + } + } catch (e: Exception) { + Log.e(TAG, "Error opening battery optimization settings", e) + onBatteryOptimizationFailed("Unable to open settings: ${e.message}") + } + } + + /** + * Open app settings as a last resort + */ + private fun openAppSettings() { + try { + val intent = Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.fromParts("package", context.packageName, null) + } + batteryOptimizationLauncher?.launch(intent) + } catch (e: Exception) { + Log.e(TAG, "Error opening app settings", e) + onBatteryOptimizationFailed("Unable to open app settings: ${e.message}") + } + } + + /** + * Check if battery optimization is supported on this device + */ + fun isBatteryOptimizationSupported(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + } + + /** + * Get battery optimization status for logging + */ + fun getBatteryOptimizationStatus(): String { + return when { + !isBatteryOptimizationSupported() -> "Not supported (Android < 6.0)" + isBatteryOptimizationDisabled() -> "Disabled (app is whitelisted)" + else -> "Enabled (app is being optimized)" + } + } + + /** + * Log battery optimization status for debugging + */ + fun logBatteryOptimizationStatus() { + Log.d(TAG, "Battery optimization status: ${getBatteryOptimizationStatus()}") + } +} + +enum class BatteryOptimizationStatus { + ENABLED, + DISABLED, + NOT_SUPPORTED +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt new file mode 100644 index 00000000..1d44cff0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -0,0 +1,298 @@ +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.res.stringResource +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 com.bitchat.android.R + +/** + * Screen shown when checking battery optimization status or requesting battery optimization disable + */ + + + +@Composable +fun BatteryOptimizationScreen( + status: BatteryOptimizationStatus, + onDisableBatteryOptimization: () -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit, + isLoading: Boolean = false +) { + val colorScheme = MaterialTheme.colorScheme + + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + when (status) { + BatteryOptimizationStatus.ENABLED -> { + BatteryOptimizationEnabledContent( + onDisableBatteryOptimization = onDisableBatteryOptimization, + onRetry = onRetry, + onSkip = onSkip, + colorScheme = colorScheme, + isLoading = isLoading + ) + } + + BatteryOptimizationStatus.DISABLED -> { + BatteryOptimizationCheckingContent( + colorScheme = colorScheme + ) + } + + BatteryOptimizationStatus.NOT_SUPPORTED -> { + BatteryOptimizationNotSupportedContent( + onRetry = onRetry, + colorScheme = colorScheme + ) + } + } + } +} + +@Composable +private fun BatteryOptimizationEnabledContent( + onDisableBatteryOptimization: () -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit, + colorScheme: ColorScheme, + isLoading: Boolean +) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "bitchat*", + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = colorScheme.primary + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Icon( + imageVector = Icons.Outlined.BatteryAlert, + contentDescription = "Battery Optimization", + modifier = Modifier.size(64.dp), + tint = colorScheme.error + ) + + Text( + text = stringResource(R.string.battery_optimization_detected), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ), + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(R.string.battery_optimization_explanation), + style = MaterialTheme.typography.bodyLarge.copy( + color = colorScheme.onSurfaceVariant + ), + textAlign = TextAlign.Center + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.battery_optimization_why_disable), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ) + ) + + Text( + text = stringResource(R.string.battery_optimization_benefits), + style = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.onSurfaceVariant + ) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onDisableBatteryOptimization, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.battery_optimization_disable_button)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + Text(stringResource(R.string.battery_optimization_check_again)) + } + + TextButton( + onClick = onSkip, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + Text(stringResource(R.string.battery_optimization_skip)) + } + } + + Text( + text = stringResource(R.string.battery_optimization_note), + style = MaterialTheme.typography.bodySmall.copy( + color = colorScheme.onSurfaceVariant + ), + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun BatteryOptimizationCheckingContent( + 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 + ) + ) + + val infiniteTransition = rememberInfiniteTransition(label = "rotation") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + Icon( + imageVector = Icons.Filled.BatteryStd, + contentDescription = "Checking Battery Optimization", + modifier = Modifier + .size(64.dp) + .rotate(rotation), + tint = colorScheme.primary + ) + + Text( + text = stringResource(R.string.battery_optimization_disabled), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ), + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(R.string.battery_optimization_success_message), + style = MaterialTheme.typography.bodyLarge.copy( + color = colorScheme.onSurfaceVariant + ), + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun BatteryOptimizationNotSupportedContent( + onRetry: () -> Unit, + colorScheme: ColorScheme +) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "bitchat*", + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = colorScheme.primary + ) + ) + + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = "Battery Optimization Not Supported", + modifier = Modifier.size(64.dp), + tint = colorScheme.primary + ) + + Text( + text = stringResource(R.string.battery_optimization_not_required), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ), + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(R.string.battery_optimization_not_supported_message), + style = MaterialTheme.typography.bodyLarge.copy( + color = colorScheme.onSurfaceVariant + ), + textAlign = TextAlign.Center + ) + + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.battery_optimization_continue)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt index cb8e73b5..f06ddd08 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt @@ -4,6 +4,7 @@ enum class OnboardingState { CHECKING, BLUETOOTH_CHECK, LOCATION_CHECK, + BATTERY_OPTIMIZATION_CHECK, PERMISSION_EXPLANATION, PERMISSION_REQUESTING, INITIALIZING, diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 4f066096..7c24cadc 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -237,6 +237,7 @@ private fun getPermissionEmoji(permissionType: PermissionType): String { PermissionType.NEARBY_DEVICES -> "šŸ“±" PermissionType.PRECISE_LOCATION -> "šŸ“" PermissionType.NOTIFICATIONS -> "šŸ””" + PermissionType.BATTERY_OPTIMIZATION -> "šŸ”‹" PermissionType.OTHER -> "šŸ”§" } } @@ -246,6 +247,7 @@ private fun getPermissionIconColor(permissionType: PermissionType): Color { PermissionType.NEARBY_DEVICES -> Color(0xFF2196F3) // Blue PermissionType.PRECISE_LOCATION -> Color(0xFFFF9800) // Orange PermissionType.NOTIFICATIONS -> Color(0xFF4CAF50) // Green + PermissionType.BATTERY_OPTIMIZATION -> Color(0xFFF44336) // Red PermissionType.OTHER -> Color(0xFF9C27B0) // Purple } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index 7d293324..cfec35e7 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat @@ -86,6 +87,31 @@ class PermissionManager(private val context: Context) { return getRequiredPermissions().all { isPermissionGranted(it) } } + /** + * Check if battery optimization is disabled for this app + */ + fun isBatteryOptimizationDisabled(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + powerManager.isIgnoringBatteryOptimizations(context.packageName) + } catch (e: Exception) { + Log.e(TAG, "Error checking battery optimization status", e) + false + } + } else { + // Battery optimization doesn't exist on Android < 6.0 + true + } + } + + /** + * Check if battery optimization is supported on this device + */ + fun isBatteryOptimizationSupported(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + } + /** * Get the list of permissions that are missing */ @@ -152,6 +178,19 @@ class PermissionManager(private val context: Context) { ) } + // Battery optimization category (if applicable) + if (isBatteryOptimizationSupported()) { + categories.add( + PermissionCategory( + type = PermissionType.BATTERY_OPTIMIZATION, + description = "Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections", + permissions = listOf("BATTERY_OPTIMIZATION"), // Custom identifier + isGranted = isBatteryOptimizationDisabled(), + systemDescription = "Allow bitchat to run without battery restrictions" + ) + ) + } + return categories } @@ -208,5 +247,6 @@ enum class PermissionType(val nameValue: String) { NEARBY_DEVICES("Nearby Devices"), PRECISE_LOCATION("Precise Location"), NOTIFICATIONS("Notifications"), + BATTERY_OPTIMIZATION("Battery Optimization"), OTHER("Other") } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 4c9e30dc..7f8ce190 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -22,9 +22,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp @@ -65,7 +67,7 @@ fun ChatScreen(viewModel: ChatViewModel) { val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val showAppInfo by viewModel.showAppInfo.observeAsState(false) - var messageText by remember { mutableStateOf("") } + var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } @@ -112,20 +114,24 @@ fun ChatScreen(viewModel: ChatViewModel) { // Input area - stays at bottom ChatInputSection( messageText = messageText, - onMessageTextChange = { newText: String -> + onMessageTextChange = { newText: TextFieldValue -> messageText = newText - viewModel.updateCommandSuggestions(newText) + viewModel.updateCommandSuggestions(newText.text) }, onSend = { - if (messageText.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.trim()) - messageText = "" + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) + messageText = TextFieldValue("") } }, showCommandSuggestions = showCommandSuggestions, commandSuggestions = commandSuggestions, onSuggestionClick = { suggestion: CommandSuggestion -> - messageText = viewModel.selectCommandSuggestion(suggestion) + val commandText = viewModel.selectCommandSuggestion(suggestion) + messageText = TextFieldValue( + text = commandText, + selection = TextRange(commandText.length) + ) }, selectedPrivatePeer = selectedPrivatePeer, currentChannel = currentChannel, @@ -195,8 +201,8 @@ fun ChatScreen(viewModel: ChatViewModel) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatInputSection( - messageText: String, - onMessageTextChange: (String) -> Unit, + messageText: TextFieldValue, + onMessageTextChange: (TextFieldValue) -> Unit, onSend: () -> Unit, showCommandSuggestions: Boolean, commandSuggestions: List, diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 761b502b..d04d84aa 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -15,23 +15,79 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.withStyle /** * Input components for ChatScreen * Extracted from ChatScreen.kt for better organization */ +/** + * VisualTransformation that styles slash commands with background and color + * while preserving cursor positioning and click handling + */ +class SlashCommandVisualTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") + val annotatedString = buildAnnotatedString { + var lastIndex = 0 + + slashCommandRegex.findAll(text.text).forEach { match -> + // Add text before the match + if (match.range.first > lastIndex) { + append(text.text.substring(lastIndex, match.range.first)) + } + + // Add the styled slash command + withStyle( + style = SpanStyle( + color = Color(0xFF00FF7F), // Bright green + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + background = Color(0xFF2D2D2D) // Dark gray background + ) + ) { + append(match.value) + } + + lastIndex = match.range.last + 1 + } + + // Add remaining text + if (lastIndex < text.text.length) { + append(text.text.substring(lastIndex)) + } + } + + return TransformedText( + text = annotatedString, + offsetMapping = OffsetMapping.Identity + ) + } +} + + + + + @Composable fun MessageInput( - value: String, - onValueChange: (String) -> Unit, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, onSend: () -> Unit, selectedPrivatePeer: String?, currentChannel: String?, @@ -39,6 +95,7 @@ fun MessageInput( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme + val isFocused = remember { mutableStateOf(false) } Row( modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding @@ -70,7 +127,12 @@ fun MessageInput( cursorBrush = SolidColor(colorScheme.primary), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { onSend() }), - modifier = Modifier.weight(1f) + visualTransformation = SlashCommandVisualTransformation(), + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + isFocused.value = focusState.isFocused + } ) Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82b84804..23bd354c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,4 +17,23 @@ No one connected Triple tap to clear all data Your Network + + + Battery Optimization Detected + Battery Optimization Disabled + Battery Optimization Not Required + Checking Battery Optimization + Why disable battery optimization? + bitchat runs in the background to maintain mesh network connections with nearby devices. Battery optimization can interrupt these connections, causing messages to be delayed or missed.\n\nDisabling battery optimization ensures reliable peer-to-peer messaging. + Disable Battery Optimization + Note: You can change this setting later in Android Settings > Apps > bitchat > Battery + Your device doesn\'t require battery optimization settings. bitchat will run normally. + Your device doesn\'t require battery optimization settings. bitchat will run normally. + bitchat can run reliably in the background + • Ensures reliable message delivery\n• Maintains mesh network connectivity\n• Allows background message relay\n• Prevents connection drops + Check Again + Skip for Now + Continue + Retry + Skip