mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 17:45:20 +00:00
Merge branch 'main' into noise
This commit is contained in:
@@ -19,6 +19,9 @@
|
||||
<!-- Haptic feedback permission -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Battery optimization permission -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- Hardware features -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
package com.bitchat.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.onboarding.*
|
||||
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
|
||||
import com.bitchat.android.onboarding.LocationStatus
|
||||
import com.bitchat.android.onboarding.LocationStatusManager
|
||||
import com.bitchat.android.onboarding.OnboardingCoordinator
|
||||
import com.bitchat.android.onboarding.OnboardingState
|
||||
import com.bitchat.android.onboarding.PermissionExplanationScreen
|
||||
import com.bitchat.android.onboarding.PermissionManager
|
||||
import com.bitchat.android.ui.ChatScreen
|
||||
import com.bitchat.android.ui.ChatViewModel
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
@@ -34,9 +46,11 @@ 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
|
||||
private val mainViewModel: MainViewModel by viewModels()
|
||||
private val chatViewModel: ChatViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||
@@ -46,24 +60,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// UI state for onboarding flow
|
||||
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
|
||||
private var bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED)
|
||||
private var locationStatus by mutableStateOf(LocationStatus.ENABLED)
|
||||
private var errorMessage by mutableStateOf("")
|
||||
private var isBluetoothLoading by mutableStateOf(false)
|
||||
private var isLocationLoading by mutableStateOf(false)
|
||||
|
||||
enum class OnboardingState {
|
||||
CHECKING,
|
||||
BLUETOOTH_CHECK,
|
||||
LOCATION_CHECK,
|
||||
PERMISSION_EXPLANATION,
|
||||
PERMISSION_REQUESTING,
|
||||
INITIALIZING,
|
||||
COMPLETE,
|
||||
ERROR
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -85,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,
|
||||
@@ -103,12 +106,33 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start the onboarding process
|
||||
checkOnboardingStatus()
|
||||
// Collect state changes in a lifecycle-aware manner
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
mainViewModel.onboardingState.collect { state ->
|
||||
handleOnboardingStateChange(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only start onboarding process if we're in the initial CHECKING state
|
||||
// This prevents restarting onboarding on configuration changes
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.CHECKING) {
|
||||
checkOnboardingStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingFlowScreen() {
|
||||
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 -> {
|
||||
InitializingScreen()
|
||||
@@ -118,7 +142,7 @@ class MainActivity : ComponentActivity() {
|
||||
BluetoothCheckScreen(
|
||||
status = bluetoothStatus,
|
||||
onEnableBluetooth = {
|
||||
isBluetoothLoading = true
|
||||
mainViewModel.updateBluetoothLoading(true)
|
||||
bluetoothStatusManager.requestEnableBluetooth()
|
||||
},
|
||||
onRetry = {
|
||||
@@ -132,7 +156,7 @@ class MainActivity : ComponentActivity() {
|
||||
LocationCheckScreen(
|
||||
status = locationStatus,
|
||||
onEnableLocation = {
|
||||
isLocationLoading = true
|
||||
mainViewModel.updateLocationLoading(true)
|
||||
locationStatusManager.requestEnableLocation()
|
||||
},
|
||||
onRetry = {
|
||||
@@ -142,11 +166,29 @@ 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(),
|
||||
onContinue = {
|
||||
onboardingState = OnboardingState.PERMISSION_REQUESTING
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_REQUESTING)
|
||||
onboardingCoordinator.requestPermissions()
|
||||
}
|
||||
)
|
||||
@@ -186,7 +228,7 @@ class MainActivity : ComponentActivity() {
|
||||
InitializationErrorScreen(
|
||||
errorMessage = errorMessage,
|
||||
onRetry = {
|
||||
onboardingState = OnboardingState.CHECKING
|
||||
mainViewModel.updateOnboardingState(OnboardingState.CHECKING)
|
||||
checkOnboardingStatus()
|
||||
},
|
||||
onOpenSettings = {
|
||||
@@ -197,8 +239,22 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOnboardingStateChange(state: OnboardingState) {
|
||||
|
||||
when (state) {
|
||||
OnboardingState.COMPLETE -> {
|
||||
// App is fully initialized, mesh service is running
|
||||
android.util.Log.d("MainActivity", "Onboarding completed - app ready")
|
||||
}
|
||||
OnboardingState.ERROR -> {
|
||||
android.util.Log.e("MainActivity", "Onboarding error state reached")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkOnboardingStatus() {
|
||||
android.util.Log.d("MainActivity", "Checking onboarding status")
|
||||
Log.d("MainActivity", "Checking onboarding status")
|
||||
|
||||
lifecycleScope.launch {
|
||||
// Small delay to show the checking state
|
||||
@@ -213,36 +269,36 @@ class MainActivity : ComponentActivity() {
|
||||
* Check Bluetooth status and proceed with onboarding flow
|
||||
*/
|
||||
private fun checkBluetoothAndProceed() {
|
||||
// android.util.Log.d("MainActivity", "Checking Bluetooth status")
|
||||
// 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")
|
||||
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()
|
||||
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
|
||||
|
||||
when (bluetoothStatus) {
|
||||
when (mainViewModel.bluetoothStatus.value) {
|
||||
BluetoothStatus.ENABLED -> {
|
||||
// Bluetooth is enabled, check location services next
|
||||
checkLocationAndProceed()
|
||||
}
|
||||
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
|
||||
Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
}
|
||||
BluetoothStatus.NOT_SUPPORTED -> {
|
||||
// Device doesn't support Bluetooth
|
||||
android.util.Log.e("MainActivity", "Bluetooth not supported")
|
||||
onboardingState = OnboardingState.BLUETOOTH_CHECK
|
||||
isBluetoothLoading = false
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,21 +307,21 @@ class MainActivity : ComponentActivity() {
|
||||
* Proceed with permission checking
|
||||
*/
|
||||
private fun proceedWithPermissionCheck() {
|
||||
android.util.Log.d("MainActivity", "Proceeding with permission check")
|
||||
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
|
||||
Log.d("MainActivity", "First time launch, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.areAllPermissionsGranted()) {
|
||||
android.util.Log.d("MainActivity", "Existing user with permissions, initializing app")
|
||||
onboardingState = OnboardingState.INITIALIZING
|
||||
Log.d("MainActivity", "Existing user with permissions, initializing app")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
} else {
|
||||
android.util.Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
||||
onboardingState = OnboardingState.PERMISSION_EXPLANATION
|
||||
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,9 +330,9 @@ class MainActivity : ComponentActivity() {
|
||||
* Handle Bluetooth enabled callback
|
||||
*/
|
||||
private fun handleBluetoothEnabled() {
|
||||
android.util.Log.d("MainActivity", "Bluetooth enabled by user")
|
||||
isBluetoothLoading = false
|
||||
bluetoothStatus = BluetoothStatus.ENABLED
|
||||
Log.d("MainActivity", "Bluetooth enabled by user")
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
|
||||
checkLocationAndProceed()
|
||||
}
|
||||
|
||||
@@ -284,36 +340,36 @@ class MainActivity : ComponentActivity() {
|
||||
* Check Location services status and proceed with onboarding flow
|
||||
*/
|
||||
private fun checkLocationAndProceed() {
|
||||
android.util.Log.d("MainActivity", "Checking location services status")
|
||||
Log.d("MainActivity", "Checking location services status")
|
||||
|
||||
// For first-time users, skip location check and go straight to permissions
|
||||
// We'll check location after permissions are granted
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
android.util.Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
|
||||
Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
|
||||
proceedWithPermissionCheck()
|
||||
return
|
||||
}
|
||||
|
||||
// For existing users, check location status
|
||||
locationStatusManager.logLocationStatus()
|
||||
locationStatus = locationStatusManager.checkLocationStatus()
|
||||
mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus())
|
||||
|
||||
when (locationStatus) {
|
||||
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)
|
||||
android.util.Log.d("MainActivity", "Location services disabled, showing enable screen")
|
||||
onboardingState = OnboardingState.LOCATION_CHECK
|
||||
isLocationLoading = false
|
||||
Log.d("MainActivity", "Location services disabled, showing enable screen")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
}
|
||||
LocationStatus.NOT_AVAILABLE -> {
|
||||
// Device doesn't support location services (very unusual)
|
||||
android.util.Log.e("MainActivity", "Location services not available")
|
||||
onboardingState = OnboardingState.LOCATION_CHECK
|
||||
isLocationLoading = false
|
||||
Log.e("MainActivity", "Location services not available")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,29 +378,29 @@ class MainActivity : ComponentActivity() {
|
||||
* Handle Location enabled callback
|
||||
*/
|
||||
private fun handleLocationEnabled() {
|
||||
android.util.Log.d("MainActivity", "Location services enabled by user")
|
||||
isLocationLoading = false
|
||||
locationStatus = LocationStatus.ENABLED
|
||||
proceedWithPermissionCheck()
|
||||
Log.d("MainActivity", "Location services enabled by user")
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
||||
checkBatteryOptimizationAndProceed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Location disabled callback
|
||||
*/
|
||||
private fun handleLocationDisabled(message: String) {
|
||||
android.util.Log.w("MainActivity", "Location services disabled or failed: $message")
|
||||
isLocationLoading = false
|
||||
locationStatus = locationStatusManager.checkLocationStatus()
|
||||
|
||||
Log.w("MainActivity", "Location services disabled or failed: $message")
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus())
|
||||
|
||||
when {
|
||||
locationStatus == LocationStatus.NOT_AVAILABLE -> {
|
||||
mainViewModel.locationStatus.value == LocationStatus.NOT_AVAILABLE -> {
|
||||
// Show permanent error for devices without location services
|
||||
errorMessage = message
|
||||
onboardingState = OnboardingState.ERROR
|
||||
mainViewModel.updateErrorMessage(message)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.ERROR)
|
||||
}
|
||||
else -> {
|
||||
// Stay on location check screen for retry
|
||||
onboardingState = OnboardingState.LOCATION_CHECK
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,73 +409,149 @@ class MainActivity : ComponentActivity() {
|
||||
* Handle Bluetooth disabled callback
|
||||
*/
|
||||
private fun handleBluetoothDisabled(message: String) {
|
||||
android.util.Log.w("MainActivity", "Bluetooth disabled or failed: $message")
|
||||
isBluetoothLoading = false
|
||||
bluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||
Log.w("MainActivity", "Bluetooth disabled or failed: $message")
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
|
||||
|
||||
when {
|
||||
bluetoothStatus == BluetoothStatus.NOT_SUPPORTED -> {
|
||||
mainViewModel.bluetoothStatus.value == BluetoothStatus.NOT_SUPPORTED -> {
|
||||
// Show permanent error for unsupported devices
|
||||
errorMessage = message
|
||||
onboardingState = OnboardingState.ERROR
|
||||
mainViewModel.updateErrorMessage(message)
|
||||
mainViewModel.updateOnboardingState(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")
|
||||
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
|
||||
Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
}
|
||||
else -> {
|
||||
// Stay on Bluetooth check screen for retry
|
||||
onboardingState = OnboardingState.BLUETOOTH_CHECK
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOnboardingComplete() {
|
||||
android.util.Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
|
||||
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 -> {
|
||||
// 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
|
||||
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
}
|
||||
currentLocationStatus != LocationStatus.ENABLED -> {
|
||||
// Location services still disabled, but now we have permissions to enable it
|
||||
android.util.Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
|
||||
locationStatus = currentLocationStatus
|
||||
onboardingState = OnboardingState.LOCATION_CHECK
|
||||
isLocationLoading = false
|
||||
Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
|
||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||
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
|
||||
android.util.Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
|
||||
onboardingState = OnboardingState.INITIALIZING
|
||||
Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOnboardingFailed(message: String) {
|
||||
android.util.Log.e("MainActivity", "Onboarding failed: $message")
|
||||
errorMessage = message
|
||||
onboardingState = OnboardingState.ERROR
|
||||
Log.e("MainActivity", "Onboarding failed: $message")
|
||||
mainViewModel.updateErrorMessage(message)
|
||||
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() {
|
||||
android.util.Log.d("MainActivity", "Starting app initialization")
|
||||
Log.d("MainActivity", "Starting app initialization")
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
@@ -427,12 +559,12 @@ class MainActivity : ComponentActivity() {
|
||||
// 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, initializing chat system")
|
||||
Log.d("MainActivity", "Permissions verified, initializing chat system")
|
||||
|
||||
// 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")
|
||||
Log.w("MainActivity", "Permissions revoked during initialization: $missing")
|
||||
handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.")
|
||||
return@launch
|
||||
}
|
||||
@@ -441,19 +573,17 @@ class MainActivity : ComponentActivity() {
|
||||
meshService.delegate = chatViewModel
|
||||
meshService.startServices()
|
||||
|
||||
android.util.Log.d("MainActivity", "Mesh service started successfully")
|
||||
Log.d("MainActivity", "Mesh service started successfully")
|
||||
|
||||
// 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
|
||||
|
||||
Log.d("MainActivity", "App initialization complete")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to initialize app", e)
|
||||
Log.e("MainActivity", "Failed to initialize app", e)
|
||||
handleOnboardingFailed("Failed to initialize the app: ${e.message}")
|
||||
}
|
||||
}
|
||||
@@ -462,7 +592,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Handle notification intents when app is already running
|
||||
if (onboardingState == OnboardingState.COMPLETE) {
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
handleNotificationIntent(intent)
|
||||
}
|
||||
}
|
||||
@@ -470,28 +600,28 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Check Bluetooth and Location status on resume and handle accordingly
|
||||
if (onboardingState == OnboardingState.COMPLETE) {
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Set app foreground state
|
||||
meshService.connectionManager.setAppBackgroundState(false)
|
||||
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
|
||||
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
|
||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if location services were disabled while app was backgrounded
|
||||
val currentLocationStatus = locationStatusManager.checkLocationStatus()
|
||||
if (currentLocationStatus != LocationStatus.ENABLED) {
|
||||
android.util.Log.w("MainActivity", "Location services disabled while app was backgrounded")
|
||||
locationStatus = currentLocationStatus
|
||||
onboardingState = OnboardingState.LOCATION_CHECK
|
||||
isLocationLoading = false
|
||||
Log.w("MainActivity", "Location services disabled while app was backgrounded")
|
||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,7 +629,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// Only set background state if app is fully initialized
|
||||
if (onboardingState == OnboardingState.COMPLETE) {
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Set app background state
|
||||
meshService.connectionManager.setAppBackgroundState(true)
|
||||
chatViewModel.setAppBackgroundState(true)
|
||||
@@ -520,7 +650,7 @@ class MainActivity : ComponentActivity() {
|
||||
val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME)
|
||||
|
||||
if (peerID != null) {
|
||||
android.util.Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
||||
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
||||
|
||||
// Open the private chat with this peer
|
||||
chatViewModel.startPrivateChat(peerID)
|
||||
@@ -530,25 +660,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart mesh services (for debugging/troubleshooting)
|
||||
*/
|
||||
fun restartMeshServices() {
|
||||
if (onboardingState == OnboardingState.COMPLETE) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
android.util.Log.d("MainActivity", "Restarting mesh services")
|
||||
meshService.stopServices()
|
||||
delay(1000)
|
||||
meshService.startServices()
|
||||
android.util.Log.d("MainActivity", "Mesh services restarted successfully")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Error restarting mesh services: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
@@ -556,18 +667,18 @@ class MainActivity : ComponentActivity() {
|
||||
// Cleanup location status manager
|
||||
try {
|
||||
locationStatusManager.cleanup()
|
||||
android.util.Log.d("MainActivity", "Location status manager cleaned up successfully")
|
||||
Log.d("MainActivity", "Location status manager cleaned up successfully")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||
}
|
||||
|
||||
// Stop mesh services if app was fully initialized
|
||||
if (onboardingState == OnboardingState.COMPLETE) {
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
try {
|
||||
meshService.stopServices()
|
||||
android.util.Log.d("MainActivity", "Mesh services stopped successfully")
|
||||
Log.d("MainActivity", "Mesh services stopped successfully")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
||||
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.bitchat.android
|
||||
|
||||
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
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
|
||||
private val _onboardingState = MutableStateFlow(OnboardingState.CHECKING)
|
||||
val onboardingState: StateFlow<OnboardingState> = _onboardingState.asStateFlow()
|
||||
|
||||
private val _bluetoothStatus = MutableStateFlow(BluetoothStatus.ENABLED)
|
||||
val bluetoothStatus: StateFlow<BluetoothStatus> = _bluetoothStatus.asStateFlow()
|
||||
|
||||
private val _locationStatus = MutableStateFlow(LocationStatus.ENABLED)
|
||||
val locationStatus: StateFlow<LocationStatus> = _locationStatus.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow("")
|
||||
val errorMessage: StateFlow<String> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _isBluetoothLoading = MutableStateFlow(false)
|
||||
val isBluetoothLoading: StateFlow<Boolean> = _isBluetoothLoading.asStateFlow()
|
||||
|
||||
private val _isLocationLoading = MutableStateFlow(false)
|
||||
val isLocationLoading: StateFlow<Boolean> = _isLocationLoading.asStateFlow()
|
||||
|
||||
private val _batteryOptimizationStatus = MutableStateFlow(BatteryOptimizationStatus.ENABLED)
|
||||
val batteryOptimizationStatus: StateFlow<BatteryOptimizationStatus> = _batteryOptimizationStatus.asStateFlow()
|
||||
|
||||
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
|
||||
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
|
||||
|
||||
// Public update functions for MainActivity
|
||||
fun updateOnboardingState(state: OnboardingState) {
|
||||
_onboardingState.value = state
|
||||
}
|
||||
|
||||
fun updateBluetoothStatus(status: BluetoothStatus) {
|
||||
_bluetoothStatus.value = status
|
||||
}
|
||||
|
||||
fun updateLocationStatus(status: LocationStatus) {
|
||||
_locationStatus.value = status
|
||||
}
|
||||
|
||||
fun updateErrorMessage(message: String) {
|
||||
_errorMessage.value = message
|
||||
}
|
||||
|
||||
fun updateBluetoothLoading(loading: Boolean) {
|
||||
_isBluetoothLoading.value = loading
|
||||
}
|
||||
|
||||
fun updateLocationLoading(loading: Boolean) {
|
||||
_isLocationLoading.value = loading
|
||||
}
|
||||
|
||||
fun updateBatteryOptimizationStatus(status: BatteryOptimizationStatus) {
|
||||
_batteryOptimizationStatus.value = status
|
||||
}
|
||||
|
||||
fun updateBatteryOptimizationLoading(loading: Boolean) {
|
||||
_isBatteryOptimizationLoading.value = loading
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.bitchat.android.core.ui.utils
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun Modifier.singleOrTripleClickable(
|
||||
onSingleClick: () -> Unit,
|
||||
onTripleClick: () -> Unit,
|
||||
clickTimeThreshold: Long = 300L
|
||||
): Modifier = composed {
|
||||
var tapCount by remember { mutableIntStateOf(0) }
|
||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
||||
var singleClickJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
this.clickable {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
if (currentTime - lastTapTime < clickTimeThreshold) {
|
||||
tapCount++
|
||||
} else {
|
||||
tapCount = 1
|
||||
}
|
||||
|
||||
lastTapTime = currentTime
|
||||
|
||||
// Cancel any pending single click action
|
||||
singleClickJob?.cancel()
|
||||
singleClickJob = null
|
||||
|
||||
when (tapCount) {
|
||||
1 -> {
|
||||
// Wait to see if more taps come
|
||||
singleClickJob = coroutineScope.launch {
|
||||
delay(clickTimeThreshold)
|
||||
if (tapCount == 1) {
|
||||
onSingleClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
// Triple click detected - execute immediately
|
||||
onTripleClick()
|
||||
tapCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Reset after threshold if no triple click
|
||||
if (tapCount > 3) {
|
||||
tapCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Intent>? = 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
enum class OnboardingState {
|
||||
CHECKING,
|
||||
BLUETOOTH_CHECK,
|
||||
LOCATION_CHECK,
|
||||
BATTERY_OPTIMIZATION_CHECK,
|
||||
PERMISSION_EXPLANATION,
|
||||
PERMISSION_REQUESTING,
|
||||
INITIALIZING,
|
||||
COMPLETE,
|
||||
ERROR
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
@@ -148,8 +149,7 @@ fun ChatHeaderContent(
|
||||
onShowAppInfo: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
var tripleClickCount by remember { mutableStateOf(0) }
|
||||
|
||||
|
||||
when {
|
||||
selectedPrivatePeer != null -> {
|
||||
// Private chat header - ensure state synchronization
|
||||
@@ -181,15 +181,8 @@ fun ChatHeaderContent(
|
||||
MainHeader(
|
||||
nickname = nickname,
|
||||
onNicknameChange = viewModel::setNickname,
|
||||
onTitleClick = {
|
||||
tripleClickCount++
|
||||
if (tripleClickCount >= 3) {
|
||||
tripleClickCount = 0
|
||||
onTripleClick()
|
||||
} else {
|
||||
onShowAppInfo()
|
||||
}
|
||||
},
|
||||
onTitleClick = onShowAppInfo,
|
||||
onTripleTitleClick = onTripleClick,
|
||||
onSidebarClick = onSidebarClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
@@ -345,6 +338,7 @@ private fun MainHeader(
|
||||
nickname: String,
|
||||
onNicknameChange: (String) -> Unit,
|
||||
onTitleClick: () -> Unit,
|
||||
onTripleTitleClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
@@ -360,12 +354,18 @@ private fun MainHeader(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat*",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier.clickable { onTitleClick() }
|
||||
modifier = Modifier.singleOrTripleClickable(
|
||||
onSingleClick = onTitleClick,
|
||||
onTripleClick = onTripleTitleClick
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
@@ -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
|
||||
@@ -46,7 +48,6 @@ import java.util.*
|
||||
* - DialogComponents: Password prompts and modals
|
||||
* - ChatUIUtils: Utility functions for formatting and colors
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -65,7 +66,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("") }
|
||||
@@ -86,7 +87,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
|
||||
// Use WindowInsets to handle keyboard properly
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val headerHeight = 36.dp
|
||||
|
||||
// Main content area that responds to keyboard/window insets
|
||||
@@ -100,32 +101,34 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
Spacer(modifier = Modifier.height(headerHeight))
|
||||
|
||||
// Messages area - takes up available space, will compress when keyboard appears
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshService,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshService,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// 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,
|
||||
@@ -192,11 +195,10 @@ 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<CommandSuggestion>,
|
||||
@@ -212,7 +214,7 @@ private fun ChatInputSection(
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column {
|
||||
Divider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
@@ -221,8 +223,8 @@ private fun ChatInputSection(
|
||||
onSuggestionClick = onSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Divider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
}
|
||||
|
||||
MessageInput(
|
||||
@@ -285,12 +287,12 @@ private fun ChatFloatingHeader(
|
||||
}
|
||||
|
||||
// Divider under header
|
||||
Divider(
|
||||
color = colorScheme.outline.copy(alpha = 0.3f),
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = headerHeight)
|
||||
.zIndex(1f)
|
||||
.zIndex(1f),
|
||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,12 @@ class ChatViewModel(
|
||||
application: Application,
|
||||
val meshService: BluetoothMeshService
|
||||
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
||||
|
||||
private val context: Context = application.applicationContext
|
||||
|
||||
|
||||
// State management
|
||||
private val state = ChatState()
|
||||
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(context)
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager)
|
||||
@@ -46,7 +44,7 @@ class ChatViewModel(
|
||||
privateChatManager = privateChatManager,
|
||||
notificationManager = notificationManager,
|
||||
coroutineScope = viewModelScope,
|
||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(context) },
|
||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||
getMyPeerID = { meshService.myPeerID }
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,10 +42,10 @@ fun MessagesList(
|
||||
}
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
SelectionContainer(modifier = modifier) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(messages) { message ->
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.R
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -16,11 +15,12 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
|
||||
/**
|
||||
* Sidebar components for ChatScreen
|
||||
@@ -73,8 +73,8 @@ fun SidebarOverlay(
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding
|
||||
) {
|
||||
SidebarHeader()
|
||||
|
||||
Divider()
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Scrollable content
|
||||
LazyColumn(
|
||||
@@ -101,7 +101,7 @@ fun SidebarOverlay(
|
||||
}
|
||||
|
||||
item {
|
||||
Divider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ private fun SidebarHeader() {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "YOUR NETWORK",
|
||||
text = stringResource(id = R.string.your_network).uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
@@ -175,7 +175,7 @@ fun ChannelsSection(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "CHANNELS",
|
||||
text = stringResource(id = R.string.channels).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
@@ -255,7 +255,7 @@ fun PeopleSection(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "PEOPLE",
|
||||
text = stringResource(id = R.string.people).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
@@ -264,7 +264,7 @@ fun PeopleSection(
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = "No one connected",
|
||||
text = stringResource(id = R.string.no_one_connected),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
|
||||
@@ -16,4 +16,24 @@
|
||||
<string name="online_users">Online Users</string>
|
||||
<string name="no_one_connected">No one connected</string>
|
||||
<string name="emergency_clear_hint">Triple tap to clear all data</string>
|
||||
<string name="your_network">Your Network</string>
|
||||
|
||||
<!-- Battery Optimization Strings -->
|
||||
<string name="battery_optimization_detected">Battery Optimization Detected</string>
|
||||
<string name="battery_optimization_disabled">Battery Optimization Disabled</string>
|
||||
<string name="battery_optimization_not_required">Battery Optimization Not Required</string>
|
||||
<string name="battery_optimization_checking">Checking Battery Optimization</string>
|
||||
<string name="battery_optimization_why_disable">Why disable battery optimization?</string>
|
||||
<string name="battery_optimization_explanation">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.</string>
|
||||
<string name="battery_optimization_disable_button">Disable Battery Optimization</string>
|
||||
<string name="battery_optimization_note">Note: You can change this setting later in Android Settings > Apps > bitchat > Battery</string>
|
||||
<string name="battery_optimization_not_supported_explanation">Your device doesn\'t require battery optimization settings. bitchat will run normally.</string>
|
||||
<string name="battery_optimization_not_supported_message">Your device doesn\'t require battery optimization settings. bitchat will run normally.</string>
|
||||
<string name="battery_optimization_success_message">bitchat can run reliably in the background</string>
|
||||
<string name="battery_optimization_benefits">• Ensures reliable message delivery\n• Maintains mesh network connectivity\n• Allows background message relay\n• Prevents connection drops</string>
|
||||
<string name="battery_optimization_check_again">Check Again</string>
|
||||
<string name="battery_optimization_skip">Skip for Now</string>
|
||||
<string name="battery_optimization_continue">Continue</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="skip">Skip</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user