diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d37638f06..96bdcf40c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,7 @@ android { } buildFeatures { compose = true + buildConfig = true } packaging { resources { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3cc22c28d..0e4cd04aa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,20 +8,24 @@ - + - + + + + + - + - + - + @@ -42,7 +46,6 @@ android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.BitchatAndroid" - android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize" android:launchMode="singleTop"> @@ -50,5 +53,13 @@ + + + + diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2e428a285..ff3c344e1 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -1,10 +1,12 @@ package com.bitchat.android +import android.content.ComponentName import android.content.Intent +import android.content.ServiceConnection import android.os.Bundle +import android.os.IBinder import android.util.Log import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize @@ -15,18 +17,20 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.lifecycleScope import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.Lifecycle -import com.bitchat.android.mesh.BluetoothMeshService -import com.bitchat.android.onboarding.BluetoothCheckScreen -import com.bitchat.android.onboarding.BluetoothStatus -import com.bitchat.android.onboarding.BluetoothStatusManager +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.bitchat.android.mesh.ForegroundService import com.bitchat.android.onboarding.BatteryOptimizationManager import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationStatus +import com.bitchat.android.onboarding.BluetoothCheckScreen +import com.bitchat.android.onboarding.BluetoothStatus +import com.bitchat.android.onboarding.BluetoothStatusManager import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.LocationCheckScreen @@ -43,32 +47,93 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - + + companion object { + private val TAG = MainActivity::class.simpleName + } + private lateinit var permissionManager: PermissionManager 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 + + // Core mesh service - now managed by a foreground service + @Volatile private var foregroundService: ForegroundService? = null + @Volatile private var isServiceBound = false + private val mainViewModel: MainViewModel by viewModels() - private val chatViewModel: ChatViewModel by viewModels { - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T + private val chatViewModel: ChatViewModel by viewModels { + viewModelFactory { + initializer { + ChatViewModel(application) } } } - + + private val serviceConnection = object : ServiceConnection, ForegroundService.ServiceListener { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as? ForegroundService.LocalBinder + if (binder != null) { + foregroundService = binder.getService() + binder.setServiceListener(this) + isServiceBound = true + Log.d(TAG, "ForegroundService connected.") + initializeApp() + } else { + Log.e(TAG, "Failed to cast binder. Service might not be the expected type.") + finish() // Can't work without the service + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + // This is called for UNEXPECTED disconnection (e.g., service crashes) + Log.w(TAG, "ForegroundService unexpectedly disconnected.") + foregroundService = null + isServiceBound = false + finish() + } + + override fun onServiceStopping() { + Log.w(TAG, "ForegroundService stopping") + finish() + } + } + + /** + * Starts the [ForegroundService] if it's not already running and then binds to it. + * This ensures that the service is running as a foreground service, which is crucial + * for its continuous operation, especially for tasks like Bluetooth mesh networking. + * Binding to the service allows the Activity to interact with it, for example, + * to get a reference to the MeshService instance or to listen for service events. + * + * The service is started first using `startForegroundService` to guarantee it transitions + * to a foreground state. Then, `bindService` is called to establish a connection. + * The `BIND_AUTO_CREATE` flag ensures that the service is created if it's not already running, + * though in this flow, `startForegroundService` typically handles the creation. + */ + private fun startAndBindService() { + // Always start the service first to ensure it's running as a foreground service. + val serviceIntent = Intent(this, ForegroundService::class.java) + if (!ForegroundService.isServiceRunning) { + Log.d(TAG, "Starting foreground service") + startForegroundService(serviceIntent) + } else { + Log.d(TAG, "Foreground service already running!") + } + // Bind to the service to get a reference to it. + if (!isServiceBound) { + Log.d(TAG, "Binding to foreground service") + bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -113,12 +178,27 @@ class MainActivity : ComponentActivity() { } } } - + + // Listen for background requests from the ViewModel + chatViewModel.backgroundRequest.observe(this) { shouldBackground -> + if (shouldBackground == true) { + finish() + } + } + + // Listen for shutdown requests from the ViewModel + chatViewModel.shutdownRequest.observe(this) { shouldShutdown -> + if (shouldShutdown == true) { + stopServiceAndExit() + } + } + // 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 @@ -222,26 +302,10 @@ class MainActivity : ComponentActivity() { OnboardingState.INITIALIZING -> { InitializingScreen() + startAndBindService() } - - OnboardingState.COMPLETE -> { - // Set up back navigation handling for the chat screen - val backCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // Let ChatViewModel handle navigation state - val handled = chatViewModel.handleBackPressed() - if (!handled) { - // If ChatViewModel doesn't handle it, disable this callback - // and let the system handle it (which will exit the app) - this.isEnabled = false - onBackPressedDispatcher.onBackPressed() - this.isEnabled = true - } - } - } - // Add the callback - this will be automatically removed when the activity is destroyed - onBackPressedDispatcher.addCallback(this, backCallback) + OnboardingState.COMPLETE -> { ChatScreen(viewModel = chatViewModel) } @@ -265,45 +329,45 @@ class MainActivity : ComponentActivity() { when (state) { OnboardingState.COMPLETE -> { // App is fully initialized, mesh service is running - android.util.Log.d("MainActivity", "Onboarding completed - app ready") + android.util.Log.d(TAG, "Onboarding completed - app ready") } OnboardingState.ERROR -> { - android.util.Log.e("MainActivity", "Onboarding error state reached") + android.util.Log.e(TAG, "Onboarding error state reached") } else -> {} } } - + private fun checkOnboardingStatus() { - Log.d("MainActivity", "Checking onboarding status") - + Log.d(TAG, "Checking onboarding status") + lifecycleScope.launch { // Small delay to show the checking state delay(500) - + // First check Bluetooth status (always required) checkBluetoothAndProceed() } } - + /** * Check Bluetooth status and proceed with onboarding flow */ private fun checkBluetoothAndProceed() { - // Log.d("MainActivity", "Checking Bluetooth status") - + // Log.d(TAG, "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()) { - Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions") + Log.d(TAG, "First-time launch, skipping Bluetooth check - will check after permissions") proceedWithPermissionCheck() return } - + // For existing users, check Bluetooth status first bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) - + when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next @@ -311,47 +375,47 @@ class MainActivity : ComponentActivity() { } BluetoothStatus.DISABLED -> { // Show Bluetooth enable screen (should have permissions as existing user) - Log.d("MainActivity", "Bluetooth disabled, showing enable screen") + Log.d(TAG, "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") + android.util.Log.e(TAG, "Bluetooth not supported") mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateBluetoothLoading(false) } } } - + /** - * Proceed with permission checking + * Proceed with permission checking */ private fun proceedWithPermissionCheck() { - Log.d("MainActivity", "Proceeding with permission check") - + Log.d(TAG, "Proceeding with permission check") + lifecycleScope.launch { delay(200) // Small delay for smooth transition - + if (permissionManager.isFirstTimeLaunch()) { - Log.d("MainActivity", "First time launch, showing permission explanation") + Log.d(TAG, "First time launch, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else if (permissionManager.areAllPermissionsGranted()) { - Log.d("MainActivity", "Existing user with permissions, initializing app") + Log.d(TAG, "Existing user with permissions, initializing app") mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) initializeApp() } else { - Log.d("MainActivity", "Existing user missing permissions, showing explanation") + Log.d(TAG, "Existing user missing permissions, showing explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } } } - + /** * Handle Bluetooth enabled callback */ private fun handleBluetoothEnabled() { - Log.d("MainActivity", "Bluetooth enabled by user") + Log.d(TAG, "Bluetooth enabled by user") mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED) checkLocationAndProceed() @@ -361,20 +425,20 @@ class MainActivity : ComponentActivity() { * Check Location services status and proceed with onboarding flow */ private fun checkLocationAndProceed() { - Log.d("MainActivity", "Checking location services status") - + Log.d(TAG, "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()) { - Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions") + Log.d(TAG, "First-time launch, skipping location check - will check after permissions") proceedWithPermissionCheck() return } - + // For existing users, check location status locationStatusManager.logLocationStatus() mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) - + when (mainViewModel.locationStatus.value) { LocationStatus.ENABLED -> { // Location services enabled, check battery optimization next @@ -382,13 +446,13 @@ class MainActivity : ComponentActivity() { } LocationStatus.DISABLED -> { // Show location enable screen (should have permissions as existing user) - Log.d("MainActivity", "Location services disabled, showing enable screen") + Log.d(TAG, "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) - Log.e("MainActivity", "Location services not available") + Log.e(TAG, "Location services not available") mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } @@ -399,7 +463,7 @@ class MainActivity : ComponentActivity() { * Handle Location enabled callback */ private fun handleLocationEnabled() { - Log.d("MainActivity", "Location services enabled by user") + Log.d(TAG, "Location services enabled by user") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(LocationStatus.ENABLED) checkBatteryOptimizationAndProceed() @@ -409,7 +473,7 @@ class MainActivity : ComponentActivity() { * Handle Location disabled callback */ private fun handleLocationDisabled(message: String) { - Log.w("MainActivity", "Location services disabled or failed: $message") + Log.w(TAG, "Location services disabled or failed: $message") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) @@ -425,15 +489,15 @@ class MainActivity : ComponentActivity() { } } } - + /** * Handle Bluetooth disabled callback */ private fun handleBluetoothDisabled(message: String) { - Log.w("MainActivity", "Bluetooth disabled or failed: $message") + Log.w(TAG, "Bluetooth disabled or failed: $message") mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) - + when { mainViewModel.bluetoothStatus.value == BluetoothStatus.NOT_SUPPORTED -> { // Show permanent error for unsupported devices @@ -443,12 +507,12 @@ class MainActivity : ComponentActivity() { 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 - Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation") + Log.d(TAG, "Bluetooth enable requires permissions, proceeding to permission explanation") proceedWithPermissionCheck() } message.contains("Permission") -> { // For existing users, redirect to permission explanation to grant missing permissions - Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation") + Log.d(TAG, "Bluetooth enable requires permissions, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else -> { @@ -457,10 +521,10 @@ class MainActivity : ComponentActivity() { } } } - + private fun handleOnboardingComplete() { - Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app") - + Log.d(TAG, "Onboarding completed, checking Bluetooth and Location before initializing app") + // After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentLocationStatus = locationStatusManager.checkLocationStatus() @@ -469,58 +533,58 @@ class MainActivity : ComponentActivity() { batteryOptimizationManager.isBatteryOptimizationDisabled() -> BatteryOptimizationStatus.DISABLED else -> BatteryOptimizationStatus.ENABLED } - + when { currentBluetoothStatus != BluetoothStatus.ENABLED -> { // Bluetooth still disabled, but now we have permissions to enable it - Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") + Log.d(TAG, "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 - Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.") + Log.d(TAG, "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.") + android.util.Log.d(TAG, "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.") mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateBatteryOptimizationLoading(false) } else -> { // Both are enabled, proceed to app initialization - Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization") + Log.d(TAG, "Both Bluetooth and Location services are enabled, proceeding to initialization") mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) initializeApp() } } } - + private fun handleOnboardingFailed(message: String) { - Log.e("MainActivity", "Onboarding failed: $message") + Log.e(TAG, "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") - + android.util.Log.d(TAG, "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") + android.util.Log.d(TAG, "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 { @@ -529,7 +593,7 @@ class MainActivity : ComponentActivity() { else -> BatteryOptimizationStatus.ENABLED } mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) - + when (currentBatteryOptimizationStatus) { BatteryOptimizationStatus.DISABLED, BatteryOptimizationStatus.NOT_SUPPORTED -> { // Battery optimization is disabled or not supported, proceed with permission check @@ -537,28 +601,28 @@ class MainActivity : ComponentActivity() { } BatteryOptimizationStatus.ENABLED -> { // Show battery optimization disable screen - android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen") + Log.d(TAG, "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") + Log.d(TAG, "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") + android.util.Log.w(TAG, "Battery optimization disable failed: $message") mainViewModel.updateBatteryOptimizationLoading(false) val currentStatus = when { !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED @@ -566,50 +630,49 @@ class MainActivity : ComponentActivity() { else -> BatteryOptimizationStatus.ENABLED } mainViewModel.updateBatteryOptimizationStatus(currentStatus) - + // Stay on battery optimization check screen for retry mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) } - + private fun initializeApp() { - Log.d("MainActivity", "Starting app initialization") - + Log.d(TAG, "Starting app initialization") + lifecycleScope.launch { try { // Initialize the app with a proper delay to ensure Bluetooth stack is ready // This solves the issue where app needs restart to work on first install delay(1000) // Give the system time to process permission grants - - Log.d("MainActivity", "Permissions verified, initializing chat system") - + + Log.d(TAG, "Permissions verified, initializing chat system") + // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { val missing = permissionManager.getMissingPermissions() - Log.w("MainActivity", "Permissions revoked during initialization: $missing") + Log.w(TAG, "Permissions revoked during initialization: $missing") handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.") return@launch } // Set up mesh service delegate and start services - meshService.delegate = chatViewModel - meshService.startServices() - - Log.d("MainActivity", "Mesh service started successfully") - + foregroundService?.getMeshService()?.let { chatViewModel.initialize(it) } + + Log.d(TAG, "Mesh service started successfully") + // Handle any notification intent handleNotificationIntent(intent) - + // Small delay to ensure mesh service is fully initialized delay(500) - Log.d("MainActivity", "App initialization complete") + Log.d(TAG, "App initialization complete") mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) } catch (e: Exception) { - Log.e("MainActivity", "Failed to initialize app", e) + Log.e(TAG, "Failed to initialize app", e) handleOnboardingFailed("Failed to initialize the app: ${e.message}") } } } - + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running @@ -617,91 +680,97 @@ class MainActivity : ComponentActivity() { handleNotificationIntent(intent) } } - + override fun onResume() { super.onResume() + chatViewModel.setAppBackgroundState(inBackground = false) // Check Bluetooth and Location status on resume and handle accordingly 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) { - Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") + Log.w(TAG, "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) { - Log.w("MainActivity", "Location services disabled while app was backgrounded") + Log.w(TAG, "Location services disabled while app was backgrounded") mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } + + startAndBindService() } } override fun onPause() { super.onPause() - // Only set background state if app is fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app background state - meshService.connectionManager.setAppBackgroundState(true) - chatViewModel.setAppBackgroundState(true) + chatViewModel.setAppBackgroundState(inBackground = true) + // Only unbind if the service is actually bound + if (isServiceBound) { + unbindService(serviceConnection) + isServiceBound = false + Log.d(TAG, "Service unbound in onPause") } } - + /** * Handle intents from notification clicks - open specific private chat */ private fun handleNotificationIntent(intent: Intent) { val shouldOpenPrivateChat = intent.getBooleanExtra( - com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, + com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, false ) - + if (shouldOpenPrivateChat) { val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID) val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME) - + if (peerID != null) { - Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") - + Log.d(TAG, "Opening private chat with $senderNickname (peerID: $peerID) from notification") + // Open the private chat with this peer chatViewModel.startPrivateChat(peerID) - + // Clear notifications for this sender since user is now viewing the chat chatViewModel.clearNotificationsForSender(peerID) } } } + /** + * Triggers the foreground service to stop itself. The service will then + * call onServiceStopping(), which will finish the activity. + */ + private fun stopServiceAndExit() { + Log.d(TAG, "User requested shutdown. Stopping service and exiting.") + foregroundService?.shutdownService() + finish() + } override fun onDestroy() { super.onDestroy() - // Cleanup location status manager try { locationStatusManager.cleanup() - Log.d("MainActivity", "Location status manager cleaned up successfully") + Log.d(TAG, "Location status manager cleaned up successfully") } catch (e: Exception) { - Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") + Log.w(TAG, "Error cleaning up location status manager: ${e.message}") } - - // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - try { - meshService.stopServices() - Log.d("MainActivity", "Mesh services stopped successfully") - } catch (e: Exception) { - Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") - } + } + + @Deprecated("Deprecated") + override fun onBackPressed() { + val handled = chatViewModel.handleBackPressed() + if (!handled) { + super.onBackPressed() } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshServiceDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshServiceDelegate.kt new file mode 100644 index 000000000..ddc503acf --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshServiceDelegate.kt @@ -0,0 +1,15 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatMessage + +/** + * A dedicated delegate for providing real-time state updates from the + * BluetoothMeshService to the ForegroundService notification. + */ +interface MeshServiceStateDelegate { + fun onMeshStateUpdated( + peerCount: Int, + unreadCount: Int, + recentMessages: List + ) +} diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt new file mode 100644 index 000000000..62db01618 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -0,0 +1,313 @@ +package com.bitchat.android.mesh + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.os.Binder +import android.os.IBinder +import android.util.Log +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.bitchat.android.R +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryAck +import com.bitchat.android.model.ReadReceipt +import com.bitchat.android.ui.theme.DarkColorScheme +import com.bitchat.android.ui.theme.LightColorScheme +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +/** + * A foreground service that provides a standard, live-updating Android notification + * with peer and message counts. + */ +class ForegroundService : Service(), BluetoothMeshDelegate { + + private val binder = LocalBinder() + private var meshService: BluetoothMeshService? = null + private lateinit var notificationManager: NotificationManager + private var serviceListener: ServiceListener? = null + + // --- Live State for Notification UI --- + private var activePeers = listOf() + private var unreadMessageCount = 0 + private val knownPeerIds = HashSet() // Used to detect new peers + + // Scheduler for periodic UI refreshes + private lateinit var uiUpdateScheduler: ScheduledExecutorService + + companion object { + private const val TAG = "MeshForegroundService" + private const val NOTIFICATION_ID = 1 + private const val FOREGROUND_CHANNEL_ID = "com.bitchat.android.FOREGROUND_SERVICE" + private const val MOCK_PEERS_ENABLED = false + + const val ACTION_RESET_UNREAD_COUNT = "com.bitchat.android.ACTION_RESET_UNREAD_COUNT" + const val ACTION_SHUTDOWN = "com.bitchat.android.ACTION_SHUTDOWN" + + @Volatile + var isServiceRunning = false + private set + } + + // --- Service Lifecycle & Setup --- + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Foreground Service onCreate") + isServiceRunning = true + notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + if (meshService == null) { + meshService = BluetoothMeshService(this).apply { + delegate = this@ForegroundService + } + } + + val intentFilter = IntentFilter().apply { + addAction(ACTION_SHUTDOWN) + } + ContextCompat.registerReceiver(this, notificationActionReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Reset unread count if the user tapped the notification to open the app + if (intent?.action == ACTION_RESET_UNREAD_COUNT) { + unreadMessageCount = 0 + } + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildNotification(false)) + startUiUpdater() + meshService?.startServices() + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + isServiceRunning = false + uiUpdateScheduler.shutdown() + unregisterReceiver(notificationActionReceiver) + meshService?.stopServices() + meshService = null + } + + override fun onBind(intent: Intent): IBinder = binder + + fun getMeshService(): BluetoothMeshService? = meshService + + // --- BluetoothMeshDelegate Implementation --- + + override fun didReceiveMessage(message: BitchatMessage) { + Log.d(TAG, "didReceiveMessage: '${message.content}' from ${message.sender}") + unreadMessageCount++ + updateNotification(false) + } + + override fun didUpdatePeerList(peers: List) { + updateNotification(false) + } + + override fun didConnectToPeer(peerID: String) { + if (knownPeerIds.add(peerID)) { + Log.i(TAG, "New peer connected: $peerID. Triggering alert.") + updateNotification(true) + } else { + updateNotification(false) + } + } + + override fun didDisconnectFromPeer(peerID: String) { + knownPeerIds.remove(peerID) + updateNotification(false) + } + + override fun didReceiveDeliveryAck(ack: DeliveryAck) {} + override fun didReceiveReadReceipt(receipt: ReadReceipt) {} + override fun didReceiveChannelLeave(channel: String, fromPeer: String) {} + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? = null + override fun getNickname(): String? = "bitchat_user" + override fun isFavorite(peerID: String): Boolean = false + + // --- Notification Building & Logic --- + + /** + * Creates a list of mock peers for debugging purposes. + * Proximity is randomized on each call to simulate changing conditions. + */ + private fun getMockPeers(): List { + val allMockPeers = listOf( + PeerInfo(id = "mock_1", nickname = "debugger_dan", proximity = Random.nextInt(0, 5)), + PeerInfo(id = "mock_2", nickname = "test_tanya", proximity = Random.nextInt(0, 5)), + PeerInfo(id = "mock_3", nickname = "fake_fred", proximity = Random.nextInt(0, 5)), + PeerInfo(id = "mock_4", nickname = "staging_sue", proximity = Random.nextInt(0, 5)), + PeerInfo(id = "mock_5", nickname = "dev_dave", proximity = Random.nextInt(0, 5)) + ) + + // Determine a random number of users to show (between 3 and 5) + val numToShow = Random.nextInt(3, 6) // Generates a number from 3 to 5 + + // Shuffle the list and take a random number of peers + return allMockPeers.shuffled().take(numToShow).sortedByDescending { it.proximity } + } + + + private fun updateNotification(alert: Boolean) { + // When MOCK_PEERS_ENABLED, override real data with a mock user list. + // This allows for easy UI testing without requiring physical peer devices. + if (MOCK_PEERS_ENABLED) { + activePeers = getMockPeers() + // Set a mock message count for a more realistic debug notification. + unreadMessageCount = 7 + } else { + val nicknames = meshService?.getPeerNicknames() ?: emptyMap() + val rssiValues = meshService?.getPeerRSSI() ?: emptyMap() + + activePeers = nicknames.map { (peerId, nickname) -> + val rssi = rssiValues[peerId] ?: -100 + PeerInfo(id = peerId, nickname = nickname, proximity = getProximityFromRssi(rssi)) + }.sortedByDescending { it.proximity } + } + notificationManager.notify(NOTIFICATION_ID, buildNotification(alert)) + } + + + /** + * Builds a standard, live-updating Android notification. + * Collapsed: Shows peer and unread message counts. + * Expanded: Shows a list of nearby peers and their proximity. + */ + private fun buildNotification(alert: Boolean): Notification { + val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme + + val peerCount = activePeers.size + val contentText = getString(R.string.notification_scanning) + val contentTitle = resources.getQuantityString(R.plurals.peers_nearby, peerCount, peerCount) + val summaryText = resources.getQuantityString(R.plurals.unread_messages, unreadMessageCount, unreadMessageCount) + + // Expanded view style using InboxStyle + val inboxStyle = NotificationCompat.InboxStyle() + .setBigContentTitle(contentTitle) + .setSummaryText(summaryText) + + // Add each peer to the expanded view + if (activePeers.isNotEmpty()) { + activePeers.forEach { peer -> + val proximityBars = "◼".repeat(peer.proximity) + "◻".repeat(4 - peer.proximity) + inboxStyle.addLine("${peer.nickname} $proximityBars") + } + } else { + inboxStyle.addLine(contentText) + } + + val builder = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(colors.primary.toArgb()) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setNumber(unreadMessageCount) + .setStyle(inboxStyle) + .setContentIntent(createMainPendingIntent()) + .setOngoing(true) + .addAction( + 0, + getString( + R.string.notification_action_shutdown), + createActionPendingIntent(ACTION_SHUTDOWN)) + + if (alert) { + builder.setOnlyAlertOnce(false) + builder.setDefaults(Notification.DEFAULT_ALL) + } else { + builder.setOnlyAlertOnce(true) + } + + return builder.build() + } + + // --- Helper Functions --- + + private fun getProximityFromRssi(rssi: Int): Int { + return when { + rssi > -60 -> 4 // Excellent + rssi > -70 -> 3 // Good + rssi > -80 -> 2 // Fair + rssi > -95 -> 1 // Weak + else -> 0 // Very weak + } + } + + private fun startUiUpdater() { + if (::uiUpdateScheduler.isInitialized && !uiUpdateScheduler.isShutdown) return + uiUpdateScheduler = Executors.newSingleThreadScheduledExecutor() + uiUpdateScheduler.scheduleWithFixedDelay({ + updateNotification(false) + }, 0, 5000L, TimeUnit.MILLISECONDS) // Update every 5 seconds + } + + // --- Boilerplate --- + + inner class LocalBinder : Binder() { + fun getService(): ForegroundService = this@ForegroundService + fun setServiceListener(listener: ServiceListener?) { + this@ForegroundService.serviceListener = listener + } + } + + interface ServiceListener { + fun onServiceStopping() + } + + private val notificationActionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_SHUTDOWN -> shutdownService() + } + } + } + + internal fun shutdownService() { + Log.i(TAG, "Shutdown action triggered. Stopping service.") + serviceListener?.onServiceStopping() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + + + private fun createNotificationChannel() { + val channelName = getString(R.string.notification_channel_name) + val channelDescription = getString(R.string.notification_channel_description) + val serviceChannel = NotificationChannel( + FOREGROUND_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = channelDescription + setShowBadge(false) + enableVibration(false) + setSound(null, null) + } + notificationManager.createNotificationChannel(serviceChannel) + } + + private fun createMainPendingIntent(): PendingIntent { + val intent = packageManager.getLaunchIntentForPackage(packageName) + // Add a way to reset unread count when user opens the app + intent?.action = ACTION_RESET_UNREAD_COUNT + return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun createActionPendingIntent(action: String): PendingIntent { + val intent = Intent(action).apply { `package` = packageName } + return PendingIntent.getBroadcast(this, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerInfo.kt b/app/src/main/java/com/bitchat/android/mesh/PeerInfo.kt new file mode 100644 index 000000000..8a4bd5def --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PeerInfo.kt @@ -0,0 +1,4 @@ +package com.bitchat.android.mesh + +// Data class to hold combined peer information for the UI +data class PeerInfo(val id: String, val nickname: String, val proximity: Int) \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1b0ceaa34..0496d0236 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* @@ -11,8 +13,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex /** @@ -44,7 +46,7 @@ fun ChatScreen(viewModel: ChatViewModel) { val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) val showAppInfo by viewModel.showAppInfo.observeAsState(false) - + val showExitDialog by viewModel.showExitDialog.observeAsState(false) var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } @@ -199,6 +201,14 @@ fun ChatScreen(viewModel: ChatViewModel) { showAppInfo = showAppInfo, onAppInfoDismiss = { viewModel.hideAppInfo() } ) + + // Exit confirmation dialog + ExitConfirmationDialog( + show = showExitDialog, + onDismiss = { viewModel.dismissExitConfirmation() }, + onConfirmExit = { viewModel.requestShutdown() }, + onConfirmBackground = { viewModel.requestBackground() } + ) } @Composable diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index bbc55dfe8..9fd8cac2b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow /** * Centralized state definitions and data classes for the chat system @@ -109,7 +111,19 @@ class ChatState { // Navigation state private val _showAppInfo = MutableLiveData(false) val showAppInfo: LiveData = _showAppInfo - + + // New LiveData to control the exit confirmation dialog + private val _showExitDialog = MutableLiveData(false) + val showExitDialog: LiveData = _showExitDialog + + // New LiveData for a shutdown request to the Activity + private val _shutdownRequest = MutableLiveData(false) + val shutdownRequest = _shutdownRequest + + // New LiveData for a background request to the Activity + private val _backgroundRequest = MutableLiveData(false) + val backgroundRequest = _backgroundRequest + // Unread state computed properties val hasUnreadChannels: MediatorLiveData = MediatorLiveData() val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() @@ -148,7 +162,9 @@ class ChatState { fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() fun getShowAppInfoValue() = _showAppInfo.value ?: false - + fun getShowExitDialogValue() = _showExitDialog.value ?: false + fun getShutdownRequestValue() = _shutdownRequest.value ?: false + // Setters for state updates fun setMessages(messages: List) { _messages.value = messages @@ -260,4 +276,16 @@ class ChatState { _showAppInfo.value = show } + fun setShowExitDialog(show: Boolean) { + _showExitDialog.value = show + } + + fun setShutdownRequest() { + _shutdownRequest.value = true + } + + fun setBackgroundRequest() { + _backgroundRequest.value = true + } + } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 8b941bbb5..eb533d974 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1,7 +1,6 @@ package com.bitchat.android.ui import android.app.Application -import android.content.Context import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData @@ -11,9 +10,9 @@ import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryAck import com.bitchat.android.model.ReadReceipt -import kotlinx.coroutines.launch import kotlinx.coroutines.delay -import java.util.* +import kotlinx.coroutines.launch +import java.util.Date import kotlin.random.Random /** @@ -21,14 +20,16 @@ import kotlin.random.Random * Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility */ class ChatViewModel( - application: Application, - val meshService: BluetoothMeshService -) : AndroidViewModel(application), BluetoothMeshDelegate { + application: Application +) : AndroidViewModel(application) { companion object { private const val TAG = "ChatViewModel" } + lateinit var meshService: BluetoothMeshService + private set + // State management private val state = ChatState() @@ -49,20 +50,7 @@ class ChatViewModel( val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager(application.applicationContext) - - // Delegate handler for mesh callbacks - private val meshDelegateHandler = MeshDelegateHandler( - state = state, - messageManager = messageManager, - channelManager = channelManager, - privateChatManager = privateChatManager, - notificationManager = notificationManager, - coroutineScope = viewModelScope, - onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, - getMyPeerID = { meshService.myPeerID }, - getMeshService = { meshService } - ) - + // Expose state through LiveData (maintaining the same interface) val messages: LiveData> = state.messages val connectedPeers: LiveData> = state.connectedPeers @@ -91,12 +79,35 @@ class ChatViewModel( val peerNicknames: LiveData> = state.peerNicknames val peerRSSI: LiveData> = state.peerRSSI val showAppInfo: LiveData = state.showAppInfo - - init { - // Note: Mesh service delegate is now set by MainActivity + val showExitDialog: LiveData = state.showExitDialog + val shutdownRequest: LiveData = state.shutdownRequest + val backgroundRequest: LiveData = state.backgroundRequest + + /** + * Requests a full shutdown of the service and app. + * Called from the header menu or the exit confirmation dialog. + */ + fun requestShutdown() { + state.setShutdownRequest() + } + + fun requestBackground() { + state.setBackgroundRequest() + } + + /** + * Dismisses the exit confirmation dialog. + */ + fun dismissExitConfirmation() { + state.setShowExitDialog(false) + } + + fun initialize(meshService: BluetoothMeshService) { + Log.d(TAG, "Initializing ChatViewModel") + this.meshService = meshService loadAndInitialize() } - + private fun loadAndInitialize() { // Load nickname val nickname = dataManager.loadNickname() @@ -127,9 +138,7 @@ class ChatViewModel( // Initialize session state monitoring initializeSessionStateMonitoring() - - // Note: Mesh service is now started by MainActivity - + // Show welcome message if no peers after delay viewModelScope.launch { delay(10000) @@ -334,7 +343,6 @@ class ChatViewModel( // Note: Mesh service restart is now handled by MainActivity // This function is no longer needed - fun setAppBackgroundState(inBackground: Boolean) { // Forward to notification manager for notification logic notificationManager.setAppBackgroundState(inBackground) @@ -491,6 +499,14 @@ class ChatViewModel( fun hideSidebar() { state.setShowSidebar(false) } + + fun showExitDialog() { + state.setShowExitDialog(true) + } + + fun shutdown() { + state.setShutdownRequest() + } /** * Handle Android back navigation @@ -525,7 +541,10 @@ class ChatViewModel( true } // No special navigation state - let system handle (usually exits app) - else -> false + else -> { + showExitDialog() + true + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt b/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt index 05309d203..ab50a9a57 100644 --- a/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp /** @@ -24,7 +25,7 @@ fun PasswordPromptDialog( ) { if (show && channelName != null) { val colorScheme = MaterialTheme.colorScheme - + AlertDialog( onDismissRequest = onDismiss, title = { @@ -37,12 +38,12 @@ fun PasswordPromptDialog( text = { Column { Text( - text = "Channel $channelName is password protected. Enter the password to join.", + text = "Channel #$channelName is password protected. Enter the password to join.", style = MaterialTheme.typography.bodyMedium, color = colorScheme.onSurface ) Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( value = passwordInput, onValueChange = onPasswordChange, @@ -61,7 +62,7 @@ fun PasswordPromptDialog( TextButton(onClick = onConfirm) { Text( text = "Join", - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelMedium, color = colorScheme.primary ) } @@ -70,7 +71,7 @@ fun PasswordPromptDialog( TextButton(onClick = onDismiss) { Text( text = "Cancel", - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelMedium, color = colorScheme.onSurface ) } @@ -88,7 +89,7 @@ fun AppInfoDialog( ) { if (show) { val colorScheme = MaterialTheme.colorScheme - + AlertDialog( onDismissRequest = onDismiss, title = { @@ -114,7 +115,65 @@ fun AppInfoDialog( TextButton(onClick = onDismiss) { Text( text = "OK", - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelMedium, + color = colorScheme.primary + ) + } + }, + containerColor = colorScheme.surface, + tonalElevation = 8.dp + ) + } +} + + +/** + * A themed dialog to confirm if the user wants to shut down the service or background the app. + * @param show Controls the visibility of the dialog. + * @param onDismiss Called when the user taps outside the dialog. + * @param onConfirmExit Called when the user confirms they want to shut down the service. + * @param onConfirmBackground Called when the user chooses to background the app. + */ +@Composable +fun ExitConfirmationDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirmExit: () -> Unit, + onConfirmBackground: () -> Unit +) { + if (show) { + val colorScheme = MaterialTheme.colorScheme + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Shut down Bitchat?", + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurface + ) + }, + text = { + Text( + text = "Do you want to shut down Bitchat or keep scanning in the background?", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton(onClick = onConfirmExit) { + Text( + "Shut Down", + style = MaterialTheme.typography.labelMedium, + color = colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onConfirmBackground) { + Text( + "Background", + style = MaterialTheme.typography.labelMedium, color = colorScheme.primary ) } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index bd6fc793d..b0dfaa8b1 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color // Colors that match the iOS bitchat theme -private val DarkColorScheme = darkColorScheme( +val DarkColorScheme = darkColorScheme( primary = Color(0xFF39FF14), // Bright green (terminal-like) onPrimary = Color.Black, secondary = Color(0xFF2ECB10), // Darker green @@ -21,7 +21,7 @@ private val DarkColorScheme = darkColorScheme( onError = Color.Black ) -private val LightColorScheme = lightColorScheme( +val LightColorScheme = lightColorScheme( primary = Color(0xFF008000), // Dark green onPrimary = Color.White, secondary = Color(0xFF006600), // Even darker green diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 783120f28..7b9655b93 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,4 +37,22 @@ Continue Retry Skip + + + + %d peer nearby + %d peers nearby + + + + %d unread + %d unread + + + Scanning for nearby peers… + Shutdown + + Bitchat Active Service + Keeps Bitchat connected and shows live status +