mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-26 01:25:22 +00:00
Merge branch 'main' into noise
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user