From 7fbd69b7e942506dddf46fa35d14712d8c1fd2e5 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Sat, 2 Aug 2025 13:43:37 -0700 Subject: [PATCH 01/13] Themed and rigged up rich foreground notifications --- app/src/main/AndroidManifest.xml | 15 +- .../java/com/bitchat/android/MainActivity.kt | 351 ++++++++--------- .../mesh/BluetoothMeshServiceDelegate.kt | 15 + .../bitchat/android/mesh/ForegroundService.kt | 354 ++++++++++++++++++ .../com/bitchat/android/ui/ChatViewModel.kt | 22 +- .../com/bitchat/android/ui/theme/Theme.kt | 4 +- .../bitchat/android/util/ProximityDrawable.kt | 60 +++ app/src/main/res/drawable/close_24px.xml | 10 + .../res/drawable/notification_bg_terminal.xml | 21 ++ .../drawable/notifications_active_24px.xml | 10 + .../res/drawable/notifications_off_24px.xml | 10 + app/src/main/res/drawable/reply_24px.xml | 11 + .../res/layout/notification_line_item.xml | 15 + .../notification_peer_item_terminal.xml | 29 ++ .../notification_terminal_collapsed.xml | 70 ++++ .../layout/notification_terminal_expanded.xml | 95 +++++ 16 files changed, 910 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/BluetoothMeshServiceDelegate.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt create mode 100644 app/src/main/res/drawable/close_24px.xml create mode 100644 app/src/main/res/drawable/notification_bg_terminal.xml create mode 100644 app/src/main/res/drawable/notifications_active_24px.xml create mode 100644 app/src/main/res/drawable/notifications_off_24px.xml create mode 100644 app/src/main/res/drawable/reply_24px.xml create mode 100644 app/src/main/res/layout/notification_line_item.xml create mode 100644 app/src/main/res/layout/notification_peer_item_terminal.xml create mode 100644 app/src/main/res/layout/notification_terminal_collapsed.xml create mode 100644 app/src/main/res/layout/notification_terminal_expanded.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3cc22c28d..b99cb2d26 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,11 @@ - + + + + + @@ -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 bfe57ca51..4866fa297 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 @@ -14,17 +16,18 @@ 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.Lifecycle import androidx.lifecycle.lifecycleScope 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 @@ -41,33 +44,62 @@ 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() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Initialize core mesh service first - meshService = BluetoothMeshService(this) - + // Initialize permission management permissionManager = PermissionManager(this) bluetoothStatusManager = BluetoothStatusManager( @@ -94,7 +126,7 @@ class MainActivity : ComponentActivity() { onOnboardingComplete = ::handleOnboardingComplete, onOnboardingFailed = ::handleOnboardingFailed ) - + setContent { BitchatTheme { Surface( @@ -105,7 +137,7 @@ class MainActivity : ComponentActivity() { } } } - + // Collect state changes in a lifecycle-aware manner lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -114,14 +146,14 @@ class MainActivity : ComponentActivity() { } } } - + // 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() @@ -132,12 +164,12 @@ class MainActivity : ComponentActivity() { val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState() val isLocationLoading by mainViewModel.isLocationLoading.collectAsState() val isBatteryOptimizationLoading by mainViewModel.isBatteryOptimizationLoading.collectAsState() - + when (onboardingState) { OnboardingState.CHECKING -> { InitializingScreen() } - + OnboardingState.BLUETOOTH_CHECK -> { BluetoothCheckScreen( status = bluetoothStatus, @@ -151,7 +183,7 @@ class MainActivity : ComponentActivity() { isLoading = isBluetoothLoading ) } - + OnboardingState.LOCATION_CHECK -> { LocationCheckScreen( status = locationStatus, @@ -165,7 +197,7 @@ class MainActivity : ComponentActivity() { isLoading = isLocationLoading ) } - + OnboardingState.BATTERY_OPTIMIZATION_CHECK -> { BatteryOptimizationScreen( status = batteryOptimizationStatus, @@ -183,7 +215,7 @@ class MainActivity : ComponentActivity() { isLoading = isBatteryOptimizationLoading ) } - + OnboardingState.PERMISSION_EXPLANATION -> { PermissionExplanationScreen( permissionCategories = permissionManager.getCategorizedPermissions(), @@ -193,37 +225,20 @@ class MainActivity : ComponentActivity() { } ) } - + OnboardingState.PERMISSION_REQUESTING -> { InitializingScreen() } - + 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) - ChatScreen(viewModel = chatViewModel) } - + OnboardingState.ERROR -> { InitializationErrorScreen( errorMessage = errorMessage, @@ -238,51 +253,51 @@ 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") + Log.d(TAG, "Onboarding completed - app ready") } OnboardingState.ERROR -> { - android.util.Log.e("MainActivity", "Onboarding error state reached") + 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 @@ -290,47 +305,46 @@ 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") + 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() @@ -340,20 +354,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 @@ -361,13 +375,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) } @@ -378,7 +392,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() @@ -388,7 +402,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()) @@ -404,15 +418,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 @@ -422,12 +436,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 -> { @@ -436,10 +450,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() @@ -448,58 +462,57 @@ 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.") + 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") - + 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") + 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 { @@ -508,7 +521,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 @@ -516,28 +529,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") + Log.w(TAG, "Battery optimization disable failed: $message") mainViewModel.updateBatteryOptimizationLoading(false) val currentStatus = when { !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED @@ -545,50 +558,55 @@ 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") - - // Handle any notification intent - handleNotificationIntent(intent) - + + foregroundService?.getMeshService()?.let { chatViewModel.initialize(it) } + // 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}") } } } - + + 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) { + startForegroundService(serviceIntent) + } + // Bind to the service to get a reference to it. + if (!isServiceBound) { + bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE) + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running @@ -596,65 +614,62 @@ class MainActivity : ComponentActivity() { handleNotificationIntent(intent) } } - + override fun onResume() { super.onResume() // 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) + // 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) } @@ -663,23 +678,21 @@ class MainActivity : ComponentActivity() { 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..0319a521b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -0,0 +1,354 @@ +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.os.Vibrator +import android.util.Log +import android.widget.RemoteViews +import androidx.compose.ui.graphics.Color +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 + +// Data class to hold combined peer information for the UI +data class PeerInfo(val id: String, val nickname: String, val proximity: Int) + +/** + * A foreground service that provides a rich, interactive, and theme-consistent notification + * in the style of a monochrome IRC/terminal client. It displays nearby peers with proximity + * and a live log of recent messages by acting as a delegate for BluetoothMeshService. + */ +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 unreadMessagesCount = 0 + private var recentMessages = mutableListOf() + private val knownPeerIds = HashSet() // Used to detect new peers + + // Scheduler for periodic UI refreshes (e.g., timestamps) + private lateinit var uiUpdateScheduler: ScheduledExecutorService + + companion object { + private const val TAG = "MeshForegroundService" + private const val NOTIFICATION_ID = 1 + private const val FOREGROUND_CHANNEL_ID = "bitchat_foreground_service" + const val ACTION_STOP_SERVICE = "com.bitchat.android.ACTION_STOP_SERVICE" + const val ACTION_MUTE = "com.bitchat.android.ACTION_MUTE" + + @Volatile + var isServiceRunning = false + private set + } + + // --- Service Lifecycle & Setup --- + + override fun onCreate() { + super.onCreate() + isServiceRunning = true + notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + // Initialize the mesh service and set this class as its delegate + if (meshService == null) { + meshService = BluetoothMeshService(this).apply { + delegate = this@ForegroundService + } + } + + val intentFilter = IntentFilter().apply { + addAction(ACTION_STOP_SERVICE) + addAction(ACTION_MUTE) + } + // For Android 14+, must specify receiver exportability + ContextCompat.registerReceiver(this, notificationActionReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildNotification(false)) // Initial notification is silent + 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 { + return binder + } + + fun getMeshService(): BluetoothMeshService? { + return meshService + } + + // --- BluetoothMeshDelegate Implementation --- + + override fun didReceiveMessage(message: BitchatMessage) { + Log.d(TAG, "didReceiveMessage: '${message.content}' from ${message.sender}") + // Add message to the log and trim if it gets too long + recentMessages.add(0, message) + if (recentMessages.size > 10) { + recentMessages = recentMessages.take(10).toMutableList() + } + updateNotification(false) // New messages don't need to alert + } + + override fun didUpdatePeerList(peers: List) { + updateNotification(false) + } + + override fun didConnectToPeer(peerID: String) { + // Check if this is a genuinely new peer + if (knownPeerIds.add(peerID)) { + Log.i(TAG, "New peer connected: $peerID. Triggering alert.") + // Trigger an alerting notification for the new peer + updateNotification(true) + } else { + // Peer reconnected, just do a silent update + updateNotification(false) + } + } + + override fun didDisconnectFromPeer(peerID: String) { + knownPeerIds.remove(peerID) + updateNotification(false) + } + + // Other delegate methods can trigger a UI update if needed + override fun didReceiveDeliveryAck(ack: DeliveryAck) { /* Can update UI later */ } + override fun didReceiveReadReceipt(receipt: ReadReceipt) { /* Can update UI later */ } + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { /* Can update UI later */ } + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? = null + override fun getNickname(): String? = "bitchat_user" // Provide a default or fetch from settings + override fun isFavorite(peerID: String): Boolean = false + + // --- Notification Building & Logic --- + + private fun updateNotification(alert: Boolean) { + // Fetch real-time data from the mesh service + val nicknames = meshService?.getPeerNicknames() ?: emptyMap() + val rssiValues = meshService?.getPeerRSSI() ?: emptyMap() + + // Combine the data into a list of PeerInfo objects for the UI + activePeers = nicknames.map { (peerId, nickname) -> + val rssi = rssiValues[peerId] ?: -100 // Default to a weak signal + PeerInfo( + id = peerId, + nickname = nickname, + proximity = getProximityFromRssi(rssi) + ) + }.sortedByDescending { it.proximity } // Sort by strongest signal first + + // Update the notification with the new data + notificationManager.notify(NOTIFICATION_ID, buildNotification(alert)) + } + + 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 builder = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(colors.primary.toArgb()) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setCustomContentView(createCollapsedRemoteViews()) + .setCustomBigContentView(createExpandedRemoteViews()) + .setContentIntent(createMainPendingIntent()) + .setOngoing(true) + + if (alert) { + // Make this specific update alert the user + builder.setOnlyAlertOnce(false) + builder.setDefaults(Notification.DEFAULT_ALL) + } else { + // Subsequent updates should be silent + builder.setOnlyAlertOnce(true) + } + + return builder.build() + } + + private fun createCollapsedRemoteViews(): RemoteViews { + val stopPendingIntent = createActionPendingIntent(ACTION_STOP_SERVICE) + val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme + val dimColor = if (isDarkTheme) Color(0xB3FFFFFF).toArgb() else colors.onSurface.toArgb() + + return RemoteViews(packageName, R.layout.notification_terminal_collapsed).apply { + setTextViewText(R.id.notification_info, "peers: ${activePeers.size} | unread: $unreadMessagesCount") + setTextColor(R.id.notification_cursor, colors.primary.toArgb()) + setTextColor(R.id.notification_title, colors.primary.toArgb()) + setTextColor(R.id.notification_info, dimColor) + setTextColor(R.id.notification_action_stop, dimColor) + setOnClickPendingIntent(R.id.notification_action_stop, stopPendingIntent) + } + } + + private fun createExpandedRemoteViews(): RemoteViews { + val stopPendingIntent = createActionPendingIntent(ACTION_STOP_SERVICE) + val mutePendingIntent = createActionPendingIntent(ACTION_MUTE) + val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme + + val primaryColor = colors.primary.toArgb() + val dimColor = if (isDarkTheme) Color(0xB3FFFFFF).toArgb() else colors.onSurface.toArgb() + val veryDimColor = if (isDarkTheme) Color(0x80FFFFFF).toArgb() else Color(0x99000000).toArgb() + val dividerColor = if (isDarkTheme) Color(0x4039FF14).toArgb() else Color(0x40000000).toArgb() + val peerProximityColor = if (isDarkTheme) Color(0xFF8AFF8A).toArgb() else Color(0xFF006600).toArgb() + + return RemoteViews(packageName, R.layout.notification_terminal_expanded).apply { + // --- Set Themed Colors for static elements --- + setTextColor(R.id.notification_title_expanded, primaryColor) + setTextColor(R.id.peer_list_header, veryDimColor) + setTextColor(R.id.log_header, veryDimColor) + setTextColor(R.id.notification_divider, dividerColor) + setTextColor(R.id.notification_action_mute, dimColor) + setTextColor(R.id.notification_action_stop_expanded, dimColor) + + // --- Populate Peer List from live data --- + removeAllViews(R.id.notification_peer_list) + activePeers.take(5).forEach { peer -> // Show top 5 peers + val peerView = RemoteViews(packageName, R.layout.notification_peer_item_terminal).apply { + setTextViewText(R.id.peer_proximity_bar, getProximityBar(peer.proximity)) + setTextColor(R.id.peer_proximity_bar, peerProximityColor) + setTextViewText(R.id.peer_name, peer.nickname) + setTextColor(R.id.peer_name, dimColor) + } + addView(R.id.notification_peer_list, peerView) + } + + // --- Populate Message Log from live data --- + removeAllViews(R.id.notification_message_log) + recentMessages.take(4).forEach { message -> + try { + val lineView = RemoteViews(packageName, R.layout.notification_line_item).apply { + // Defensive coding: handle potential nulls to prevent crashes + val sender = message.sender?.take(8) ?: "unknown" + val content = message.content ?: "[empty message]" + val formattedMessage = "<$sender> $content" + setTextViewText(R.id.line_item_text, formattedMessage) + setTextColor(R.id.line_item_text, dimColor) + } + addView(R.id.notification_message_log, lineView) + } catch (e: Exception) { + Log.e(TAG, "Failed to create message view for log", e) + } + } + + // --- Set Actions --- + setOnClickPendingIntent(R.id.notification_action_mute, mutePendingIntent) + setOnClickPendingIntent(R.id.notification_action_stop_expanded, stopPendingIntent) + } + } + + // --- 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 / No signal + } + } + + private fun getProximityBar(proximity: Int): String { + val filledChar = "▆" + val emptyChar = " " + return "[${filledChar.repeat(proximity)}${emptyChar.repeat(4 - proximity)}]" + } + + private fun startUiUpdater() { + if (::uiUpdateScheduler.isInitialized && !uiUpdateScheduler.isShutdown) return + uiUpdateScheduler = Executors.newSingleThreadScheduledExecutor() + uiUpdateScheduler.scheduleWithFixedDelay({ + updateNotification(false) + }, 5, 5, TimeUnit.SECONDS) + } + + // --- Boilerplate (Intents, Binder, etc.) --- + + 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?) { + Log.d(TAG, "Notification action received: ${intent?.action}") + when (intent?.action) { + ACTION_STOP_SERVICE -> stopForegroundServiceAndApp() + ACTION_MUTE -> Log.d(TAG, "Mute action tapped") + } + } + } + + private fun stopForegroundServiceAndApp() { + Log.i(TAG, "Stop action triggered. Stopping service.") + serviceListener?.onServiceStopping() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun createNotificationChannel() { + // Use IMPORTANCE_DEFAULT to allow sound/vibration for new peer alerts + val serviceChannel = NotificationChannel( + FOREGROUND_CHANNEL_ID, "Bitchat Active Service", NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Keeps Bitchat connected and shows live status" + setShowBadge(false) + // Disable vibration/sound by default; we will trigger it manually + enableVibration(false) + setSound(null, null) + } + notificationManager.createNotificationChannel(serviceChannel) + } + + private fun createMainPendingIntent(): PendingIntent { + val intent = packageManager.getLaunchIntentForPackage(packageName) + return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } + + private fun createActionPendingIntent(action: String): PendingIntent { + val intent = Intent(this, notificationActionReceiver::class.java).also { it.action = action } + 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/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index ea9f3b5ab..360434f8a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,7 +5,9 @@ import android.content.Context import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData +import androidx.lifecycle.application import androidx.lifecycle.viewModelScope +import com.bitchat.android.BitchatApplication import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage @@ -21,14 +23,16 @@ import kotlin.random.Random * Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility */ class ChatViewModel( - application: Application, - val meshService: BluetoothMeshService + application: Application ) : AndroidViewModel(application), BluetoothMeshDelegate { companion object { private const val TAG = "ChatViewModel" } + lateinit var meshService: BluetoothMeshService + private set + // State management private val state = ChatState() @@ -87,12 +91,14 @@ class ChatViewModel( val peerSessionStates: LiveData> = state.peerSessionStates val peerFingerprints: LiveData> = state.peerFingerprints val showAppInfo: LiveData = state.showAppInfo - - init { - // Note: Mesh service delegate is now set by MainActivity + + fun initialize(meshService: BluetoothMeshService) { + Log.d(TAG, "Initializing ChatViewModel") + this.meshService = meshService + this.meshService.delegate = this loadAndInitialize() } - + private fun loadAndInitialize() { // Load nickname val nickname = dataManager.loadNickname() @@ -123,9 +129,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) 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/java/com/bitchat/android/util/ProximityDrawable.kt b/app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt new file mode 100644 index 000000000..68fa988af --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.ui.drawables + +import android.graphics.* +import android.graphics.drawable.Drawable +import androidx.core.graphics.drawable.toBitmap + +/** + * A custom drawable that renders a 4-bar signal strength indicator. + * The number of active bars is determined by the proximity level. + * + * @param proximity An integer from 0 to 4 representing signal strength. + * @param activeColor The color of the active bars. + * @param inactiveColor The color of the inactive bars. + */ +class ProximityDrawable( + private val proximity: Int, + private val activeColor: Int, + private val inactiveColor: Int +) : Drawable() { + + private val paint = Paint().apply { + style = Paint.Style.FILL + } + + // Defines the relative heights of the four bars. + private val barHeightFactors = listOf(0.4f, 0.6f, 0.8f, 1.0f) + + override fun draw(canvas: Canvas) { + val width = bounds.width().toFloat() + val height = bounds.height().toFloat() + val barWidth = width / 7f // Leaves space between bars + val barSpacing = barWidth / 2f + + for (i in 0..3) { + paint.color = if (i < proximity) activeColor else inactiveColor + + val barHeight = height * barHeightFactors[i] + val left = (barWidth + barSpacing) * i + val top = height - barHeight + + canvas.drawRoundRect(left, top, left + barWidth, height, 2f, 2f, paint) + } + } + + // Helper to convert this drawable to a Bitmap, which RemoteViews requires. + fun toBitmap(width: Int, height: Int): Bitmap { + return this.toBitmap(width, height, Bitmap.Config.ARGB_8888) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} \ No newline at end of file diff --git a/app/src/main/res/drawable/close_24px.xml b/app/src/main/res/drawable/close_24px.xml new file mode 100644 index 000000000..7a0ff35df --- /dev/null +++ b/app/src/main/res/drawable/close_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/notification_bg_terminal.xml b/app/src/main/res/drawable/notification_bg_terminal.xml new file mode 100644 index 000000000..ffb838463 --- /dev/null +++ b/app/src/main/res/drawable/notification_bg_terminal.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/notifications_active_24px.xml b/app/src/main/res/drawable/notifications_active_24px.xml new file mode 100644 index 000000000..266bf0c12 --- /dev/null +++ b/app/src/main/res/drawable/notifications_active_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/notifications_off_24px.xml b/app/src/main/res/drawable/notifications_off_24px.xml new file mode 100644 index 000000000..462629adb --- /dev/null +++ b/app/src/main/res/drawable/notifications_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/reply_24px.xml b/app/src/main/res/drawable/reply_24px.xml new file mode 100644 index 000000000..718f8e912 --- /dev/null +++ b/app/src/main/res/drawable/reply_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/notification_line_item.xml b/app/src/main/res/layout/notification_line_item.xml new file mode 100644 index 000000000..6d0379869 --- /dev/null +++ b/app/src/main/res/layout/notification_line_item.xml @@ -0,0 +1,15 @@ + + + diff --git a/app/src/main/res/layout/notification_peer_item_terminal.xml b/app/src/main/res/layout/notification_peer_item_terminal.xml new file mode 100644 index 000000000..d3cd6aa9e --- /dev/null +++ b/app/src/main/res/layout/notification_peer_item_terminal.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/notification_terminal_collapsed.xml b/app/src/main/res/layout/notification_terminal_collapsed.xml new file mode 100644 index 000000000..84754ad5e --- /dev/null +++ b/app/src/main/res/layout/notification_terminal_collapsed.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/notification_terminal_expanded.xml b/app/src/main/res/layout/notification_terminal_expanded.xml new file mode 100644 index 000000000..9a3f24bea --- /dev/null +++ b/app/src/main/res/layout/notification_terminal_expanded.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 55143149739b2b07638c5b1916710b41eb6f1185 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Sat, 2 Aug 2025 16:16:55 -0700 Subject: [PATCH 02/13] working ansi grid with spark peer rendering system --- app/src/main/AndroidManifest.xml | 8 +- .../bitchat/android/mesh/ForegroundService.kt | 184 ++++++++++- .../com/bitchat/android/util/AnsiChars.kt | 290 ++++++++++++++++++ .../notification_terminal_collapsed.xml | 75 +++-- 4 files changed, 505 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/util/AnsiChars.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b99cb2d26..0e4cd04aa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ - + @@ -19,13 +19,13 @@ - + - + - + diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index 0319a521b..4b53bfdb3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -10,6 +10,12 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color as AndroidColor +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.RectF import android.os.Binder import android.os.IBinder import android.os.Vibrator @@ -28,10 +34,77 @@ import com.bitchat.android.ui.theme.LightColorScheme import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import androidx.core.graphics.createBitmap +import java.util.Random // Data class to hold combined peer information for the UI data class PeerInfo(val id: String, val nickname: String, val proximity: Int) +/** + * A helper class to manage a grid of characters and colors for ANSI-style art. + * This makes it easier to "draw" text, shapes, and other elements onto a character-based canvas. + */ +class AnsiGrid( + val width: Int, + val height: Int, + private val defaultColor: Int +) { + // A grid of pairs, where each pair holds a character and its integer color. + private val grid: Array>> = Array(height) { + Array(width) { Pair(AnsiChars.EMPTY_CHAR, defaultColor) } + } + private val textPaint = Paint().apply { + isAntiAlias = true + typeface = Typeface.MONOSPACE + } + + fun setChar(x: Int, y: Int, char: Char, color: Int) { + if (x in 0 until width && y in 0 until height) { + grid[y][x] = Pair(char, color) + } + } + + fun drawText(x: Int, y: Int, text: String, color: Int) { + text.forEachIndexed { index, char -> + setChar(x + index, y, char, color) + } + } + + fun clear() { + for (y in 0 until height) { + for (x in 0 until width) { + grid[y][x] = Pair(AnsiChars.EMPTY_CHAR, defaultColor) + } + } + } + + /** + * Renders the entire grid onto a Canvas. + * @param canvas The Android Canvas to draw on. + * @param charWidth The calculated width of a single character in pixels. + * @param charHeight The calculated height of a single character in pixels. + */ + fun render(canvas: Canvas, charWidth: Float, charHeight: Float) { + // Fine-tune the text size for your font. 1.1f is a good starting point. + textPaint.textSize = charHeight * 1.1f + val baselineOffset = textPaint.fontMetrics.descent + + for (row in 0 until height) { + for (col in 0 until width) { + val (char, color) = grid[row][col] + if (char != AnsiChars.EMPTY_CHAR) { + textPaint.color = color + val xPos = col * charWidth + // Adjust y to be the baseline for the text + val yPos = (row + 1) * charHeight - baselineOffset + canvas.drawText(char.toString(), xPos, yPos, textPaint) + } + } + } + } +} + + /** * A foreground service that provides a rich, interactive, and theme-consistent notification * in the style of a monochrome IRC/terminal client. It displays nearby peers with proximity @@ -50,6 +123,11 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private var recentMessages = mutableListOf() private val knownPeerIds = HashSet() // Used to detect new peers + // --- State for ANSI Grid Visualization --- + private val peerSparks = mutableMapOf>() // PeerID -> (x, y) + private val random = Random() + + // Scheduler for periodic UI refreshes (e.g., timestamps) private lateinit var uiUpdateScheduler: ScheduledExecutorService @@ -199,19 +277,103 @@ class ForegroundService : Service(), BluetoothMeshDelegate { return builder.build() } + /** + * This is the core function for rendering the ANSI art status grid. + * It creates a bitmap, calculates the grid dimensions, draws dynamic + * content onto an AnsiGrid, and then renders the grid to the bitmap. + */ + private fun getCollapsedRenderBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val density = resources.displayMetrics.density + val bitmap = createBitmap(bitmapWidth, bitmapHeight) + val canvas = Canvas(bitmap) + + val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + AnsiChars.Palette.initialize(isDarkTheme) + + canvas.drawColor(AnsiChars.Palette.background) + + // Use a small, monospace font to determine the grid size + val textPaint = Paint().apply { + typeface = Typeface.MONOSPACE + textSize = 6 * density + } + val charWidth = textPaint.measureText(AnsiChars.BLOCK_FULL.toString()) + val charHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + + if (charWidth <= 0 || charHeight <= 0) return bitmap + + val numCols = (bitmapWidth / charWidth).toInt() + val numRows = (bitmapHeight / charHeight).toInt() + + if (numCols <= 0 || numRows <= 0) return bitmap + + // Create the grid and draw our visualizations + val grid = AnsiGrid(numCols, numRows, AnsiChars.Palette.background) + drawPeerSparks(grid, activePeers) + drawStatusText(grid, "peers: ${activePeers.size}") + + // Render the final grid to the canvas + grid.render(canvas, charWidth, charHeight) + + return bitmap + } + + /** + * Draws a "spark" for each peer that moves around randomly. + * The color is based on the peer's proximity. + */ + private fun drawPeerSparks(grid: AnsiGrid, peers: List) { + // Remove sparks for peers that have disconnected + peerSparks.keys.retainAll(peers.map { it.id }.toSet()) + + peers.forEach { peer -> + // If the peer is new, assign it a random starting position + if (!peerSparks.containsKey(peer.id)) { + peerSparks[peer.id] = Pair(random.nextInt(grid.width), random.nextInt(grid.height)) + } + + // Jiggle the spark's position randomly + var (x, y) = peerSparks[peer.id]!! + x += random.nextInt(3) - 1 // Move by -1, 0, or 1 + y += random.nextInt(3) - 1 + x = x.coerceIn(0, grid.width - 1) + y = y.coerceIn(0, grid.height - 1) + peerSparks[peer.id] = Pair(x, y) + + // Draw the spark using a Braille character that looks like a person + val char = AnsiChars.BRAILLE_DOTS_245 + val color = AnsiChars.Palette.getProximityColor(peer.proximity) + grid.setChar(x, y, char, color) + } + } + + /** + * Draws a simple text string onto the grid, typically in a corner. + */ + private fun drawStatusText(grid: AnsiGrid, text: String) { + val yPos = grid.height - 1 // Bottom row + val xPos = grid.width - text.length - 1 // Right-aligned + if (xPos < 0) return // Don't draw if it doesn't fit + + grid.drawText(xPos, yPos, text, AnsiChars.Palette.primary) + } + + private fun createCollapsedRemoteViews(): RemoteViews { - val stopPendingIntent = createActionPendingIntent(ACTION_STOP_SERVICE) val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme - val dimColor = if (isDarkTheme) Color(0xB3FFFFFF).toArgb() else colors.onSurface.toArgb() + + // We must estimate the width for the initial bitmap render. + // The notification layout will stretch the ImageView, and our bitmap will scale. + // 200dp is a reasonable estimate for the available space. + val density = resources.displayMetrics.density + val estimatedBitmapWidthPx = (200 * density).toInt() + val bitmapHeightPx = (48 * density).toInt() return RemoteViews(packageName, R.layout.notification_terminal_collapsed).apply { - setTextViewText(R.id.notification_info, "peers: ${activePeers.size} | unread: $unreadMessagesCount") setTextColor(R.id.notification_cursor, colors.primary.toArgb()) setTextColor(R.id.notification_title, colors.primary.toArgb()) - setTextColor(R.id.notification_info, dimColor) - setTextColor(R.id.notification_action_stop, dimColor) - setOnClickPendingIntent(R.id.notification_action_stop, stopPendingIntent) + setImageViewBitmap(R.id.notification_render, getCollapsedRenderBitmap(estimatedBitmapWidthPx, bitmapHeightPx)) } } @@ -293,9 +455,11 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private fun startUiUpdater() { if (::uiUpdateScheduler.isInitialized && !uiUpdateScheduler.isShutdown) return uiUpdateScheduler = Executors.newSingleThreadScheduledExecutor() + // Update the UI every 250ms for smoother animation uiUpdateScheduler.scheduleWithFixedDelay({ + // Post the update to the main thread if you encounter issues updateNotification(false) - }, 5, 5, TimeUnit.SECONDS) + }, 0, 250, TimeUnit.MILLISECONDS) } // --- Boilerplate (Intents, Binder, etc.) --- @@ -348,7 +512,11 @@ class ForegroundService : Service(), BluetoothMeshDelegate { } private fun createActionPendingIntent(action: String): PendingIntent { - val intent = Intent(this, notificationActionReceiver::class.java).also { it.action = action } + // Create an implicit intent that will be caught by the dynamically registered receiver. + val intent = Intent(action).apply { + // Explicitly set the package to ensure the broadcast is only handled within this app. + `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/util/AnsiChars.kt b/app/src/main/java/com/bitchat/android/util/AnsiChars.kt new file mode 100644 index 000000000..1c76d28d9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/AnsiChars.kt @@ -0,0 +1,290 @@ +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.abs +import kotlin.math.roundToInt +import androidx.core.graphics.get +import androidx.core.graphics.scale + +/** + * A helper object providing compile-time constants and utility functions + * for creating terminal-based art and user interfaces (TUI). + * + * This object includes constants for box-drawing, shades, and symbols, + * as well as helper functions for drawing shapes, gradients, and converting + * Android Bitmaps to ANSI/ASCII art in both grayscale and 256-color. + */ +object AnsiChars { + + // --- General Characters --- + const val EMPTY_CHAR = ' ' // Standard space + const val BRAILLE_BLANK = '⠀' // Braille Pattern Blank (U+2800), an invisible space. + const val ESC = '\u001B' // ANSI Escape character + + // --- Single-Line Box Drawing --- + const val BOX_HORIZONTAL = '─' + const val BOX_VERTICAL = '│' + const val BOX_DOWN_AND_RIGHT = '┌' + const val BOX_DOWN_AND_LEFT = '┐' + const val BOX_UP_AND_RIGHT = '└' + const val BOX_UP_AND_LEFT = '┘' + const val BOX_VERTICAL_AND_RIGHT = '├' + const val BOX_VERTICAL_AND_LEFT = '┤' + const val BOX_DOWN_AND_HORIZONTAL = '┬' + const val BOX_UP_AND_HORIZONTAL = '┴' + const val BOX_VERTICAL_AND_HORIZONTAL = '┼' + + // --- Rounded-Corner Box Drawing --- + const val BOX_ROUNDED_DOWN_AND_RIGHT = '╭' + const val BOX_ROUNDED_DOWN_AND_LEFT = '╮' + const val BOX_ROUNDED_UP_AND_RIGHT = '╰' + const val BOX_ROUNDED_UP_AND_LEFT = '╯' + + // --- Double-Line Box Drawing --- + const val BOX_DOUBLE_HORIZONTAL = '═' + const val BOX_DOUBLE_VERTICAL = '║' + const val BOX_DOUBLE_DOWN_AND_RIGHT = '╔' + const val BOX_DOUBLE_DOWN_AND_LEFT = '╗' + const val BOX_DOUBLE_UP_AND_RIGHT = '╚' + const val BOX_DOUBLE_UP_AND_LEFT = '╝' + const val BOX_DOUBLE_VERTICAL_AND_RIGHT = '╠' + const val BOX_DOUBLE_VERTICAL_AND_LEFT = '╣' + const val BOX_DOUBLE_DOWN_AND_HORIZONTAL = '╦' + const val BOX_DOUBLE_UP_AND_HORIZONTAL = '╩' + const val BOX_DOUBLE_VERTICAL_AND_HORIZONTAL = '╬' + + // --- Block Elements --- + const val BLOCK_FULL = '█' + const val BLOCK_UPPER_HALF = '▀' + const val BLOCK_LOWER_HALF = '▄' + const val BLOCK_LEFT_HALF = '▌' + const val BLOCK_RIGHT_HALF = '▐' + const val BLOCK_UPPER_LEFT_QUADRANT = '▘' + const val BLOCK_UPPER_RIGHT_QUADRANT = '▝' + const val BLOCK_LOWER_LEFT_QUADRANT = '▖' + const val BLOCK_LOWER_RIGHT_QUADRANT = '▗' + const val BLOCK_QUADRANT_UPPER_LEFT_AND_LOWER_RIGHT = '▚' + const val BLOCK_QUADRANT_UPPER_RIGHT_AND_LOWER_LEFT = '▞' + + + // --- Shades --- + const val SHADE_LIGHT = '░' + const val SHADE_MEDIUM = '▒' + const val SHADE_DARK = '▓' + + // --- Braille Patterns (useful for small icons) --- + const val BRAILLE_DOTS_1 = '⠁' + const val BRAILLE_DOTS_12 = '⠃' + const val BRAILLE_DOTS_123 = '⠇' + const val BRAILLE_DOTS_245 = '⠚' // Looks like a person/peer icon + + // --- ASCII Character Ramps for Image Conversion --- + const val ASCII_RAMP_SIMPLE = "@%#*+=-:. " // Darkest to lightest + const val ASCII_RAMP_DETAILED = "\$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " + + // --- Other Symbols --- + const val CHECKMARK = '✓' + const val CROSS = '✗' + const val TRIANGLE_UP = '▲' + const val TRIANGLE_DOWN = '▼' + const val TRIANGLE_LEFT = '◀' + const val TRIANGLE_RIGHT = '▶' + + // --- IRC Control Codes --- + const val IRC_BOLD = '\u0002' + const val IRC_COLOR = '\u0003' + const val IRC_REVERSE = '\u0016' + const val IRC_UNDERLINE = '\u001F' + const val IRC_RESET = '\u000F' + + + /** + * A simple, theme-aware color palette for the terminal UI. + * These should be initialized with colors from the app's theme. + */ + object Palette { + var primary: Int = android.graphics.Color.GREEN + var secondary: Int = android.graphics.Color.WHITE + var background: Int = android.graphics.Color.BLACK + var accent: Int = android.graphics.Color.CYAN + + fun initialize(isDarkTheme: Boolean) { + if (isDarkTheme) { + primary = android.graphics.Color.rgb(0x39, 0xFF, 0x14) // Bright Green + secondary = android.graphics.Color.LTGRAY + background = android.graphics.Color.BLACK + accent = android.graphics.Color.rgb(0x00, 0xFF, 0xFF) // Cyan + } else { + primary = android.graphics.Color.DKGRAY + secondary = android.graphics.Color.GRAY + background = android.graphics.Color.WHITE + accent = android.graphics.Color.BLUE + } + } + + fun getProximityColor(proximity: Int, maxProximity: Int = 4): Int { + // Simple gradient from dim to bright based on proximity (0-4) + val alpha = (60 + (proximity.toFloat() / maxProximity) * 195).toInt().coerceIn(0, 255) + return android.graphics.Color.argb(alpha, + android.graphics.Color.red(primary), + android.graphics.Color.green(primary), + android.graphics.Color.blue(primary) + ) + } + } + + + // --- Helper Functions for Drawing --- + + /** + * Creates a string by repeating a character a specified number of times. + */ + fun line(char: Char, length: Int): String { + if (length <= 0) return "" + return char.toString().repeat(length) + } + + /** + * Draws a simple box using single-line characters. + */ + fun simpleBox(width: Int, height: Int, rounded: Boolean = false): String { + if (width < 2 || height < 2) return "" + + val tl = if(rounded) BOX_ROUNDED_DOWN_AND_RIGHT else BOX_DOWN_AND_RIGHT + val tr = if(rounded) BOX_ROUNDED_DOWN_AND_LEFT else BOX_DOWN_AND_LEFT + val bl = if(rounded) BOX_ROUNDED_UP_AND_RIGHT else BOX_UP_AND_RIGHT + val br = if(rounded) BOX_ROUNDED_UP_AND_LEFT else BOX_UP_AND_LEFT + + val top = "$tl${line(BOX_HORIZONTAL, width - 2)}$tr" + val bottom = "$bl${line(BOX_HORIZONTAL, width - 2)}$br" + + if (height == 2) return "$top\n$bottom" + + val middleContent = " ".repeat(width - 2) + val middle = "$BOX_VERTICAL$middleContent$BOX_VERTICAL" + val middleRows = List(height - 2) { middle }.joinToString("\n") + + return "$top\n$middleRows\n$bottom" + } + + /** + * Draws a box with a title embedded in the top border. + */ + fun titledBox(title: String, width: Int, useDoubleLine: Boolean = false): String { + val cleanTitle = " $title " + if (width < cleanTitle.length + 2) return "" + + val hChar = if (useDoubleLine) BOX_DOUBLE_HORIZONTAL else BOX_HORIZONTAL + val tlChar = if (useDoubleLine) BOX_DOUBLE_DOWN_AND_RIGHT else BOX_DOWN_AND_RIGHT + val trChar = if (useDoubleLine) BOX_DOUBLE_DOWN_AND_LEFT else BOX_DOWN_AND_LEFT + val blChar = if (useDoubleLine) BOX_DOUBLE_UP_AND_RIGHT else BOX_UP_AND_RIGHT + val brChar = if (useDoubleLine) BOX_DOUBLE_UP_AND_LEFT else BOX_UP_AND_LEFT + + val remainingWidth = width - cleanTitle.length - 2 + val leftPad = remainingWidth / 2 + val rightPad = remainingWidth - leftPad + + val top = "$tlChar${line(hChar, leftPad)}$cleanTitle${line(hChar, rightPad)}$trChar" + val bottom = "$blChar${line(hChar, width - 2)}$brChar" + + return "$top\n$bottom" + } + + // --- Color & Gradient Helpers --- + + /** + * Wraps a string with ANSI 256-color escape codes. + * @param text The string to colorize. + * @param colorCode A color code between 0 and 255. + * @return The colorized string. + */ + fun colorize(text: String, colorCode: Int): String { + val code = colorCode.coerceIn(0, 255) + return "$ESC[38;5;${code}m$text$ESC[0m" + } + + /** + * Creates a horizontal gradient using shade characters. + */ + fun gradient(width: Int, reversed: Boolean = false): String { + if (width <= 0) return "" + val gradientChars = listOf(SHADE_LIGHT, SHADE_MEDIUM, SHADE_DARK, BLOCK_FULL) + val finalChars = if (reversed) gradientChars.reversed() else gradientChars + + val segmentWidth = width / finalChars.size + val remainder = width % finalChars.size + + val sb = StringBuilder(width) + finalChars.forEachIndexed { index, char -> + val len = segmentWidth + if (index < remainder) 1 else 0 + sb.append(char.toString().repeat(len)) + } + return sb.toString() + } + + // --- Image Conversion Helpers --- + + /** + * Converts an Android Bitmap into a grayscale ANSI/ASCII art string. + */ + fun imageToAnsi(bitmap: Bitmap, targetWidth: Int, detailed: Boolean = false): String { + if (targetWidth <= 0) return "" + + val ramp = if (detailed) ASCII_RAMP_DETAILED else ASCII_RAMP_SIMPLE + val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() + val targetHeight = (targetWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) + val scaledBitmap = bitmap.scale(targetWidth, targetHeight) + val sb = StringBuilder(targetWidth * targetHeight) + + for (y in 0 until scaledBitmap.height) { + for (x in 0 until scaledBitmap.width) { + val pixel = scaledBitmap[x, y] + val brightness = (0.299 * Color.red(pixel) + 0.587 * Color.green(pixel) + 0.114 * Color.blue(pixel)).toInt() + val rampIndex = (brightness / 255.0 * (ramp.length - 1)).roundToInt() + sb.append(ramp.reversed()[rampIndex]) + } + sb.append('\n') + } + return sb.toString() + } + + /** + * Converts an Android Bitmap into a 256-color ANSI art string. + * Uses the full block character for each "pixel" and colorizes it. + */ + fun imageToAnsiColor(bitmap: Bitmap, targetWidth: Int): String { + if (targetWidth <= 0) return "" + + val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() + val targetHeight = (targetWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) + val scaledBitmap = bitmap.scale(targetWidth, targetHeight) + val sb = StringBuilder() + + for (y in 0 until scaledBitmap.height) { + for (x in 0 until scaledBitmap.width) { + val pixel = scaledBitmap[x, y] + val colorCode = rgbToAnsi256(Color.red(pixel), Color.green(pixel), Color.blue(pixel)) + sb.append(colorize(BLOCK_FULL.toString(), colorCode)) + } + sb.append('\n') + } + return sb.toString() + } + + /** + * Maps an RGB color to the nearest color in the xterm 256-color palette. + */ + private fun rgbToAnsi256(r: Int, g: Int, b: Int): Int { + // Grayscale colors + if (abs(r - g) < 8 && abs(g - b) < 8) { + val gray = (r + g + b) / 3 + if (gray > 238) return 231 // White + if (gray < 18) return 16 // Black + return 232 + ((gray - 8) / 10) + } + // 6x6x6 color cube + val rAnsi = (r * 5 / 255) + val gAnsi = (g * 5 / 255) + val bAnsi = (b * 5 / 255) + return 16 + (36 * rAnsi) + (6 * gAnsi) + bAnsi + } +} diff --git a/app/src/main/res/layout/notification_terminal_collapsed.xml b/app/src/main/res/layout/notification_terminal_collapsed.xml index 84754ad5e..70bb1edc2 100644 --- a/app/src/main/res/layout/notification_terminal_collapsed.xml +++ b/app/src/main/res/layout/notification_terminal_collapsed.xml @@ -10,61 +10,56 @@ android:background="@drawable/notification_bg_terminal" android:gravity="center_vertical" android:orientation="horizontal" - android:paddingStart="12dp" - android:paddingEnd="8dp"> + android:padding="2dp"> - - + + + android:scaleType="fitXY" + /> + + + + + + - - - + + + + + + + + + + - - From df5a46fb1e547d96c6fa87ea181b466d6228d84c Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Sun, 3 Aug 2025 14:11:16 -0700 Subject: [PATCH 03/13] working debug and real time peer list in working ansi-grid --- .../bitchat/android/mesh/ForegroundService.kt | 446 +++++++++--------- .../com/bitchat/android/util/AnsiChars.kt | 396 ++++++++-------- .../java/com/bitchat/android/util/AnsiGrid.kt | 66 +++ .../notification_terminal_collapsed.xml | 45 +- .../layout/notification_terminal_expanded.xml | 88 +--- 5 files changed, 483 insertions(+), 558 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/util/AnsiGrid.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index 4b53bfdb3..f3d5a2083 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -1,5 +1,6 @@ package com.bitchat.android.mesh +import android.R.style.Theme import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -12,103 +13,46 @@ import android.content.IntentFilter import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color as AndroidColor import android.graphics.Paint import android.graphics.Typeface -import android.graphics.RectF import android.os.Binder import android.os.IBinder -import android.os.Vibrator import android.util.Log import android.widget.RemoteViews +import androidx.annotation.LayoutRes import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap 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 com.bitchat.android.util.AnsiChars +import com.bitchat.android.util.AnsiGrid +import java.util.Random import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit -import androidx.core.graphics.createBitmap -import java.util.Random +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin // Data class to hold combined peer information for the UI data class PeerInfo(val id: String, val nickname: String, val proximity: Int) -/** - * A helper class to manage a grid of characters and colors for ANSI-style art. - * This makes it easier to "draw" text, shapes, and other elements onto a character-based canvas. - */ -class AnsiGrid( - val width: Int, - val height: Int, - private val defaultColor: Int -) { - // A grid of pairs, where each pair holds a character and its integer color. - private val grid: Array>> = Array(height) { - Array(width) { Pair(AnsiChars.EMPTY_CHAR, defaultColor) } - } - private val textPaint = Paint().apply { - isAntiAlias = true - typeface = Typeface.MONOSPACE - } - - fun setChar(x: Int, y: Int, char: Char, color: Int) { - if (x in 0 until width && y in 0 until height) { - grid[y][x] = Pair(char, color) - } - } - - fun drawText(x: Int, y: Int, text: String, color: Int) { - text.forEachIndexed { index, char -> - setChar(x + index, y, char, color) - } - } - - fun clear() { - for (y in 0 until height) { - for (x in 0 until width) { - grid[y][x] = Pair(AnsiChars.EMPTY_CHAR, defaultColor) - } - } - } - - /** - * Renders the entire grid onto a Canvas. - * @param canvas The Android Canvas to draw on. - * @param charWidth The calculated width of a single character in pixels. - * @param charHeight The calculated height of a single character in pixels. - */ - fun render(canvas: Canvas, charWidth: Float, charHeight: Float) { - // Fine-tune the text size for your font. 1.1f is a good starting point. - textPaint.textSize = charHeight * 1.1f - val baselineOffset = textPaint.fontMetrics.descent - - for (row in 0 until height) { - for (col in 0 until width) { - val (char, color) = grid[row][col] - if (char != AnsiChars.EMPTY_CHAR) { - textPaint.color = color - val xPos = col * charWidth - // Adjust y to be the baseline for the text - val yPos = (row + 1) * charHeight - baselineOffset - canvas.drawText(char.toString(), xPos, yPos, textPaint) - } - } - } - } -} +private const val FONT_SIZE = 10 /** * A foreground service that provides a rich, interactive, and theme-consistent notification * in the style of a monochrome IRC/terminal client. It displays nearby peers with proximity * and a live log of recent messages by acting as a delegate for BluetoothMeshService. + * + * The notification features a generative ANSI art landscape where peers are represented as stars. */ class ForegroundService : Service(), BluetoothMeshDelegate { @@ -119,16 +63,16 @@ class ForegroundService : Service(), BluetoothMeshDelegate { // --- Live State for Notification UI --- private var activePeers = listOf() - private var unreadMessagesCount = 0 private var recentMessages = mutableListOf() private val knownPeerIds = HashSet() // Used to detect new peers // --- State for ANSI Grid Visualization --- - private val peerSparks = mutableMapOf>() // PeerID -> (x, y) + private var frame: Long = 0 // Animation frame counter + private val peerStarData = mutableMapOf>() // PeerID -> (x, random phase offset) private val random = Random() - // Scheduler for periodic UI refreshes (e.g., timestamps) + // Scheduler for periodic UI refreshes private lateinit var uiUpdateScheduler: ScheduledExecutorService companion object { @@ -137,12 +81,22 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private const val FOREGROUND_CHANNEL_ID = "bitchat_foreground_service" const val ACTION_STOP_SERVICE = "com.bitchat.android.ACTION_STOP_SERVICE" const val ACTION_MUTE = "com.bitchat.android.ACTION_MUTE" + private const val DEBUG_MODE = true // Enable to use mock peers @Volatile var isServiceRunning = false private set } + // --- Mock Data for Debugging --- + private val mockPeers = listOf( + PeerInfo("id_1", "zerocool", 4), + PeerInfo("id_2", "acidburn", 3), + PeerInfo("id_3", "phantomphreak", 2), + PeerInfo("id_4", "lordnikon", 1), + PeerInfo("id_5", "cerealkiller", 0) + ) + // --- Service Lifecycle & Setup --- override fun onCreate() { @@ -150,7 +104,6 @@ class ForegroundService : Service(), BluetoothMeshDelegate { isServiceRunning = true notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - // Initialize the mesh service and set this class as its delegate if (meshService == null) { meshService = BluetoothMeshService(this).apply { delegate = this@ForegroundService @@ -161,13 +114,12 @@ class ForegroundService : Service(), BluetoothMeshDelegate { addAction(ACTION_STOP_SERVICE) addAction(ACTION_MUTE) } - // For Android 14+, must specify receiver exportability ContextCompat.registerReceiver(this, notificationActionReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { createNotificationChannel() - startForeground(NOTIFICATION_ID, buildNotification(false)) // Initial notification is silent + startForeground(NOTIFICATION_ID, buildNotification(false)) startUiUpdater() meshService?.startServices() return START_STICKY @@ -182,24 +134,19 @@ class ForegroundService : Service(), BluetoothMeshDelegate { meshService = null } - override fun onBind(intent: Intent): IBinder { - return binder - } + override fun onBind(intent: Intent): IBinder = binder - fun getMeshService(): BluetoothMeshService? { - return meshService - } + fun getMeshService(): BluetoothMeshService? = meshService // --- BluetoothMeshDelegate Implementation --- override fun didReceiveMessage(message: BitchatMessage) { Log.d(TAG, "didReceiveMessage: '${message.content}' from ${message.sender}") - // Add message to the log and trim if it gets too long recentMessages.add(0, message) if (recentMessages.size > 10) { recentMessages = recentMessages.take(10).toMutableList() } - updateNotification(false) // New messages don't need to alert + updateNotification(false) } override fun didUpdatePeerList(peers: List) { @@ -207,13 +154,10 @@ class ForegroundService : Service(), BluetoothMeshDelegate { } override fun didConnectToPeer(peerID: String) { - // Check if this is a genuinely new peer if (knownPeerIds.add(peerID)) { Log.i(TAG, "New peer connected: $peerID. Triggering alert.") - // Trigger an alerting notification for the new peer updateNotification(true) } else { - // Peer reconnected, just do a silent update updateNotification(false) } } @@ -223,35 +167,32 @@ class ForegroundService : Service(), BluetoothMeshDelegate { updateNotification(false) } - // Other delegate methods can trigger a UI update if needed - override fun didReceiveDeliveryAck(ack: DeliveryAck) { /* Can update UI later */ } - override fun didReceiveReadReceipt(receipt: ReadReceipt) { /* Can update UI later */ } - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { /* Can update UI later */ } + 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" // Provide a default or fetch from settings + override fun getNickname(): String? = "bitchat_user" override fun isFavorite(peerID: String): Boolean = false // --- Notification Building & Logic --- private fun updateNotification(alert: Boolean) { - // Fetch real-time data from the mesh service - val nicknames = meshService?.getPeerNicknames() ?: emptyMap() - val rssiValues = meshService?.getPeerRSSI() ?: emptyMap() - - // Combine the data into a list of PeerInfo objects for the UI - activePeers = nicknames.map { (peerId, nickname) -> - val rssi = rssiValues[peerId] ?: -100 // Default to a weak signal - PeerInfo( - id = peerId, - nickname = nickname, - proximity = getProximityFromRssi(rssi) - ) - }.sortedByDescending { it.proximity } // Sort by strongest signal first + if (DEBUG_MODE) { + activePeers = mockPeers + } 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 } + } - // Update the notification with the new data notificationManager.notify(NOTIFICATION_ID, buildNotification(alert)) } + 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 @@ -266,171 +207,218 @@ class ForegroundService : Service(), BluetoothMeshDelegate { .setOngoing(true) if (alert) { - // Make this specific update alert the user builder.setOnlyAlertOnce(false) builder.setDefaults(Notification.DEFAULT_ALL) } else { - // Subsequent updates should be silent builder.setOnlyAlertOnce(true) } return builder.build() } - /** - * This is the core function for rendering the ANSI art status grid. - * It creates a bitmap, calculates the grid dimensions, draws dynamic - * content onto an AnsiGrid, and then renders the grid to the bitmap. - */ - private fun getCollapsedRenderBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { + private fun getAnsiGrid(bitmapWidth: Int, bitmapHeight: Int, fgColor: Int): Triple? { val density = resources.displayMetrics.density - val bitmap = createBitmap(bitmapWidth, bitmapHeight) - val canvas = Canvas(bitmap) - - val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - AnsiChars.Palette.initialize(isDarkTheme) - - canvas.drawColor(AnsiChars.Palette.background) - - // Use a small, monospace font to determine the grid size val textPaint = Paint().apply { + color = fgColor + isAntiAlias = true typeface = Typeface.MONOSPACE - textSize = 6 * density + textSize = FONT_SIZE * density } - val charWidth = textPaint.measureText(AnsiChars.BLOCK_FULL.toString()) + // Use a standard, reliable character for measuring width. 'W' is a good choice. + val charWidth = textPaint.measureText("W") val charHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent - if (charWidth <= 0 || charHeight <= 0) return bitmap + if (charWidth <= 0 || charHeight <= 0) return null val numCols = (bitmapWidth / charWidth).toInt() val numRows = (bitmapHeight / charHeight).toInt() - if (numCols <= 0 || numRows <= 0) return bitmap + if (numCols <= 0 || numRows <= 0) return null + + val grid = AnsiGrid(numCols, numRows, textPaint) + return Triple(grid, charWidth, charHeight) + } - // Create the grid and draw our visualizations - val grid = AnsiGrid(numCols, numRows, AnsiChars.Palette.background) - drawPeerSparks(grid, activePeers) - drawStatusText(grid, "peers: ${activePeers.size}") + private fun getCollapsedRenderBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val bitmap = createBitmap(bitmapWidth, bitmapHeight) + val canvas = Canvas(bitmap) + val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - // Render the final grid to the canvas - grid.render(canvas, charWidth, charHeight) + val bgColor = if (isDarkTheme) DarkColorScheme.background.toArgb() else LightColorScheme.background.toArgb() + val fgColor = if (isDarkTheme) DarkColorScheme.primary.toArgb() else LightColorScheme.primary.toArgb() + canvas.drawColor(bgColor) + val gridData = getAnsiGrid(bitmapWidth, bitmapHeight, fgColor) + if (gridData != null) { + val (grid, charWidth, charHeight) = gridData + drawTerminalContent(grid, activePeers, isForCollapsedView = true) + // Pass charWidth and charHeight to the updated render function + grid.render(canvas, charWidth, charHeight) + } return bitmap } /** - * Draws a "spark" for each peer that moves around randomly. - * The color is based on the peer's proximity. + * Renders the entire expanded notification content into a single bitmap. */ - private fun drawPeerSparks(grid: AnsiGrid, peers: List) { - // Remove sparks for peers that have disconnected - peerSparks.keys.retainAll(peers.map { it.id }.toSet()) - - peers.forEach { peer -> - // If the peer is new, assign it a random starting position - if (!peerSparks.containsKey(peer.id)) { - peerSparks[peer.id] = Pair(random.nextInt(grid.width), random.nextInt(grid.height)) - } + private fun getExpandedRenderBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val bitmap = createBitmap(bitmapWidth, bitmapHeight) + val canvas = Canvas(bitmap) + val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - // Jiggle the spark's position randomly - var (x, y) = peerSparks[peer.id]!! - x += random.nextInt(3) - 1 // Move by -1, 0, or 1 - y += random.nextInt(3) - 1 - x = x.coerceIn(0, grid.width - 1) - y = y.coerceIn(0, grid.height - 1) - peerSparks[peer.id] = Pair(x, y) - - // Draw the spark using a Braille character that looks like a person - val char = AnsiChars.BRAILLE_DOTS_245 - val color = AnsiChars.Palette.getProximityColor(peer.proximity) - grid.setChar(x, y, char, color) + val bgColor = if (isDarkTheme) DarkColorScheme.background.toArgb() else LightColorScheme.background.toArgb() + val fgColor = if (isDarkTheme) DarkColorScheme.primary.toArgb() else LightColorScheme.primary.toArgb() + canvas.drawColor(bgColor) + + val gridData = getAnsiGrid(bitmapWidth, bitmapHeight, fgColor) + if (gridData != null) { + val (grid, charWidth, charHeight) = gridData + drawTerminalContent(grid, activePeers, isForCollapsedView = false) + // Pass charWidth and charHeight to the updated render function + grid.render(canvas, charWidth, charHeight) + } + return bitmap + } + + // --- Generative ANSI Art Functions --- + + /** + * Main drawing function to orchestrate the creation of the terminal UI. + * Switches between collapsed and expanded layouts. + */ + private fun drawTerminalContent(grid: AnsiGrid, peers: List, isForCollapsedView: Boolean) { + grid.clear() + if (isForCollapsedView) { + drawCollapsedContent(grid, peers) + } else { + drawExpandedContent(grid, peers) } } /** - * Draws a simple text string onto the grid, typically in a corner. + * Draws the content for the expanded notification view. */ - private fun drawStatusText(grid: AnsiGrid, text: String) { - val yPos = grid.height - 1 // Bottom row - val xPos = grid.width - text.length - 1 // Right-aligned - if (xPos < 0) return // Don't draw if it doesn't fit + private fun drawExpandedContent(grid: AnsiGrid, peers: List) { + drawBitchatLogo(grid) - grid.drawText(xPos, yPos, text, AnsiChars.Palette.primary) + val peerCountText = "peers: ${peers.size}" + val xPos = grid.width - peerCountText.length - 1 + if (xPos >= 0) { + grid.drawText(xPos, 0, peerCountText) + } + + val startX = 15 + val startY = 1 + val maxPeers = (grid.height - startY) + val availableWidth = grid.width - startX + drawPeerList(grid, peers, startX, startY, maxPeers, availableWidth) } + /** + * Draws a compact, info-rich layout for the collapsed notification view. + */ + private fun drawCollapsedContent(grid: AnsiGrid, peers: List) { + drawBitchatLogo(grid) - private fun createCollapsedRemoteViews(): RemoteViews { - val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme + val peerCountText = "peers: ${peers.size}" + val xPos = grid.width - peerCountText.length - 1 + if (xPos >= 0) { + grid.drawText(xPos, 0, peerCountText) + } - // We must estimate the width for the initial bitmap render. - // The notification layout will stretch the ImageView, and our bitmap will scale. - // 200dp is a reasonable estimate for the available space. - val density = resources.displayMetrics.density - val estimatedBitmapWidthPx = (200 * density).toInt() - val bitmapHeightPx = (48 * density).toInt() + val startX = 15 + val startY = 1 + val maxPeers = (grid.height - startY).coerceAtMost(3) + val availableWidth = grid.width - startX + drawPeerList(grid, peers, startX, startY, maxPeers, availableWidth) + } + /** + * Draws the "bitchat" logo and name side-by-side for the expanded view. + */ + private fun drawBitchatLogo(grid: AnsiGrid) { + val logoOutline = listOf( + " ╓─╮ ─╥─ ─╥─", + " ╟─┤ ║ ║ ", + " ╙─╯ ─╨─ ╜ ", + " bitchat " + ) + + logoOutline.forEachIndexed { index, line -> + grid.drawText(0, index, line) + } + } + + + /** + * Draws a list of peers at a specified location with gradient proximity bars. + */ + private fun drawPeerList(grid: AnsiGrid, peers: List, startX: Int, startY: Int, maxPeers: Int, availableWidth: Int) { + val proximityGradient = listOf(AnsiChars.Shade.LIGHT, AnsiChars.Shade.MEDIUM, AnsiChars.Shade.DARK, AnsiChars.Block.FULL) + + peers.take(maxPeers).forEachIndexed { index, peer -> + val yPos = startY + index + if (yPos >= grid.height) return@forEachIndexed // Stop if we run out of space + + val bars = proximityGradient.take(peer.proximity).joinToString("") + val padding = AnsiChars.line(' ', 4 - peer.proximity) + val proximityString = "[$bars$padding]" + + // Ensure the nickname doesn't overflow the available space. + val maxNicknameLength = (availableWidth - proximityString.length - 1).coerceAtLeast(1) + val nickname = if (peer.nickname.length > maxNicknameLength) { + peer.nickname.take(maxNicknameLength) + } else { + peer.nickname + } + val text = String.format("%-${maxNicknameLength}s %s", nickname, proximityString) + grid.drawText(startX, yPos, text) + } + } + + private fun getRemoteViewDimensions(@LayoutRes layoutId: Int, viewId: Int): Pair? { + val remoteViews = RemoteViews(packageName, layoutId) + val layout = remoteViews.apply(applicationContext, null) ?: return null + val targetView = layout.findViewById(viewId) ?: return null + if (targetView.width == 0 || targetView.height == 0) { + // If the view hasn't been measured yet, try to force a measure pass + targetView.measure( + android.view.View.MeasureSpec.makeMeasureSpec(resources.displayMetrics.widthPixels, android.view.View.MeasureSpec.AT_MOST), + android.view.View.MeasureSpec.makeMeasureSpec(resources.displayMetrics.heightPixels, android.view.View.MeasureSpec.AT_MOST) + ) + } + return if (targetView.measuredWidth > 0 && targetView.measuredHeight > 0) { + Pair(targetView.measuredWidth, targetView.measuredHeight) + } else null + } + + private fun createCollapsedRemoteViews(): RemoteViews { + val dimensions = getRemoteViewDimensions(R.layout.notification_terminal_collapsed, R.id.notification_render) + val bitmapWidthPx = dimensions?.first ?: (resources.displayMetrics.widthPixels).toInt() + val bitmapHeightPx = dimensions?.second ?: (48 * resources.displayMetrics.density).toInt() return RemoteViews(packageName, R.layout.notification_terminal_collapsed).apply { - setTextColor(R.id.notification_cursor, colors.primary.toArgb()) - setTextColor(R.id.notification_title, colors.primary.toArgb()) - setImageViewBitmap(R.id.notification_render, getCollapsedRenderBitmap(estimatedBitmapWidthPx, bitmapHeightPx)) + setImageViewBitmap(R.id.notification_render, getCollapsedRenderBitmap(bitmapWidthPx, bitmapHeightPx)) } } private fun createExpandedRemoteViews(): RemoteViews { - val stopPendingIntent = createActionPendingIntent(ACTION_STOP_SERVICE) - val mutePendingIntent = createActionPendingIntent(ACTION_MUTE) val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme - - val primaryColor = colors.primary.toArgb() val dimColor = if (isDarkTheme) Color(0xB3FFFFFF).toArgb() else colors.onSurface.toArgb() - val veryDimColor = if (isDarkTheme) Color(0x80FFFFFF).toArgb() else Color(0x99000000).toArgb() - val dividerColor = if (isDarkTheme) Color(0x4039FF14).toArgb() else Color(0x40000000).toArgb() - val peerProximityColor = if (isDarkTheme) Color(0xFF8AFF8A).toArgb() else Color(0xFF006600).toArgb() + val dimensions = getRemoteViewDimensions(R.layout.notification_terminal_expanded, R.id.notification_render) + val bitmapWidthPx = dimensions?.first ?: resources.displayMetrics.widthPixels + val bitmapHeightPx = dimensions?.second ?: (208 * resources.displayMetrics.density).toInt() return RemoteViews(packageName, R.layout.notification_terminal_expanded).apply { - // --- Set Themed Colors for static elements --- - setTextColor(R.id.notification_title_expanded, primaryColor) - setTextColor(R.id.peer_list_header, veryDimColor) - setTextColor(R.id.log_header, veryDimColor) - setTextColor(R.id.notification_divider, dividerColor) + setImageViewBitmap(R.id.notification_render, getExpandedRenderBitmap(bitmapWidthPx, bitmapHeightPx)) + + // Set colors for the action buttons setTextColor(R.id.notification_action_mute, dimColor) setTextColor(R.id.notification_action_stop_expanded, dimColor) - // --- Populate Peer List from live data --- - removeAllViews(R.id.notification_peer_list) - activePeers.take(5).forEach { peer -> // Show top 5 peers - val peerView = RemoteViews(packageName, R.layout.notification_peer_item_terminal).apply { - setTextViewText(R.id.peer_proximity_bar, getProximityBar(peer.proximity)) - setTextColor(R.id.peer_proximity_bar, peerProximityColor) - setTextViewText(R.id.peer_name, peer.nickname) - setTextColor(R.id.peer_name, dimColor) - } - addView(R.id.notification_peer_list, peerView) - } - - // --- Populate Message Log from live data --- - removeAllViews(R.id.notification_message_log) - recentMessages.take(4).forEach { message -> - try { - val lineView = RemoteViews(packageName, R.layout.notification_line_item).apply { - // Defensive coding: handle potential nulls to prevent crashes - val sender = message.sender?.take(8) ?: "unknown" - val content = message.content ?: "[empty message]" - val formattedMessage = "<$sender> $content" - setTextViewText(R.id.line_item_text, formattedMessage) - setTextColor(R.id.line_item_text, dimColor) - } - addView(R.id.notification_message_log, lineView) - } catch (e: Exception) { - Log.e(TAG, "Failed to create message view for log", e) - } - } - - // --- Set Actions --- - setOnClickPendingIntent(R.id.notification_action_mute, mutePendingIntent) - setOnClickPendingIntent(R.id.notification_action_stop_expanded, stopPendingIntent) + // Set Actions + setOnClickPendingIntent(R.id.notification_action_mute, createActionPendingIntent(ACTION_MUTE)) + setOnClickPendingIntent(R.id.notification_action_stop_expanded, createActionPendingIntent(ACTION_STOP_SERVICE)) } } @@ -442,27 +430,20 @@ class ForegroundService : Service(), BluetoothMeshDelegate { rssi > -70 -> 3 // Good rssi > -80 -> 2 // Fair rssi > -95 -> 1 // Weak - else -> 0 // Very weak / No signal + else -> 0 // Very weak } } - private fun getProximityBar(proximity: Int): String { - val filledChar = "▆" - val emptyChar = " " - return "[${filledChar.repeat(proximity)}${emptyChar.repeat(4 - proximity)}]" - } - private fun startUiUpdater() { if (::uiUpdateScheduler.isInitialized && !uiUpdateScheduler.isShutdown) return uiUpdateScheduler = Executors.newSingleThreadScheduledExecutor() - // Update the UI every 250ms for smoother animation uiUpdateScheduler.scheduleWithFixedDelay({ - // Post the update to the main thread if you encounter issues + frame++ // Increment animation frame updateNotification(false) - }, 0, 250, TimeUnit.MILLISECONDS) + }, 0, 2000, TimeUnit.MILLISECONDS) // Update every 2 seconds } - // --- Boilerplate (Intents, Binder, etc.) --- + // --- Boilerplate --- inner class LocalBinder : Binder() { fun getService(): ForegroundService = this@ForegroundService @@ -477,7 +458,6 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private val notificationActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "Notification action received: ${intent?.action}") when (intent?.action) { ACTION_STOP_SERVICE -> stopForegroundServiceAndApp() ACTION_MUTE -> Log.d(TAG, "Mute action tapped") @@ -493,13 +473,11 @@ class ForegroundService : Service(), BluetoothMeshDelegate { } private fun createNotificationChannel() { - // Use IMPORTANCE_DEFAULT to allow sound/vibration for new peer alerts val serviceChannel = NotificationChannel( FOREGROUND_CHANNEL_ID, "Bitchat Active Service", NotificationManager.IMPORTANCE_DEFAULT ).apply { description = "Keeps Bitchat connected and shows live status" setShowBadge(false) - // Disable vibration/sound by default; we will trigger it manually enableVibration(false) setSound(null, null) } @@ -512,11 +490,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { } private fun createActionPendingIntent(action: String): PendingIntent { - // Create an implicit intent that will be caught by the dynamically registered receiver. - val intent = Intent(action).apply { - // Explicitly set the package to ensure the broadcast is only handled within this app. - `package` = packageName - } + 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/util/AnsiChars.kt b/app/src/main/java/com/bitchat/android/util/AnsiChars.kt index 1c76d28d9..de414ec5c 100644 --- a/app/src/main/java/com/bitchat/android/util/AnsiChars.kt +++ b/app/src/main/java/com/bitchat/android/util/AnsiChars.kt @@ -1,246 +1,191 @@ +package com.bitchat.android.util + import android.graphics.Bitmap import android.graphics.Color -import kotlin.math.abs -import kotlin.math.roundToInt import androidx.core.graphics.get import androidx.core.graphics.scale +import kotlin.math.abs +import kotlin.random.Random /** * A helper object providing compile-time constants and utility functions * for creating terminal-based art and user interfaces (TUI). * - * This object includes constants for box-drawing, shades, and symbols, - * as well as helper functions for drawing shapes, gradients, and converting - * Android Bitmaps to ANSI/ASCII art in both grayscale and 256-color. + * This object includes a comprehensive set of constants for Code Page 437 + * (the original IBM PC character set), including box-drawing, shades, and symbols. + * It also contains helper functions for converting Android Bitmaps to ANSI/ASCII art, + * including advanced techniques like Braille art. */ object AnsiChars { - // --- General Characters --- - const val EMPTY_CHAR = ' ' // Standard space - const val BRAILLE_BLANK = '⠀' // Braille Pattern Blank (U+2800), an invisible space. - const val ESC = '\u001B' // ANSI Escape character - - // --- Single-Line Box Drawing --- - const val BOX_HORIZONTAL = '─' - const val BOX_VERTICAL = '│' - const val BOX_DOWN_AND_RIGHT = '┌' - const val BOX_DOWN_AND_LEFT = '┐' - const val BOX_UP_AND_RIGHT = '└' - const val BOX_UP_AND_LEFT = '┘' - const val BOX_VERTICAL_AND_RIGHT = '├' - const val BOX_VERTICAL_AND_LEFT = '┤' - const val BOX_DOWN_AND_HORIZONTAL = '┬' - const val BOX_UP_AND_HORIZONTAL = '┴' - const val BOX_VERTICAL_AND_HORIZONTAL = '┼' - - // --- Rounded-Corner Box Drawing --- - const val BOX_ROUNDED_DOWN_AND_RIGHT = '╭' - const val BOX_ROUNDED_DOWN_AND_LEFT = '╮' - const val BOX_ROUNDED_UP_AND_RIGHT = '╰' - const val BOX_ROUNDED_UP_AND_LEFT = '╯' - - // --- Double-Line Box Drawing --- - const val BOX_DOUBLE_HORIZONTAL = '═' - const val BOX_DOUBLE_VERTICAL = '║' - const val BOX_DOUBLE_DOWN_AND_RIGHT = '╔' - const val BOX_DOUBLE_DOWN_AND_LEFT = '╗' - const val BOX_DOUBLE_UP_AND_RIGHT = '╚' - const val BOX_DOUBLE_UP_AND_LEFT = '╝' - const val BOX_DOUBLE_VERTICAL_AND_RIGHT = '╠' - const val BOX_DOUBLE_VERTICAL_AND_LEFT = '╣' - const val BOX_DOUBLE_DOWN_AND_HORIZONTAL = '╦' - const val BOX_DOUBLE_UP_AND_HORIZONTAL = '╩' - const val BOX_DOUBLE_VERTICAL_AND_HORIZONTAL = '╬' - - // --- Block Elements --- - const val BLOCK_FULL = '█' - const val BLOCK_UPPER_HALF = '▀' - const val BLOCK_LOWER_HALF = '▄' - const val BLOCK_LEFT_HALF = '▌' - const val BLOCK_RIGHT_HALF = '▐' - const val BLOCK_UPPER_LEFT_QUADRANT = '▘' - const val BLOCK_UPPER_RIGHT_QUADRANT = '▝' - const val BLOCK_LOWER_LEFT_QUADRANT = '▖' - const val BLOCK_LOWER_RIGHT_QUADRANT = '▗' - const val BLOCK_QUADRANT_UPPER_LEFT_AND_LOWER_RIGHT = '▚' - const val BLOCK_QUADRANT_UPPER_RIGHT_AND_LOWER_LEFT = '▞' - - - // --- Shades --- - const val SHADE_LIGHT = '░' - const val SHADE_MEDIUM = '▒' - const val SHADE_DARK = '▓' - - // --- Braille Patterns (useful for small icons) --- - const val BRAILLE_DOTS_1 = '⠁' - const val BRAILLE_DOTS_12 = '⠃' - const val BRAILLE_DOTS_123 = '⠇' - const val BRAILLE_DOTS_245 = '⠚' // Looks like a person/peer icon - - // --- ASCII Character Ramps for Image Conversion --- - const val ASCII_RAMP_SIMPLE = "@%#*+=-:. " // Darkest to lightest - const val ASCII_RAMP_DETAILED = "\$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " - - // --- Other Symbols --- - const val CHECKMARK = '✓' - const val CROSS = '✗' - const val TRIANGLE_UP = '▲' - const val TRIANGLE_DOWN = '▼' - const val TRIANGLE_LEFT = '◀' - const val TRIANGLE_RIGHT = '▶' - - // --- IRC Control Codes --- - const val IRC_BOLD = '\u0002' - const val IRC_COLOR = '\u0003' - const val IRC_REVERSE = '\u0016' - const val IRC_UNDERLINE = '\u001F' - const val IRC_RESET = '\u000F' - + // --- General & Control Characters --- + const val EMPTY_CHAR = ' ' + const val BRAILLE_BLANK = '⠀' // An invisible character that still takes up space + const val ESC = '\u001B' /** - * A simple, theme-aware color palette for the terminal UI. - * These should be initialized with colors from the app's theme. + * Characters for drawing boxes and lines. + * Based on the classic Code Page 437. */ - object Palette { - var primary: Int = android.graphics.Color.GREEN - var secondary: Int = android.graphics.Color.WHITE - var background: Int = android.graphics.Color.BLACK - var accent: Int = android.graphics.Color.CYAN - - fun initialize(isDarkTheme: Boolean) { - if (isDarkTheme) { - primary = android.graphics.Color.rgb(0x39, 0xFF, 0x14) // Bright Green - secondary = android.graphics.Color.LTGRAY - background = android.graphics.Color.BLACK - accent = android.graphics.Color.rgb(0x00, 0xFF, 0xFF) // Cyan - } else { - primary = android.graphics.Color.DKGRAY - secondary = android.graphics.Color.GRAY - background = android.graphics.Color.WHITE - accent = android.graphics.Color.BLUE - } + object Box { + object Single { + const val H = '─'; const val V = '│' + const val DR = '┌'; const val DL = '┐' + const val UR = '└'; const val UL = '┘' + const val VR = '├'; const val VL = '┤' + const val DH = '┬'; const val UH = '┴'; const val VH = '┼' } - fun getProximityColor(proximity: Int, maxProximity: Int = 4): Int { - // Simple gradient from dim to bright based on proximity (0-4) - val alpha = (60 + (proximity.toFloat() / maxProximity) * 195).toInt().coerceIn(0, 255) - return android.graphics.Color.argb(alpha, - android.graphics.Color.red(primary), - android.graphics.Color.green(primary), - android.graphics.Color.blue(primary) - ) + object Double { + const val H = '═'; const val V = '║' + const val DR = '╔'; const val DL = '╗' + const val UR = '╚'; const val UL = '╝' + const val VR = '╠'; const val VL = '╣' + const val DH = '╦'; const val UH = '╩'; const val VH = '╬' } - } - - // --- Helper Functions for Drawing --- + object Rounded { + const val DR = '╭'; const val DL = '╮' + const val UR = '╰'; const val UL = '╯' + } - /** - * Creates a string by repeating a character a specified number of times. - */ - fun line(char: Char, length: Int): String { - if (length <= 0) return "" - return char.toString().repeat(length) + object Mixed { + const val V_S_H_D = '╪'; const val V_D_H_S = '╫' + const val DR_S_V_D_H = '╒'; const val DR_D_V_S_H = '╓' + const val DL_S_V_D_H = '╕'; const val DL_D_V_S_H = '╖' + const val UR_S_V_D_H = '╘'; const val UR_D_V_S_H = '╙' + const val UL_S_V_D_H = '╛'; const val UL_D_V_S_H = '╜' + const val VR_S_V_D_H = '╞'; const val VR_D_V_S_H = '╟' + const val VL_S_V_D_H = '╡'; const val VL_D_V_S_H = '╢' + const val DH_S_V_D_H = '╤'; const val DH_D_V_S_H = '╥' + const val UH_S_V_D_H = '╧'; const val UH_D_V_S_H = '╨' + } } /** - * Draws a simple box using single-line characters. + * Block elements for filling areas, creating gradients, or pixel-like effects. */ - fun simpleBox(width: Int, height: Int, rounded: Boolean = false): String { - if (width < 2 || height < 2) return "" - - val tl = if(rounded) BOX_ROUNDED_DOWN_AND_RIGHT else BOX_DOWN_AND_RIGHT - val tr = if(rounded) BOX_ROUNDED_DOWN_AND_LEFT else BOX_DOWN_AND_LEFT - val bl = if(rounded) BOX_ROUNDED_UP_AND_RIGHT else BOX_UP_AND_RIGHT - val br = if(rounded) BOX_ROUNDED_UP_AND_LEFT else BOX_UP_AND_LEFT - - val top = "$tl${line(BOX_HORIZONTAL, width - 2)}$tr" - val bottom = "$bl${line(BOX_HORIZONTAL, width - 2)}$br" + object Block { + const val FULL = '█'; const val UPPER_H = '▀'; const val LOWER_H = '▄' + const val LEFT_H = '▌'; const val RIGHT_H = '▐' + const val LOWER_1_8 = ' '; const val LOWER_1_4 = '▂'; const val LOWER_3_8 = '▃' + const val LOWER_5_8 = '▅'; const val LOWER_3_4 = '▆'; const val LOWER_7_8 = '▇' + const val UPPER_1_8 = '▔' + + object Quadrant { + const val UL = '▘'; const val UR = '▝'; const val LL = '▖'; const val LR = '▗' + const val UL_LR = '▚'; const val UR_LL = '▞' + const val UL_UR_LL = '▛'; const val UL_UR_LR = '▜' + const val UL_LL_LR = '▙'; const val UR_LL_LR = '▟' + } + } - if (height == 2) return "$top\n$bottom" + object Shade { + const val LIGHT = '░'; const val MEDIUM = '▒'; const val DARK = '▓' + val GRADIENT = listOf(LIGHT, MEDIUM, DARK, Block.FULL) + } - val middleContent = " ".repeat(width - 2) - val middle = "$BOX_VERTICAL$middleContent$BOX_VERTICAL" - val middleRows = List(height - 2) { middle }.joinToString("\n") + object Irc { + const val BOLD = '\u0002'; const val COLOR = '\u0003'; const val REVERSE = '\u0016' + const val UNDERLINE = '\u001F'; const val RESET = '\u000F' + } - return "$top\n$middleRows\n$bottom" + object Shapes { + const val CHECK = '✓'; const val CROSS = '✗' + const val TRI_U = '▲'; const val TRI_D = '▼'; const val TRI_L = '◀'; const val TRI_R = '▶' + const val SQ_S_F = '▪'; const val SQ_F = '■'; const val SQ_S = '▫'; const val SQ = '□' + const val CIRC_F = '●'; const val CIRC = '○' + const val DOT = '⋅'; const val BULLET = '•' + const val DIAM_F = '◆'; const val DIAM = '◇' } /** - * Draws a box with a title embedded in the top border. + * The complete set of iconic, mathematical, and international symbols from Code Page 437. */ - fun titledBox(title: String, width: Int, useDoubleLine: Boolean = false): String { - val cleanTitle = " $title " - if (width < cleanTitle.length + 2) return "" + object Cp437Symbols { + const val SMILEY_W = '☺'; const val SMILEY_B = '☻'; const val HEART = '♥' + const val DIAMOND = '♦'; const val CLUB = '♣'; const val SPADE = '♠' + const val BULLET_H = '○'; const val BULLET_F = '●'; const val MALE = '♂'; const val FEMALE = '♀' + const val NOTE_1 = '♪'; const val NOTE_2 = '♫'; const val SUN = '☼' + const val ARROW_R_F = '►'; const val ARROW_L_F = '◄'; const val ARROW_UD = '↕' + const val EXCLAM_D = '‼'; const val PILCROW = '¶'; const val SECTION = '§' + const val CURSOR_R = '▬'; const val ARROW_UD_B = '↨'; const val ARROW_U = '↑' + const val ARROW_D = '↓'; const val ARROW_R = '→'; const val ARROW_L = '←' + const val ANGLE_R = '∟'; const val ARROW_LR = '↔'; const val HOUSE = '⌂' + const val C_CEDILLA_U = 'Ç'; const val U_DIAERESIS_L = 'ü'; const val E_ACUTE_L = 'é' + const val A_CIRCUMFLEX_L = 'â'; const val A_DIAERESIS_L = 'ä'; const val A_GRAVE_L = 'à' + const val A_RING_L = 'å'; const val C_CEDILLA_L = 'ç'; const val E_CIRCUMFLEX_L = 'ê' + const val E_DIAERESIS_L = 'ë'; const val E_GRAVE_L = 'è'; const val I_DIAERESIS_L = 'ï' + const val I_CIRCUMFLEX_L = 'î'; const val I_GRAVE_L = 'ì'; const val A_DIAERESIS_U = 'Ä' + const val A_RING_U = 'Å'; const val E_ACUTE_U = 'É'; const val AE_L = 'æ'; const val AE_U = 'Æ' + const val O_CIRCUMFLEX_L = 'ô'; const val O_DIAERESIS_L = 'ö'; const val O_GRAVE_L = 'ò' + const val U_CIRCUMFLEX_L = 'û'; const val U_GRAVE_L = 'ù'; const val Y_DIAERESIS_L = 'ÿ' + const val O_DIAERESIS_U = 'Ö'; const val U_DIAERESIS_U = 'Ü'; const val CENT = '¢' + const val POUND = '£'; const val YEN = '¥'; const val PESETA = '₧'; const val F_HOOK = 'ƒ' + const val A_ACUTE_L = 'á'; const val I_ACUTE_L = 'í'; const val O_ACUTE_L = 'ó' + const val U_ACUTE_L = 'ú'; const val N_TILDE_L = 'ñ'; const val N_TILDE_U = 'Ñ' + const val ORDINAL_F = 'ª'; const val ORDINAL_M = 'º'; const val Q_MARK_INV = '¿' + const val NOT_REV = '⌐'; const val NOT = '¬'; const val HALF = '½'; const val QUARTER = '¼' + const val EXCLAM_INV = '¡'; const val CHEVRON_L = '«'; const val CHEVRON_R = '»' + const val ALPHA = 'α'; const val BETA_SHARP_S = 'ß'; const val GAMMA_U = 'Γ'; const val PI = 'π' + const val SIGMA_U = 'Σ'; const val SIGMA_L = 'σ'; const val MU = 'µ'; const val TAU = 'τ' + const val PHI_U = 'Φ'; const val THETA_U = 'Θ'; const val OMEGA_U = 'Ω'; const val DELTA_L = 'δ' + const val INFINITY = '∞'; const val PHI_L = 'φ'; const val EPSILON = 'ε' + const val INTERSECTION = '∩'; const val TRIPLE_BAR = '≡'; const val PLUS_MINUS = '±' + const val GTE = '≥'; const val LTE = '≤'; const val INTEGRAL_T = '⌠'; const val INTEGRAL_B = '⌡' + const val DIV = '÷'; const val ALMOST_EQ = '≈'; const val DEGREE = '°' + const val BULLET_OP = '∙'; const val INTERPUNCT = '·'; const val SQRT = '√' + const val POWER_N = 'ⁿ'; const val SQUARE = '²'; const val CURSOR_B = '■' + } - val hChar = if (useDoubleLine) BOX_DOUBLE_HORIZONTAL else BOX_HORIZONTAL - val tlChar = if (useDoubleLine) BOX_DOUBLE_DOWN_AND_RIGHT else BOX_DOWN_AND_RIGHT - val trChar = if (useDoubleLine) BOX_DOUBLE_DOWN_AND_LEFT else BOX_DOWN_AND_LEFT - val blChar = if (useDoubleLine) BOX_DOUBLE_UP_AND_RIGHT else BOX_UP_AND_RIGHT - val brChar = if (useDoubleLine) BOX_DOUBLE_UP_AND_LEFT else BOX_UP_AND_LEFT + object AsciiRamp { + const val SIMPLE = "@%#*+=-:. " // Darkest to lightest + const val DETAILED = "\$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " + } - val remainingWidth = width - cleanTitle.length - 2 - val leftPad = remainingWidth / 2 - val rightPad = remainingWidth - leftPad + /** + * Interesting Unicode characters and sequences for special effects. + */ + object Tricks { + // For creating "corrupted" or "glitch" text (Zalgo) + val ZALGO_UP = charArrayOf('\u030d', '\u030e', '\u0304', '\u0305', '\u033f', '\u0311', '\u0306', '\u0310', '\u0352', '\u0357', '\u0351', '\u0307', '\u0308', '\u030a', '\u0342', '\u0343', '\u0344', '\u034a', '\u034b', '\u034c', '\u0350', '\u0358', '\u035b', '\u035d', '\u035e', '\u035f', '\u0360', '\u0361', '\u0362') + val ZALGO_DOWN = charArrayOf('\u0316', '\u0317', '\u0318', '\u0319', '\u031c', '\u031d', '\u031e', '\u031f', '\u0320', '\u0324', '\u0325', '\u0326', '\u0329', '\u032a', '\u032b', '\u032c', '\u032d', '\u032e', '\u032f', '\u0330', '\u0331', '\u0332', '\u0333', '\u0339', '\u033a', '\u033b', '\u033c') + val ZALGO_MID = charArrayOf('\u0334', '\u0335', '\u0336', '\u0337', '\u0338', '\u033d', '\u033e', '\u0345', '\u0346', '\u0347', '\u0348', '\u0349', '\u034d', '\u034e', '\u0353', '\u0354', '\u0355', '\u0356', '\u0359', '\u035a', '\u035c') + } - val top = "$tlChar${line(hChar, leftPad)}$cleanTitle${line(hChar, rightPad)}$trChar" - val bottom = "$blChar${line(hChar, width - 2)}$brChar" + // --- Helper Functions and Tricks --- - return "$top\n$bottom" + fun line(char: Char, length: Int): String { + if (length <= 0) return "" + return char.toString().repeat(length) } - // --- Color & Gradient Helpers --- - - /** - * Wraps a string with ANSI 256-color escape codes. - * @param text The string to colorize. - * @param colorCode A color code between 0 and 255. - * @return The colorized string. - */ fun colorize(text: String, colorCode: Int): String { val code = colorCode.coerceIn(0, 255) return "$ESC[38;5;${code}m$text$ESC[0m" } - /** - * Creates a horizontal gradient using shade characters. - */ - fun gradient(width: Int, reversed: Boolean = false): String { - if (width <= 0) return "" - val gradientChars = listOf(SHADE_LIGHT, SHADE_MEDIUM, SHADE_DARK, BLOCK_FULL) - val finalChars = if (reversed) gradientChars.reversed() else gradientChars - - val segmentWidth = width / finalChars.size - val remainder = width % finalChars.size - - val sb = StringBuilder(width) - finalChars.forEachIndexed { index, char -> - val len = segmentWidth + if (index < remainder) 1 else 0 - sb.append(char.toString().repeat(len)) + private fun rgbToAnsi256(r: Int, g: Int, b: Int): Int { + if (abs(r - g) < 8 && abs(g - b) < 8) { + val gray = (r + g + b) / 3 + if (gray > 238) return 231; if (gray < 18) return 16 + return 232 + ((gray - 8) / 10) } - return sb.toString() + val rAnsi = (r * 5 / 255); val gAnsi = (g * 5 / 255); val bAnsi = (b * 5 / 255) + return 16 + (36 * rAnsi) + (6 * gAnsi) + bAnsi } - // --- Image Conversion Helpers --- - - /** - * Converts an Android Bitmap into a grayscale ANSI/ASCII art string. - */ - fun imageToAnsi(bitmap: Bitmap, targetWidth: Int, detailed: Boolean = false): String { + fun imageToAnsiColor(bitmap: Bitmap, targetWidth: Int): String { if (targetWidth <= 0) return "" - - val ramp = if (detailed) ASCII_RAMP_DETAILED else ASCII_RAMP_SIMPLE val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() val targetHeight = (targetWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) val scaledBitmap = bitmap.scale(targetWidth, targetHeight) - val sb = StringBuilder(targetWidth * targetHeight) - + val sb = StringBuilder() for (y in 0 until scaledBitmap.height) { for (x in 0 until scaledBitmap.width) { val pixel = scaledBitmap[x, y] - val brightness = (0.299 * Color.red(pixel) + 0.587 * Color.green(pixel) + 0.114 * Color.blue(pixel)).toInt() - val rampIndex = (brightness / 255.0 * (ramp.length - 1)).roundToInt() - sb.append(ramp.reversed()[rampIndex]) + val colorCode = rgbToAnsi256(Color.red(pixel), Color.green(pixel), Color.blue(pixel)) + sb.append(colorize(Block.FULL.toString(), colorCode)) } sb.append('\n') } @@ -248,22 +193,21 @@ object AnsiChars { } /** - * Converts an Android Bitmap into a 256-color ANSI art string. - * Uses the full block character for each "pixel" and colorizes it. + * Converts an image to grayscale ASCII art. + * @param ramp The string of characters to use for shading, from darkest to lightest. */ - fun imageToAnsiColor(bitmap: Bitmap, targetWidth: Int): String { + fun imageToGrayscale(bitmap: Bitmap, targetWidth: Int, ramp: String = AsciiRamp.SIMPLE): String { if (targetWidth <= 0) return "" - val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() val targetHeight = (targetWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) val scaledBitmap = bitmap.scale(targetWidth, targetHeight) val sb = StringBuilder() - for (y in 0 until scaledBitmap.height) { for (x in 0 until scaledBitmap.width) { val pixel = scaledBitmap[x, y] - val colorCode = rgbToAnsi256(Color.red(pixel), Color.green(pixel), Color.blue(pixel)) - sb.append(colorize(BLOCK_FULL.toString(), colorCode)) + val gray = (Color.red(pixel) * 0.299 + Color.green(pixel) * 0.587 + Color.blue(pixel) * 0.114).toInt() + val rampIndex = (gray * (ramp.length - 1)) / 255 + sb.append(ramp[rampIndex]) } sb.append('\n') } @@ -271,20 +215,50 @@ object AnsiChars { } /** - * Maps an RGB color to the nearest color in the xterm 256-color palette. + * Converts an image to high-resolution text art using Braille characters. + * Each Braille char is a 2x4 matrix of dots. */ - private fun rgbToAnsi256(r: Int, g: Int, b: Int): Int { - // Grayscale colors - if (abs(r - g) < 8 && abs(g - b) < 8) { - val gray = (r + g + b) / 3 - if (gray > 238) return 231 // White - if (gray < 18) return 16 // Black - return 232 + ((gray - 8) / 10) + fun imageToBraille(bitmap: Bitmap, targetWidth: Int, invert: Boolean = false): String { + if (targetWidth <= 0) return "" + val charWidth = targetWidth * 2 + val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() + val charHeight = (charWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) + val scaledBitmap = bitmap.scale(charWidth, charHeight) + val sb = StringBuilder() + + for (y in 0 until charHeight step 4) { + for (x in 0 until charWidth step 2) { + var brailleCode = 0x2800 + var dotValue = 0 + // Braille dots are numbered 1-8, column by column + if (y + 0 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y]) > 128) 1 else 0; brailleCode += dotValue * 1 + if (y + 1 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y + 1]) > 128) 1 else 0; brailleCode += dotValue * 2 + if (y + 2 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y + 2]) > 128) 1 else 0; brailleCode += dotValue * 4 + if (y + 0 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y]) > 128) 1 else 0; brailleCode += dotValue * 8 + if (y + 1 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y + 1]) > 128) 1 else 0; brailleCode += dotValue * 16 + if (y + 2 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y + 2]) > 128) 1 else 0; brailleCode += dotValue * 32 + if (y + 3 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y + 3]) > 128) 1 else 0; brailleCode += dotValue * 64 + if (y + 3 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y + 3]) > 128) 1 else 0; brailleCode += dotValue * 128 + + if (invert) brailleCode = 0x28FF - (brailleCode - 0x2800) + if (brailleCode == 0x2800) sb.append(EMPTY_CHAR) else sb.append(brailleCode.toChar()) + } + sb.append('\n') } - // 6x6x6 color cube - val rAnsi = (r * 5 / 255) - val gAnsi = (g * 5 / 255) - val bAnsi = (b * 5 / 255) - return 16 + (36 * rAnsi) + (6 * gAnsi) + bAnsi + return sb.toString() + } + + /** + * Applies "Zalgo" effect to text, making it look corrupted or demonic. + */ + fun zalgo(text: String, up: Int = 3, mid: Int = 2, down: Int = 3): String { + val sb = StringBuilder() + text.forEach { char -> + sb.append(char) + repeat(Random.nextInt(up)) { sb.append(Tricks.ZALGO_UP.random()) } + repeat(Random.nextInt(mid)) { sb.append(Tricks.ZALGO_MID.random()) } + repeat(Random.nextInt(down)) { sb.append(Tricks.ZALGO_DOWN.random()) } + } + return sb.toString() } } diff --git a/app/src/main/java/com/bitchat/android/util/AnsiGrid.kt b/app/src/main/java/com/bitchat/android/util/AnsiGrid.kt new file mode 100644 index 000000000..6d1a3ba61 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/AnsiGrid.kt @@ -0,0 +1,66 @@ +package com.bitchat.android.util + +import android.graphics.Canvas +import android.graphics.Paint + +/** + * A helper class to manage a grid of characters for ANSI-style art. + * This makes it easier to "draw" text, shapes, and other elements onto a character-based canvas. + */ +class AnsiGrid( + val width: Int, + val height: Int, + val textPaint: Paint +) { + // A grid of characters. + private val grid: Array> = Array(height) { + Array(width) { AnsiChars.EMPTY_CHAR } + } + + fun setChar(x: Int, y: Int, char: Char) { + if (x in 0 until width && y in 0 until height) { + grid[y][x] = char + } + } + + fun drawText(x: Int, y: Int, text: String) { + text.forEachIndexed { index, char -> + setChar(x + index, y, char) + } + } + + fun clear() { + for (y in 0 until height) { + for (x in 0 until width) { + grid[y][x] = AnsiChars.EMPTY_CHAR + } + } + } + + /** + * Renders the entire grid onto a Canvas, drawing character-by-character to enforce a strict grid. + * @param canvas The Android Canvas to draw on. + * @param charWidth The calculated width of a single character cell in pixels. + * @param charHeight The calculated height of a single character cell in pixels. + */ + fun render(canvas: Canvas, charWidth: Float, charHeight: Float) { + val baselineOffset = textPaint.fontMetrics.descent + + for (y in 0 until height) { + for (x in 0 until width) { + val charToDraw = grid[y][x] + // Skip drawing blank characters for a minor performance improvement. + if (charToDraw == AnsiChars.EMPTY_CHAR || charToDraw == AnsiChars.BRAILLE_BLANK) { + continue + } + + // Calculate the exact X and Y position for this character in the grid. + val drawX = x * charWidth + val drawY = (y + 1) * charHeight - baselineOffset + + // Draw the single character at the calculated position. + canvas.drawText(charToDraw.toString(), drawX, drawY, textPaint) + } + } + } +} diff --git a/app/src/main/res/layout/notification_terminal_collapsed.xml b/app/src/main/res/layout/notification_terminal_collapsed.xml index 70bb1edc2..6523465af 100644 --- a/app/src/main/res/layout/notification_terminal_collapsed.xml +++ b/app/src/main/res/layout/notification_terminal_collapsed.xml @@ -12,54 +12,11 @@ android:orientation="horizontal" android:padding="2dp"> - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/notification_terminal_expanded.xml b/app/src/main/res/layout/notification_terminal_expanded.xml index 9a3f24bea..31a1d0474 100644 --- a/app/src/main/res/layout/notification_terminal_expanded.xml +++ b/app/src/main/res/layout/notification_terminal_expanded.xml @@ -2,94 +2,48 @@ + android:paddingTop="2dp" + android:paddingHorizontal="2dp"> - - - - - - - - - + android:layout_height="208dp" + android:scaleType="fitXY" + tools:background="#80008000" + /> - - + android:layout_height="48dp" + android:gravity="center_vertical|end" + android:orientation="horizontal"> \ No newline at end of file From 1d548832122d8a037b98d0a9e3331a7add5a63e8 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Sun, 3 Aug 2025 15:19:58 -0700 Subject: [PATCH 04/13] Added shutdown UI --- .../java/com/bitchat/android/MainActivity.kt | 82 ++++++- .../java/com/bitchat/android/ui/ChatHeader.kt | 205 +++++++++++------- .../java/com/bitchat/android/ui/ChatScreen.kt | 35 +-- .../com/bitchat/android/ui/ChatViewModel.kt | 199 +++++++++-------- .../bitchat/android/ui/DialogComponents.kt | 73 ++++++- 5 files changed, 393 insertions(+), 201 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 4866fa297..378b805d9 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -7,6 +7,7 @@ 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,6 +16,8 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -39,8 +42,10 @@ 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.ExitConfirmationDialog import com.bitchat.android.ui.theme.BitchatTheme import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -59,10 +64,22 @@ class MainActivity : ComponentActivity() { @Volatile private var foregroundService: ForegroundService? = null @Volatile private var isServiceBound = false + // State for the exit confirmation dialog + private var showExitDialog by mutableStateOf(false) + private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { viewModelFactory { initializer { + // IMPORTANT: You must add the shutdown request flow to your ChatViewModel + // + // In ChatViewModel.kt: + // private val _shutdownRequest = MutableSharedFlow() + // val shutdownRequest = _shutdownRequest.asSharedFlow() + // + // fun requestShutdown() { + // viewModelScope.launch { _shutdownRequest.emit(Unit) } + // } ChatViewModel(application) } } @@ -92,8 +109,8 @@ class MainActivity : ComponentActivity() { } override fun onServiceStopping() { - Log.w(TAG, "ForegroundService stopping") - finish() + Log.w(TAG, "ForegroundService is stopping, closing the app.") + finishAndRemoveTask() } } @@ -127,6 +144,17 @@ class MainActivity : ComponentActivity() { onOnboardingFailed = ::handleOnboardingFailed ) + // Handle back press + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // If the chat view model handles the back press (e.g., closing a sidebar), + // do nothing. Otherwise, show our exit confirmation dialog. + if (!chatViewModel.handleBackPressed()) { + showExitDialog = true + } + } + }) + setContent { BitchatTheme { Surface( @@ -134,6 +162,20 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { OnboardingFlowScreen() + + // Display the exit dialog when its state is true + ExitConfirmationDialog( + show = showExitDialog, + onDismiss = { showExitDialog = false }, + onConfirmBackground = { + showExitDialog = false + moveTaskToBack(true) + }, + onConfirmExit = { + showExitDialog = false + stopServiceAndExit() + } + ) } } } @@ -141,12 +183,22 @@ class MainActivity : ComponentActivity() { // Collect state changes in a lifecycle-aware manner lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - mainViewModel.onboardingState.collect { state -> - handleOnboardingStateChange(state) + // Listen for onboarding state changes + launch { + mainViewModel.onboardingState.collect { state -> + handleOnboardingStateChange(state) + } + } + // Listen for shutdown requests from the ViewModel + launch { + chatViewModel.shutdownRequest.collect { + 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) { @@ -676,6 +728,20 @@ class MainActivity : ComponentActivity() { } } + /** + * 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.") + val intent = Intent(ForegroundService.ACTION_STOP_SERVICE).apply { + // Ensure the broadcast is delivered only to our app's receiver + `package` = packageName + } + sendBroadcast(intent) + } + + override fun onDestroy() { super.onDestroy() @@ -687,12 +753,4 @@ class MainActivity : ComponentActivity() { Log.w(TAG, "Error cleaning up location status manager: ${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/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index a5f90f848..953fd67d8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -192,68 +192,6 @@ fun PeerCounter( } } -@Composable -fun ChatHeaderContent( - selectedPrivatePeer: String?, - currentChannel: String?, - nickname: String, - viewModel: ChatViewModel, - onBackClick: () -> Unit, - onSidebarClick: () -> Unit, - onTripleClick: () -> Unit, - onShowAppInfo: () -> Unit -) { - val colorScheme = MaterialTheme.colorScheme - - when { - selectedPrivatePeer != null -> { - // Private chat header - Fully reactive state tracking - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) - - // Reactive favorite computation - no more static lookups! - val isFavorite = isFavoriteReactive( - peerID = selectedPrivatePeer, - peerFingerprints = peerFingerprints, - favoritePeers = favoritePeers - ) - val sessionState = peerSessionStates[selectedPrivatePeer] - - Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") - - PrivateChatHeader( - peerID = selectedPrivatePeer, - peerNicknames = viewModel.meshService.getPeerNicknames(), - isFavorite = isFavorite, - sessionState = sessionState, - onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } - ) - } - currentChannel != null -> { - // Channel header - ChannelHeader( - channel = currentChannel, - onBackClick = onBackClick, - onLeaveChannel = { viewModel.leaveChannel(currentChannel) }, - onSidebarClick = onSidebarClick - ) - } - else -> { - // Main header - MainHeader( - nickname = nickname, - onNicknameChange = viewModel::setNickname, - onTitleClick = onShowAppInfo, - onTripleTitleClick = onTripleClick, - onSidebarClick = onSidebarClick, - viewModel = viewModel - ) - } - } -} - @Composable private fun PrivateChatHeader( peerID: String, @@ -344,7 +282,7 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + Box(modifier = Modifier.fillMaxWidth()) { // Back button - positioned all the way to the left with minimal margin Button( @@ -375,7 +313,7 @@ private fun ChannelHeader( ) } } - + // Title - perfectly centered regardless of other elements Text( text = "channel: $channel", @@ -385,7 +323,7 @@ private fun ChannelHeader( .align(Alignment.Center) .clickable { onSidebarClick() } ) - + // Leave button - positioned on the right TextButton( onClick = onLeaveChannel, @@ -400,6 +338,68 @@ private fun ChannelHeader( } } +@Composable +fun ChatHeaderContent( + selectedPrivatePeer: String?, + currentChannel: String?, + nickname: String, + viewModel: ChatViewModel, + onBackClick: () -> Unit, + onSidebarClick: () -> Unit, + onTripleClick: () -> Unit, + onShowAppInfo: () -> Unit, + onShutdownClick: () -> Unit // New callback for shutdown +) { + val colorScheme = MaterialTheme.colorScheme + + when { + selectedPrivatePeer != null -> { + // Private chat header - Fully reactive state tracking + val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) + val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) + + val isFavorite = isFavoriteReactive( + peerID = selectedPrivatePeer, + peerFingerprints = peerFingerprints, + favoritePeers = favoritePeers + ) + val sessionState = peerSessionStates[selectedPrivatePeer] + + PrivateChatHeader( + peerID = selectedPrivatePeer, + peerNicknames = viewModel.meshService.getPeerNicknames(), + isFavorite = isFavorite, + sessionState = sessionState, + onBackClick = onBackClick, + onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } + ) + } + currentChannel != null -> { + // Channel header + ChannelHeader( + channel = currentChannel, + onBackClick = onBackClick, + onLeaveChannel = { viewModel.leaveChannel(currentChannel) }, + onSidebarClick = onSidebarClick + ) + } + else -> { + // Main header + MainHeader( + nickname = nickname, + onNicknameChange = viewModel::setNickname, + onTitleClick = onShowAppInfo, + onTripleTitleClick = onTripleClick, + onSidebarClick = onSidebarClick, + viewModel = viewModel, + onShutdownClick = onShutdownClick, // Pass down the shutdown callback + onPanicClear = onTripleClick + ) + } + } +} + @Composable private fun MainHeader( nickname: String, @@ -407,7 +407,9 @@ private fun MainHeader( onTitleClick: () -> Unit, onTripleTitleClick: () -> Unit, onSidebarClick: () -> Unit, - viewModel: ChatViewModel + viewModel: ChatViewModel, + onShutdownClick: () -> Unit, + onPanicClear: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) @@ -415,14 +417,15 @@ private fun MainHeader( val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val isConnected by viewModel.isConnected.observeAsState(false) - + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + // Left side: Logo and Nickname Row( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.weight(1f).fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -434,22 +437,64 @@ private fun MainHeader( onTripleClick = onTripleTitleClick ) ) - + Spacer(modifier = Modifier.width(2.dp)) - + NicknameEditor( value = nickname, onValueChange = onNicknameChange ) } - - PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, - joinedChannels = joinedChannels, - hasUnreadChannels = hasUnreadChannels, - hasUnreadPrivateMessages = hasUnreadPrivateMessages, - isConnected = isConnected, - onClick = onSidebarClick - ) + + // Right side: Peer Counter and Menu + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + PeerCounter( + connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, + joinedChannels = joinedChannels, + hasUnreadChannels = hasUnreadChannels, + hasUnreadPrivateMessages = hasUnreadPrivateMessages, + isConnected = isConnected, + onClick = onSidebarClick + ) + + var showMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More options", + tint = colorScheme.onSurface + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("App Info") }, + onClick = { + onTitleClick() + showMenu = false + } + ) + DropdownMenuItem( + text = { Text("Panic Clear Data") }, + onClick = { + onPanicClear() + showMenu = false + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Shut Down Service", color = colorScheme.error) }, + onClick = { + onShutdownClick() + showMenu = false + } + ) + } + } } -} +} \ 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 c4dd311c8..210fd51c5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -65,31 +65,31 @@ fun ChatScreen(viewModel: ChatViewModel) { val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val showAppInfo by viewModel.showAppInfo.observeAsState(false) - + var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } - + // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { showPasswordDialog = showPasswordPrompt } - + val isConnected by viewModel.isConnected.observeAsState(false) val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) - + // Determine what messages to show val displayMessages = when { selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList() currentChannel != null -> channelMessages[currentChannel] ?: emptyList() else -> messages } - + // Use WindowInsets to handle keyboard properly Box(modifier = Modifier.fillMaxSize()) { val headerHeight = 42.dp - + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -99,7 +99,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { // Header spacer - creates space for the floating header Spacer(modifier = Modifier.height(headerHeight)) - + // Messages area - takes up available space, will compress when keyboard appears MessagesList( messages = displayMessages, @@ -107,7 +107,7 @@ fun ChatScreen(viewModel: ChatViewModel) { meshService = viewModel.meshService, modifier = Modifier.weight(1f) ) - + // Input area - stays at bottom ChatInputSection( messageText = messageText, @@ -136,7 +136,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme ) } - + // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( headerHeight = headerHeight, @@ -149,7 +149,7 @@ fun ChatScreen(viewModel: ChatViewModel) { onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() } ) - + // Sidebar overlay AnimatedVisibility( visible = showSidebar, @@ -161,7 +161,7 @@ fun ChatScreen(viewModel: ChatViewModel) { targetOffsetX = { it }, animationSpec = tween(250, easing = EaseInCubic) ) + fadeOut(animationSpec = tween(250)), - modifier = Modifier.zIndex(2f) + modifier = Modifier.zIndex(2f) ) { SidebarOverlay( viewModel = viewModel, @@ -170,7 +170,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) } } - + // Dialogs ChatDialogs( showPasswordDialog = showPasswordDialog, @@ -215,7 +215,7 @@ private fun ChatInputSection( ) { Column { HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - + // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { CommandSuggestionsBox( @@ -226,7 +226,7 @@ private fun ChatInputSection( HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) } - + MessageInput( value = messageText, onValueChange = onMessageTextChange, @@ -277,7 +277,8 @@ private fun ChatFloatingHeader( }, onSidebarClick = onSidebarToggle, onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo + onShowAppInfo = onShowAppInfo, + onShutdownClick = { viewModel.requestShutdown() } ) }, colors = TopAppBarDefaults.topAppBarColors( @@ -285,7 +286,7 @@ private fun ChatFloatingHeader( ) ) } - + // Divider under header HorizontalDivider( modifier = Modifier @@ -316,7 +317,7 @@ private fun ChatDialogs( onConfirm = onPasswordConfirm, onDismiss = onPasswordDismiss ) - + // App info dialog AppInfoDialog( show = showAppInfo, 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 360434f8a..50fedc2f1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,6 +5,7 @@ import android.content.Context import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.bitchat.android.BitchatApplication @@ -15,6 +16,8 @@ import com.bitchat.android.model.DeliveryAck import com.bitchat.android.model.ReadReceipt import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.util.* import kotlin.random.Random @@ -35,25 +38,25 @@ class ChatViewModel( // State management private val state = ChatState() - + // Specialized managers private val dataManager = DataManager(application.applicationContext) private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) - + // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) - override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) + override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) override fun broadcastNoiseIdentityAnnouncement() = meshService.broadcastNoiseIdentityAnnouncement() override fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte) = meshService.sendHandshakeRequest(targetPeerID, pendingCount) override fun getMyPeerID(): String = meshService.myPeerID } - + 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, @@ -66,7 +69,7 @@ class ChatViewModel( getMyPeerID = { meshService.myPeerID }, getMeshService = { meshService } ) - + // Expose state through LiveData (maintaining the same interface) val messages: LiveData> = state.messages val connectedPeers: LiveData> = state.connectedPeers @@ -92,6 +95,32 @@ class ChatViewModel( val peerFingerprints: LiveData> = state.peerFingerprints val showAppInfo: LiveData = state.showAppInfo + // New LiveData to control the exit confirmation dialog + private val _showExitDialog = MutableLiveData(false) + val showExitDialog: LiveData = _showExitDialog + + // Flow to signal a shutdown request to the Activity + private val _shutdownRequest = MutableSharedFlow() + val shutdownRequest = _shutdownRequest.asSharedFlow() + + /** + * Requests a full shutdown of the service and app. + * Called from the header menu or the exit confirmation dialog. + */ + fun requestShutdown() { + viewModelScope.launch { + _shutdownRequest.emit(Unit) + } + _showExitDialog.value = false + } + + /** + * Dismisses the exit confirmation dialog. + */ + fun dismissExitConfirmation() { + _showExitDialog.value = false + } + fun initialize(meshService: BluetoothMeshService) { Log.d(TAG, "Initializing ChatViewModel") this.meshService = meshService @@ -103,12 +132,12 @@ class ChatViewModel( // Load nickname val nickname = dataManager.loadNickname() state.setNickname(nickname) - + // Load data val (joinedChannels, protectedChannels) = channelManager.loadChannelData() state.setJoinedChannels(joinedChannels) state.setPasswordProtectedChannels(protectedChannels) - + // Initialize channel messages joinedChannels.forEach { channel -> if (!state.getChannelMessagesValue().containsKey(channel)) { @@ -117,16 +146,16 @@ class ChatViewModel( state.setChannelMessages(updatedChannelMessages) } } - + // Load other data dataManager.loadFavorites() state.setFavoritePeers(dataManager.favoritePeers) dataManager.loadBlockedUsers() - + // Log all favorites at startup dataManager.logAllFavorites() logCurrentFavoriteState() - + // Initialize session state monitoring initializeSessionStateMonitoring() @@ -144,37 +173,37 @@ class ChatViewModel( } } } - + override fun onCleared() { super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } - + // MARK: - Nickname Management - + fun setNickname(newNickname: String) { state.setNickname(newNickname) dataManager.saveNickname(newNickname) meshService.sendBroadcastAnnounce() } - + // MARK: - Channel Management (delegated) - + fun joinChannel(channel: String, password: String? = null): Boolean { return channelManager.joinChannel(channel, password, meshService.myPeerID) } - + fun switchToChannel(channel: String?) { channelManager.switchToChannel(channel) } - + fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) meshService.sendMessage("left $channel") } - + // MARK: - Private Chat Management (delegated) - + fun startPrivateChat(peerID: String) { val success = privateChatManager.startPrivateChat(peerID, meshService) if (success) { @@ -184,18 +213,18 @@ class ChatViewModel( clearNotificationsForSender(peerID) } } - + fun endPrivateChat() { privateChatManager.endPrivateChat() // Notify notification manager that no private chat is active setCurrentPrivateChatPeer(null) } - + // MARK: - Message Sending - + fun sendMessage(content: String) { if (content.isEmpty()) return - + // Check for commands if (content.startsWith("/")) { commandProcessor.processCommand(content, meshService, meshService.myPeerID) { messageContent, mentions, channel -> @@ -203,26 +232,26 @@ class ChatViewModel( } return } - + val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue()) val channels = messageManager.parseChannels(content) - + // Auto-join mentioned channels channels.forEach { channel -> if (!state.getJoinedChannelsValue().contains(channel)) { joinChannel(channel) } } - + val selectedPeer = state.getSelectedPrivateChatPeerValue() val currentChannelValue = state.getCurrentChannelValue() - + if (selectedPeer != null) { // Send private message val recipientNickname = meshService.getPeerNicknames()[selectedPeer] privateChatManager.sendPrivateMessage( - content, - selectedPeer, + content, + selectedPeer, recipientNickname, state.getNicknameValue(), meshService.myPeerID @@ -240,16 +269,16 @@ class ChatViewModel( mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) - + if (currentChannelValue != null) { channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) - + // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { channelManager.sendEncryptedChannelMessage( - content, - mentions, - currentChannelValue, + content, + mentions, + currentChannelValue, state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> @@ -269,21 +298,21 @@ class ChatViewModel( } } } - + // MARK: - Utility Functions - + fun getPeerIDForNickname(nickname: String): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - + fun toggleFavorite(peerID: String) { Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) - + // Log current state after toggle logCurrentFavoriteState() } - + private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") @@ -291,7 +320,7 @@ class ChatViewModel( Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") } - + /** * Initialize session state monitoring for reactive UI updates */ @@ -303,133 +332,133 @@ class ChatViewModel( } } } - + /** * Update reactive states for all connected peers (session states and fingerprints) */ private fun updateReactiveStates() { val currentPeers = state.getConnectedPeersValue() - + // Update session states val sessionStates = currentPeers.associateWith { peerID -> meshService.getSessionState(peerID).toString() } state.setPeerSessionStates(sessionStates) - + // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) } - + // MARK: - Debug and Troubleshooting - + fun getDebugStatus(): String { return meshService.getDebugStatus() } - + // 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) } - + fun setCurrentPrivateChatPeer(peerID: String?) { // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } - + fun clearNotificationsForSender(peerID: String) { // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } - + // MARK: - Command Autocomplete (delegated) - + fun updateCommandSuggestions(input: String) { commandProcessor.updateCommandSuggestions(input) } - + fun selectCommandSuggestion(suggestion: CommandSuggestion): String { return commandProcessor.selectCommandSuggestion(suggestion) } - + // MARK: - BluetoothMeshDelegate Implementation (delegated) - + override fun didReceiveMessage(message: BitchatMessage) { meshDelegateHandler.didReceiveMessage(message) } - + override fun didConnectToPeer(peerID: String) { meshDelegateHandler.didConnectToPeer(peerID) } - + override fun didDisconnectFromPeer(peerID: String) { meshDelegateHandler.didDisconnectFromPeer(peerID) } - + override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } - + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } - + override fun didReceiveDeliveryAck(ack: DeliveryAck) { meshDelegateHandler.didReceiveDeliveryAck(ack) } - + override fun didReceiveReadReceipt(receipt: ReadReceipt) { meshDelegateHandler.didReceiveReadReceipt(receipt) } - + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) } - + override fun getNickname(): String? { return meshDelegateHandler.getNickname() } - + override fun isFavorite(peerID: String): Boolean { return meshDelegateHandler.isFavorite(peerID) } - + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager - + // MARK: - Emergency Clear - + fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") - + // Clear all UI managers messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() dataManager.clearAllData() - + // Clear all mesh service data clearAllMeshServiceData() - + // Clear all cryptographic data clearAllCryptographicData() - + // Clear all notifications notificationManager.clearAllNotifications() - + // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - + Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared") - + // Note: Mesh service restart is now handled by MainActivity // This method now only clears data, not mesh service lifecycle } - + /** * Clear all mesh service related data */ @@ -437,13 +466,13 @@ class ChatViewModel( try { // Request mesh service to clear all its internal data meshService.clearAllInternalData() - + Log.d(TAG, "✅ Cleared all mesh service data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}") } } - + /** * Clear all cryptographic data including persistent identity */ @@ -451,7 +480,7 @@ class ChatViewModel( try { // Clear encryption service persistent identity (Ed25519 signing keys) meshService.clearAllEncryptionData() - + // Clear secure identity state (if used) try { val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) @@ -460,31 +489,31 @@ class ChatViewModel( } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") } - + Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") } } - + // MARK: - Navigation Management - + fun showAppInfo() { state.setShowAppInfo(true) } - + fun hideAppInfo() { state.setShowAppInfo(false) } - + fun showSidebar() { state.setShowSidebar(true) } - + fun hideSidebar() { state.setShowSidebar(false) } - + /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system 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 ) } From 16f79d68b087af581d1b8dff740e9fb87456b2e5 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Sun, 3 Aug 2025 15:22:54 -0700 Subject: [PATCH 05/13] cleanup --- .../bitchat/android/mesh/ForegroundService.kt | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index f3d5a2083..13be8d274 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -81,22 +81,12 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private const val FOREGROUND_CHANNEL_ID = "bitchat_foreground_service" const val ACTION_STOP_SERVICE = "com.bitchat.android.ACTION_STOP_SERVICE" const val ACTION_MUTE = "com.bitchat.android.ACTION_MUTE" - private const val DEBUG_MODE = true // Enable to use mock peers @Volatile var isServiceRunning = false private set } - // --- Mock Data for Debugging --- - private val mockPeers = listOf( - PeerInfo("id_1", "zerocool", 4), - PeerInfo("id_2", "acidburn", 3), - PeerInfo("id_3", "phantomphreak", 2), - PeerInfo("id_4", "lordnikon", 1), - PeerInfo("id_5", "cerealkiller", 0) - ) - // --- Service Lifecycle & Setup --- override fun onCreate() { @@ -177,17 +167,13 @@ class ForegroundService : Service(), BluetoothMeshDelegate { // --- Notification Building & Logic --- private fun updateNotification(alert: Boolean) { - if (DEBUG_MODE) { - activePeers = mockPeers - } else { - val nicknames = meshService?.getPeerNicknames() ?: emptyMap() - val rssiValues = meshService?.getPeerRSSI() ?: emptyMap() + 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 } - } + 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)) } From 43071dd17f97ce8e4dbe6dd26486b5d40619524d Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 18:59:51 -0700 Subject: [PATCH 06/13] removed custom UI and added vanilla notification styles. Also added mock user function for testing. --- app/build.gradle.kts | 1 + .../java/com/bitchat/android/MainActivity.kt | 9 +- .../bitchat/android/mesh/ForegroundService.kt | 359 +++++------------- .../java/com/bitchat/android/mesh/PeerInfo.kt | 4 + .../res/layout/notification_line_item.xml | 15 - .../notification_peer_item_terminal.xml | 29 -- .../notification_terminal_collapsed.xml | 22 -- .../layout/notification_terminal_expanded.xml | 49 --- app/src/main/res/values/strings.xml | 20 + 9 files changed, 123 insertions(+), 385 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/PeerInfo.kt delete mode 100644 app/src/main/res/layout/notification_line_item.xml delete mode 100644 app/src/main/res/layout/notification_peer_item_terminal.xml delete mode 100644 app/src/main/res/layout/notification_terminal_collapsed.xml delete mode 100644 app/src/main/res/layout/notification_terminal_expanded.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 27796d1f0..56aa4f21e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,6 +41,7 @@ android { } buildFeatures { compose = true + buildConfig = true } packaging { resources { diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 378b805d9..bf030caec 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -43,6 +43,7 @@ import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.ui.ChatScreen import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.ExitConfirmationDialog +import com.bitchat.android.ui.NotificationManager import com.bitchat.android.ui.theme.BitchatTheme import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect @@ -708,13 +709,13 @@ class MainActivity : ComponentActivity() { */ private fun handleNotificationIntent(intent: Intent) { val shouldOpenPrivateChat = intent.getBooleanExtra( - com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, + 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) + val peerID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID) + val senderNickname = intent.getStringExtra(NotificationManager.EXTRA_SENDER_NICKNAME) if (peerID != null) { Log.d(TAG, "Opening private chat with $senderNickname (peerID: $peerID) from notification") @@ -734,7 +735,7 @@ class MainActivity : ComponentActivity() { */ private fun stopServiceAndExit() { Log.d(TAG, "User requested shutdown. Stopping service and exiting.") - val intent = Intent(ForegroundService.ACTION_STOP_SERVICE).apply { + val intent = Intent(ForegroundService.ACTION_SHUTDOWN).apply { // Ensure the broadcast is delivered only to our app's receiver `package` = packageName } diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index 13be8d274..9d5d433ee 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -1,6 +1,5 @@ package com.bitchat.android.mesh -import android.R.style.Theme import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -11,48 +10,28 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Typeface import android.os.Binder import android.os.IBinder import android.util.Log -import android.widget.RemoteViews -import androidx.annotation.LayoutRes -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import androidx.core.graphics.createBitmap +import com.bitchat.android.BuildConfig 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 com.bitchat.android.util.AnsiChars -import com.bitchat.android.util.AnsiGrid -import java.util.Random import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin +import kotlin.random.Random -// Data class to hold combined peer information for the UI -data class PeerInfo(val id: String, val nickname: String, val proximity: Int) - - -private const val FONT_SIZE = 10 /** - * A foreground service that provides a rich, interactive, and theme-consistent notification - * in the style of a monochrome IRC/terminal client. It displays nearby peers with proximity - * and a live log of recent messages by acting as a delegate for BluetoothMeshService. - * - * The notification features a generative ANSI art landscape where peers are represented as stars. + * A foreground service that provides a standard, live-updating Android notification + * with peer and message counts. */ class ForegroundService : Service(), BluetoothMeshDelegate { @@ -63,24 +42,21 @@ class ForegroundService : Service(), BluetoothMeshDelegate { // --- Live State for Notification UI --- private var activePeers = listOf() - private var recentMessages = mutableListOf() + private var unreadMessageCount = 0 private val knownPeerIds = HashSet() // Used to detect new peers - // --- State for ANSI Grid Visualization --- - private var frame: Long = 0 // Animation frame counter - private val peerStarData = mutableMapOf>() // PeerID -> (x, random phase offset) - private val random = Random() - - // 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 = "bitchat_foreground_service" - const val ACTION_STOP_SERVICE = "com.bitchat.android.ACTION_STOP_SERVICE" - const val ACTION_MUTE = "com.bitchat.android.ACTION_MUTE" + 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 @@ -101,13 +77,16 @@ class ForegroundService : Service(), BluetoothMeshDelegate { } val intentFilter = IntentFilter().apply { - addAction(ACTION_STOP_SERVICE) - addAction(ACTION_MUTE) + 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() @@ -132,10 +111,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { Log.d(TAG, "didReceiveMessage: '${message.content}' from ${message.sender}") - recentMessages.add(0, message) - if (recentMessages.size > 10) { - recentMessages = recentMessages.take(10).toMutableList() - } + unreadMessageCount++ updateNotification(false) } @@ -166,246 +142,93 @@ class ForegroundService : Service(), BluetoothMeshDelegate { // --- Notification Building & Logic --- - private fun updateNotification(alert: Boolean) { - 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)) - } - - - 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 builder = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setColor(colors.primary.toArgb()) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) - .setCustomContentView(createCollapsedRemoteViews()) - .setCustomBigContentView(createExpandedRemoteViews()) - .setContentIntent(createMainPendingIntent()) - .setOngoing(true) - - if (alert) { - builder.setOnlyAlertOnce(false) - builder.setDefaults(Notification.DEFAULT_ALL) - } else { - builder.setOnlyAlertOnce(true) - } - - return builder.build() - } - - private fun getAnsiGrid(bitmapWidth: Int, bitmapHeight: Int, fgColor: Int): Triple? { - val density = resources.displayMetrics.density - val textPaint = Paint().apply { - color = fgColor - isAntiAlias = true - typeface = Typeface.MONOSPACE - textSize = FONT_SIZE * density - } - // Use a standard, reliable character for measuring width. 'W' is a good choice. - val charWidth = textPaint.measureText("W") - val charHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent - - if (charWidth <= 0 || charHeight <= 0) return null - - val numCols = (bitmapWidth / charWidth).toInt() - val numRows = (bitmapHeight / charHeight).toInt() - - if (numCols <= 0 || numRows <= 0) return null - - val grid = AnsiGrid(numCols, numRows, textPaint) - return Triple(grid, charWidth, charHeight) - } - - private fun getCollapsedRenderBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { - val bitmap = createBitmap(bitmapWidth, bitmapHeight) - val canvas = Canvas(bitmap) - val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - - val bgColor = if (isDarkTheme) DarkColorScheme.background.toArgb() else LightColorScheme.background.toArgb() - val fgColor = if (isDarkTheme) DarkColorScheme.primary.toArgb() else LightColorScheme.primary.toArgb() - canvas.drawColor(bgColor) - - val gridData = getAnsiGrid(bitmapWidth, bitmapHeight, fgColor) - if (gridData != null) { - val (grid, charWidth, charHeight) = gridData - drawTerminalContent(grid, activePeers, isForCollapsedView = true) - // Pass charWidth and charHeight to the updated render function - grid.render(canvas, charWidth, charHeight) - } - return bitmap - } - /** - * Renders the entire expanded notification content into a single bitmap. + * Creates a list of mock peers for debugging purposes. + * Proximity is randomized on each call to simulate changing conditions. */ - private fun getExpandedRenderBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { - val bitmap = createBitmap(bitmapWidth, bitmapHeight) - val canvas = Canvas(bitmap) - val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + 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)) + ) - val bgColor = if (isDarkTheme) DarkColorScheme.background.toArgb() else LightColorScheme.background.toArgb() - val fgColor = if (isDarkTheme) DarkColorScheme.primary.toArgb() else LightColorScheme.primary.toArgb() - canvas.drawColor(bgColor) + // 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 - val gridData = getAnsiGrid(bitmapWidth, bitmapHeight, fgColor) - if (gridData != null) { - val (grid, charWidth, charHeight) = gridData - drawTerminalContent(grid, activePeers, isForCollapsedView = false) - // Pass charWidth and charHeight to the updated render function - grid.render(canvas, charWidth, charHeight) - } - return bitmap + // Shuffle the list and take a random number of peers + return allMockPeers.shuffled().take(numToShow).sortedByDescending { it.proximity } } - // --- Generative ANSI Art Functions --- - /** - * Main drawing function to orchestrate the creation of the terminal UI. - * Switches between collapsed and expanded layouts. - */ - private fun drawTerminalContent(grid: AnsiGrid, peers: List, isForCollapsedView: Boolean) { - grid.clear() - if (isForCollapsedView) { - drawCollapsedContent(grid, peers) + 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 { - drawExpandedContent(grid, peers) - } - } + val nicknames = meshService?.getPeerNicknames() ?: emptyMap() + val rssiValues = meshService?.getPeerRSSI() ?: emptyMap() - /** - * Draws the content for the expanded notification view. - */ - private fun drawExpandedContent(grid: AnsiGrid, peers: List) { - drawBitchatLogo(grid) - - val peerCountText = "peers: ${peers.size}" - val xPos = grid.width - peerCountText.length - 1 - if (xPos >= 0) { - grid.drawText(xPos, 0, peerCountText) + activePeers = nicknames.map { (peerId, nickname) -> + val rssi = rssiValues[peerId] ?: -100 + PeerInfo(id = peerId, nickname = nickname, proximity = getProximityFromRssi(rssi)) + }.sortedByDescending { it.proximity } } - - val startX = 15 - val startY = 1 - val maxPeers = (grid.height - startY) - val availableWidth = grid.width - startX - drawPeerList(grid, peers, startX, startY, maxPeers, availableWidth) + notificationManager.notify(NOTIFICATION_ID, buildNotification(alert)) } - /** - * Draws a compact, info-rich layout for the collapsed notification view. - */ - private fun drawCollapsedContent(grid: AnsiGrid, peers: List) { - drawBitchatLogo(grid) - - val peerCountText = "peers: ${peers.size}" - val xPos = grid.width - peerCountText.length - 1 - if (xPos >= 0) { - grid.drawText(xPos, 0, peerCountText) - } - - val startX = 15 - val startY = 1 - val maxPeers = (grid.height - startY).coerceAtMost(3) - val availableWidth = grid.width - startX - drawPeerList(grid, peers, startX, startY, maxPeers, availableWidth) - } /** - * Draws the "bitchat" logo and name side-by-side for the expanded view. + * 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 drawBitchatLogo(grid: AnsiGrid) { - val logoOutline = listOf( - " ╓─╮ ─╥─ ─╥─", - " ╟─┤ ║ ║ ", - " ╙─╯ ─╨─ ╜ ", - " bitchat " - ) + 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 - logoOutline.forEachIndexed { index, line -> - grid.drawText(0, index, line) - } - } + val peerCount = activePeers.size + val contentTitle = resources.getQuantityString(R.plurals.peers_nearby, peerCount, peerCount) + val contentText = resources.getQuantityString(R.plurals.unread_messages, unreadMessageCount, unreadMessageCount) + // Expanded view style using InboxStyle + val inboxStyle = NotificationCompat.InboxStyle() + .setBigContentTitle(contentTitle) + .setSummaryText(getString(R.string.notification_summary)) - /** - * Draws a list of peers at a specified location with gradient proximity bars. - */ - private fun drawPeerList(grid: AnsiGrid, peers: List, startX: Int, startY: Int, maxPeers: Int, availableWidth: Int) { - val proximityGradient = listOf(AnsiChars.Shade.LIGHT, AnsiChars.Shade.MEDIUM, AnsiChars.Shade.DARK, AnsiChars.Block.FULL) - - peers.take(maxPeers).forEachIndexed { index, peer -> - val yPos = startY + index - if (yPos >= grid.height) return@forEachIndexed // Stop if we run out of space - - val bars = proximityGradient.take(peer.proximity).joinToString("") - val padding = AnsiChars.line(' ', 4 - peer.proximity) - val proximityString = "[$bars$padding]" - - // Ensure the nickname doesn't overflow the available space. - val maxNicknameLength = (availableWidth - proximityString.length - 1).coerceAtLeast(1) - val nickname = if (peer.nickname.length > maxNicknameLength) { - peer.nickname.take(maxNicknameLength) - } else { - peer.nickname + // 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") } - val text = String.format("%-${maxNicknameLength}s %s", nickname, proximityString) - grid.drawText(startX, yPos, text) + } else { + inboxStyle.addLine(getString(R.string.notification_scanning)) } - } - private fun getRemoteViewDimensions(@LayoutRes layoutId: Int, viewId: Int): Pair? { - val remoteViews = RemoteViews(packageName, layoutId) - val layout = remoteViews.apply(applicationContext, null) ?: return null - val targetView = layout.findViewById(viewId) ?: return null - if (targetView.width == 0 || targetView.height == 0) { - // If the view hasn't been measured yet, try to force a measure pass - targetView.measure( - android.view.View.MeasureSpec.makeMeasureSpec(resources.displayMetrics.widthPixels, android.view.View.MeasureSpec.AT_MOST), - android.view.View.MeasureSpec.makeMeasureSpec(resources.displayMetrics.heightPixels, android.view.View.MeasureSpec.AT_MOST) - ) - } - return if (targetView.measuredWidth > 0 && targetView.measuredHeight > 0) { - Pair(targetView.measuredWidth, targetView.measuredHeight) - } else null - } + val builder = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(colors.primary.toArgb()) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setStyle(inboxStyle) + .setContentIntent(createMainPendingIntent()) + .setOngoing(true) + .addAction(0, getString(R.string.notification_action_shutdown), createActionPendingIntent(ACTION_SHUTDOWN)) - private fun createCollapsedRemoteViews(): RemoteViews { - val dimensions = getRemoteViewDimensions(R.layout.notification_terminal_collapsed, R.id.notification_render) - val bitmapWidthPx = dimensions?.first ?: (resources.displayMetrics.widthPixels).toInt() - val bitmapHeightPx = dimensions?.second ?: (48 * resources.displayMetrics.density).toInt() - return RemoteViews(packageName, R.layout.notification_terminal_collapsed).apply { - setImageViewBitmap(R.id.notification_render, getCollapsedRenderBitmap(bitmapWidthPx, bitmapHeightPx)) + if (alert) { + builder.setOnlyAlertOnce(false) + builder.setDefaults(Notification.DEFAULT_ALL) + } else { + builder.setOnlyAlertOnce(true) } - } - - private fun createExpandedRemoteViews(): RemoteViews { - val isDarkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - val colors = if (isDarkTheme) DarkColorScheme else LightColorScheme - val dimColor = if (isDarkTheme) Color(0xB3FFFFFF).toArgb() else colors.onSurface.toArgb() - val dimensions = getRemoteViewDimensions(R.layout.notification_terminal_expanded, R.id.notification_render) - val bitmapWidthPx = dimensions?.first ?: resources.displayMetrics.widthPixels - val bitmapHeightPx = dimensions?.second ?: (208 * resources.displayMetrics.density).toInt() - return RemoteViews(packageName, R.layout.notification_terminal_expanded).apply { - setImageViewBitmap(R.id.notification_render, getExpandedRenderBitmap(bitmapWidthPx, bitmapHeightPx)) - - // Set colors for the action buttons - setTextColor(R.id.notification_action_mute, dimColor) - setTextColor(R.id.notification_action_stop_expanded, dimColor) - - // Set Actions - setOnClickPendingIntent(R.id.notification_action_mute, createActionPendingIntent(ACTION_MUTE)) - setOnClickPendingIntent(R.id.notification_action_stop_expanded, createActionPendingIntent(ACTION_STOP_SERVICE)) - } + return builder.build() } // --- Helper Functions --- @@ -424,9 +247,8 @@ class ForegroundService : Service(), BluetoothMeshDelegate { if (::uiUpdateScheduler.isInitialized && !uiUpdateScheduler.isShutdown) return uiUpdateScheduler = Executors.newSingleThreadScheduledExecutor() uiUpdateScheduler.scheduleWithFixedDelay({ - frame++ // Increment animation frame updateNotification(false) - }, 0, 2000, TimeUnit.MILLISECONDS) // Update every 2 seconds + }, 0, 5000L, TimeUnit.MILLISECONDS) // Update every 5 seconds } // --- Boilerplate --- @@ -445,24 +267,27 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private val notificationActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { - ACTION_STOP_SERVICE -> stopForegroundServiceAndApp() - ACTION_MUTE -> Log.d(TAG, "Mute action tapped") + ACTION_SHUTDOWN -> shutdownService() } } } - private fun stopForegroundServiceAndApp() { - Log.i(TAG, "Stop action triggered. Stopping service.") + private 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, "Bitchat Active Service", NotificationManager.IMPORTANCE_DEFAULT + FOREGROUND_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT ).apply { - description = "Keeps Bitchat connected and shows live status" + description = channelDescription setShowBadge(false) enableVibration(false) setSound(null, null) @@ -472,7 +297,9 @@ class ForegroundService : Service(), BluetoothMeshDelegate { private fun createMainPendingIntent(): PendingIntent { val intent = packageManager.getLaunchIntentForPackage(packageName) - return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + // 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 { 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/res/layout/notification_line_item.xml b/app/src/main/res/layout/notification_line_item.xml deleted file mode 100644 index 6d0379869..000000000 --- a/app/src/main/res/layout/notification_line_item.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/app/src/main/res/layout/notification_peer_item_terminal.xml b/app/src/main/res/layout/notification_peer_item_terminal.xml deleted file mode 100644 index d3cd6aa9e..000000000 --- a/app/src/main/res/layout/notification_peer_item_terminal.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/notification_terminal_collapsed.xml b/app/src/main/res/layout/notification_terminal_collapsed.xml deleted file mode 100644 index 6523465af..000000000 --- a/app/src/main/res/layout/notification_terminal_collapsed.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/notification_terminal_expanded.xml b/app/src/main/res/layout/notification_terminal_expanded.xml deleted file mode 100644 index 31a1d0474..000000000 --- a/app/src/main/res/layout/notification_terminal_expanded.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 699d394d7..068ccdbba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,4 +36,24 @@ Continue Retry Skip + + + + + %d peer nearby + %d peers nearby + + + + %d unread message + %d unread messages + + + Bitchat is active + Scanning for nearby peers… + Shutdown + + Bitchat Active Service + Keeps Bitchat connected and shows live status + From bcd385cea3a4f28f3f4a7f9a745f24f29c06b802 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 19:05:39 -0700 Subject: [PATCH 07/13] revert chatheader --- .../java/com/bitchat/android/ui/ChatHeader.kt | 205 +++++++----------- 1 file changed, 80 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 953fd67d8..a5f90f848 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -192,6 +192,68 @@ fun PeerCounter( } } +@Composable +fun ChatHeaderContent( + selectedPrivatePeer: String?, + currentChannel: String?, + nickname: String, + viewModel: ChatViewModel, + onBackClick: () -> Unit, + onSidebarClick: () -> Unit, + onTripleClick: () -> Unit, + onShowAppInfo: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + when { + selectedPrivatePeer != null -> { + // Private chat header - Fully reactive state tracking + val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) + val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) + + // Reactive favorite computation - no more static lookups! + val isFavorite = isFavoriteReactive( + peerID = selectedPrivatePeer, + peerFingerprints = peerFingerprints, + favoritePeers = favoritePeers + ) + val sessionState = peerSessionStates[selectedPrivatePeer] + + Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") + + PrivateChatHeader( + peerID = selectedPrivatePeer, + peerNicknames = viewModel.meshService.getPeerNicknames(), + isFavorite = isFavorite, + sessionState = sessionState, + onBackClick = onBackClick, + onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } + ) + } + currentChannel != null -> { + // Channel header + ChannelHeader( + channel = currentChannel, + onBackClick = onBackClick, + onLeaveChannel = { viewModel.leaveChannel(currentChannel) }, + onSidebarClick = onSidebarClick + ) + } + else -> { + // Main header + MainHeader( + nickname = nickname, + onNicknameChange = viewModel::setNickname, + onTitleClick = onShowAppInfo, + onTripleTitleClick = onTripleClick, + onSidebarClick = onSidebarClick, + viewModel = viewModel + ) + } + } +} + @Composable private fun PrivateChatHeader( peerID: String, @@ -282,7 +344,7 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + Box(modifier = Modifier.fillMaxWidth()) { // Back button - positioned all the way to the left with minimal margin Button( @@ -313,7 +375,7 @@ private fun ChannelHeader( ) } } - + // Title - perfectly centered regardless of other elements Text( text = "channel: $channel", @@ -323,7 +385,7 @@ private fun ChannelHeader( .align(Alignment.Center) .clickable { onSidebarClick() } ) - + // Leave button - positioned on the right TextButton( onClick = onLeaveChannel, @@ -338,68 +400,6 @@ private fun ChannelHeader( } } -@Composable -fun ChatHeaderContent( - selectedPrivatePeer: String?, - currentChannel: String?, - nickname: String, - viewModel: ChatViewModel, - onBackClick: () -> Unit, - onSidebarClick: () -> Unit, - onTripleClick: () -> Unit, - onShowAppInfo: () -> Unit, - onShutdownClick: () -> Unit // New callback for shutdown -) { - val colorScheme = MaterialTheme.colorScheme - - when { - selectedPrivatePeer != null -> { - // Private chat header - Fully reactive state tracking - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) - - val isFavorite = isFavoriteReactive( - peerID = selectedPrivatePeer, - peerFingerprints = peerFingerprints, - favoritePeers = favoritePeers - ) - val sessionState = peerSessionStates[selectedPrivatePeer] - - PrivateChatHeader( - peerID = selectedPrivatePeer, - peerNicknames = viewModel.meshService.getPeerNicknames(), - isFavorite = isFavorite, - sessionState = sessionState, - onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } - ) - } - currentChannel != null -> { - // Channel header - ChannelHeader( - channel = currentChannel, - onBackClick = onBackClick, - onLeaveChannel = { viewModel.leaveChannel(currentChannel) }, - onSidebarClick = onSidebarClick - ) - } - else -> { - // Main header - MainHeader( - nickname = nickname, - onNicknameChange = viewModel::setNickname, - onTitleClick = onShowAppInfo, - onTripleTitleClick = onTripleClick, - onSidebarClick = onSidebarClick, - viewModel = viewModel, - onShutdownClick = onShutdownClick, // Pass down the shutdown callback - onPanicClear = onTripleClick - ) - } - } -} - @Composable private fun MainHeader( nickname: String, @@ -407,9 +407,7 @@ private fun MainHeader( onTitleClick: () -> Unit, onTripleTitleClick: () -> Unit, onSidebarClick: () -> Unit, - viewModel: ChatViewModel, - onShutdownClick: () -> Unit, - onPanicClear: () -> Unit + viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) @@ -417,15 +415,14 @@ private fun MainHeader( val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val isConnected by viewModel.isConnected.observeAsState(false) - + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Left side: Logo and Nickname Row( - modifier = Modifier.weight(1f).fillMaxHeight(), + modifier = Modifier.fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -437,64 +434,22 @@ private fun MainHeader( onTripleClick = onTripleTitleClick ) ) - + Spacer(modifier = Modifier.width(2.dp)) - + NicknameEditor( value = nickname, onValueChange = onNicknameChange ) } - - // Right side: Peer Counter and Menu - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, - joinedChannels = joinedChannels, - hasUnreadChannels = hasUnreadChannels, - hasUnreadPrivateMessages = hasUnreadPrivateMessages, - isConnected = isConnected, - onClick = onSidebarClick - ) - - var showMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showMenu = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "More options", - tint = colorScheme.onSurface - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - DropdownMenuItem( - text = { Text("App Info") }, - onClick = { - onTitleClick() - showMenu = false - } - ) - DropdownMenuItem( - text = { Text("Panic Clear Data") }, - onClick = { - onPanicClear() - showMenu = false - } - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Shut Down Service", color = colorScheme.error) }, - onClick = { - onShutdownClick() - showMenu = false - } - ) - } - } + + PeerCounter( + connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, + joinedChannels = joinedChannels, + hasUnreadChannels = hasUnreadChannels, + hasUnreadPrivateMessages = hasUnreadPrivateMessages, + isConnected = isConnected, + onClick = onSidebarClick + ) } -} \ No newline at end of file +} From df5977cde984bef730dd68e777bc069b63bf932b Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 19:20:16 -0700 Subject: [PATCH 08/13] removed unused files --- .../java/com/bitchat/android/MainActivity.kt | 11 - .../java/com/bitchat/android/ui/ChatScreen.kt | 3 +- .../com/bitchat/android/util/AnsiChars.kt | 264 ------------------ .../java/com/bitchat/android/util/AnsiGrid.kt | 66 ----- .../bitchat/android/util/ProximityDrawable.kt | 60 ---- app/src/main/res/drawable/close_24px.xml | 10 - .../res/drawable/notification_bg_terminal.xml | 21 -- .../drawable/notifications_active_24px.xml | 10 - .../res/drawable/notifications_off_24px.xml | 10 - app/src/main/res/drawable/reply_24px.xml | 11 - 10 files changed, 1 insertion(+), 465 deletions(-) delete mode 100644 app/src/main/java/com/bitchat/android/util/AnsiChars.kt delete mode 100644 app/src/main/java/com/bitchat/android/util/AnsiGrid.kt delete mode 100644 app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt delete mode 100644 app/src/main/res/drawable/close_24px.xml delete mode 100644 app/src/main/res/drawable/notification_bg_terminal.xml delete mode 100644 app/src/main/res/drawable/notifications_active_24px.xml delete mode 100644 app/src/main/res/drawable/notifications_off_24px.xml delete mode 100644 app/src/main/res/drawable/reply_24px.xml diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index bf030caec..ed662ee10 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -46,7 +46,6 @@ import com.bitchat.android.ui.ExitConfirmationDialog import com.bitchat.android.ui.NotificationManager import com.bitchat.android.ui.theme.BitchatTheme import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -72,15 +71,6 @@ class MainActivity : ComponentActivity() { private val chatViewModel: ChatViewModel by viewModels { viewModelFactory { initializer { - // IMPORTANT: You must add the shutdown request flow to your ChatViewModel - // - // In ChatViewModel.kt: - // private val _shutdownRequest = MutableSharedFlow() - // val shutdownRequest = _shutdownRequest.asSharedFlow() - // - // fun requestShutdown() { - // viewModelScope.launch { _shutdownRequest.emit(Unit) } - // } ChatViewModel(application) } } @@ -199,7 +189,6 @@ class MainActivity : ComponentActivity() { } } - // 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) { 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 210fd51c5..ee974e142 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -277,8 +277,7 @@ private fun ChatFloatingHeader( }, onSidebarClick = onSidebarToggle, onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onShutdownClick = { viewModel.requestShutdown() } + onShowAppInfo = onShowAppInfo ) }, colors = TopAppBarDefaults.topAppBarColors( diff --git a/app/src/main/java/com/bitchat/android/util/AnsiChars.kt b/app/src/main/java/com/bitchat/android/util/AnsiChars.kt deleted file mode 100644 index de414ec5c..000000000 --- a/app/src/main/java/com/bitchat/android/util/AnsiChars.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.bitchat.android.util - -import android.graphics.Bitmap -import android.graphics.Color -import androidx.core.graphics.get -import androidx.core.graphics.scale -import kotlin.math.abs -import kotlin.random.Random - -/** - * A helper object providing compile-time constants and utility functions - * for creating terminal-based art and user interfaces (TUI). - * - * This object includes a comprehensive set of constants for Code Page 437 - * (the original IBM PC character set), including box-drawing, shades, and symbols. - * It also contains helper functions for converting Android Bitmaps to ANSI/ASCII art, - * including advanced techniques like Braille art. - */ -object AnsiChars { - - // --- General & Control Characters --- - const val EMPTY_CHAR = ' ' - const val BRAILLE_BLANK = '⠀' // An invisible character that still takes up space - const val ESC = '\u001B' - - /** - * Characters for drawing boxes and lines. - * Based on the classic Code Page 437. - */ - object Box { - object Single { - const val H = '─'; const val V = '│' - const val DR = '┌'; const val DL = '┐' - const val UR = '└'; const val UL = '┘' - const val VR = '├'; const val VL = '┤' - const val DH = '┬'; const val UH = '┴'; const val VH = '┼' - } - - object Double { - const val H = '═'; const val V = '║' - const val DR = '╔'; const val DL = '╗' - const val UR = '╚'; const val UL = '╝' - const val VR = '╠'; const val VL = '╣' - const val DH = '╦'; const val UH = '╩'; const val VH = '╬' - } - - object Rounded { - const val DR = '╭'; const val DL = '╮' - const val UR = '╰'; const val UL = '╯' - } - - object Mixed { - const val V_S_H_D = '╪'; const val V_D_H_S = '╫' - const val DR_S_V_D_H = '╒'; const val DR_D_V_S_H = '╓' - const val DL_S_V_D_H = '╕'; const val DL_D_V_S_H = '╖' - const val UR_S_V_D_H = '╘'; const val UR_D_V_S_H = '╙' - const val UL_S_V_D_H = '╛'; const val UL_D_V_S_H = '╜' - const val VR_S_V_D_H = '╞'; const val VR_D_V_S_H = '╟' - const val VL_S_V_D_H = '╡'; const val VL_D_V_S_H = '╢' - const val DH_S_V_D_H = '╤'; const val DH_D_V_S_H = '╥' - const val UH_S_V_D_H = '╧'; const val UH_D_V_S_H = '╨' - } - } - - /** - * Block elements for filling areas, creating gradients, or pixel-like effects. - */ - object Block { - const val FULL = '█'; const val UPPER_H = '▀'; const val LOWER_H = '▄' - const val LEFT_H = '▌'; const val RIGHT_H = '▐' - const val LOWER_1_8 = ' '; const val LOWER_1_4 = '▂'; const val LOWER_3_8 = '▃' - const val LOWER_5_8 = '▅'; const val LOWER_3_4 = '▆'; const val LOWER_7_8 = '▇' - const val UPPER_1_8 = '▔' - - object Quadrant { - const val UL = '▘'; const val UR = '▝'; const val LL = '▖'; const val LR = '▗' - const val UL_LR = '▚'; const val UR_LL = '▞' - const val UL_UR_LL = '▛'; const val UL_UR_LR = '▜' - const val UL_LL_LR = '▙'; const val UR_LL_LR = '▟' - } - } - - object Shade { - const val LIGHT = '░'; const val MEDIUM = '▒'; const val DARK = '▓' - val GRADIENT = listOf(LIGHT, MEDIUM, DARK, Block.FULL) - } - - object Irc { - const val BOLD = '\u0002'; const val COLOR = '\u0003'; const val REVERSE = '\u0016' - const val UNDERLINE = '\u001F'; const val RESET = '\u000F' - } - - object Shapes { - const val CHECK = '✓'; const val CROSS = '✗' - const val TRI_U = '▲'; const val TRI_D = '▼'; const val TRI_L = '◀'; const val TRI_R = '▶' - const val SQ_S_F = '▪'; const val SQ_F = '■'; const val SQ_S = '▫'; const val SQ = '□' - const val CIRC_F = '●'; const val CIRC = '○' - const val DOT = '⋅'; const val BULLET = '•' - const val DIAM_F = '◆'; const val DIAM = '◇' - } - - /** - * The complete set of iconic, mathematical, and international symbols from Code Page 437. - */ - object Cp437Symbols { - const val SMILEY_W = '☺'; const val SMILEY_B = '☻'; const val HEART = '♥' - const val DIAMOND = '♦'; const val CLUB = '♣'; const val SPADE = '♠' - const val BULLET_H = '○'; const val BULLET_F = '●'; const val MALE = '♂'; const val FEMALE = '♀' - const val NOTE_1 = '♪'; const val NOTE_2 = '♫'; const val SUN = '☼' - const val ARROW_R_F = '►'; const val ARROW_L_F = '◄'; const val ARROW_UD = '↕' - const val EXCLAM_D = '‼'; const val PILCROW = '¶'; const val SECTION = '§' - const val CURSOR_R = '▬'; const val ARROW_UD_B = '↨'; const val ARROW_U = '↑' - const val ARROW_D = '↓'; const val ARROW_R = '→'; const val ARROW_L = '←' - const val ANGLE_R = '∟'; const val ARROW_LR = '↔'; const val HOUSE = '⌂' - const val C_CEDILLA_U = 'Ç'; const val U_DIAERESIS_L = 'ü'; const val E_ACUTE_L = 'é' - const val A_CIRCUMFLEX_L = 'â'; const val A_DIAERESIS_L = 'ä'; const val A_GRAVE_L = 'à' - const val A_RING_L = 'å'; const val C_CEDILLA_L = 'ç'; const val E_CIRCUMFLEX_L = 'ê' - const val E_DIAERESIS_L = 'ë'; const val E_GRAVE_L = 'è'; const val I_DIAERESIS_L = 'ï' - const val I_CIRCUMFLEX_L = 'î'; const val I_GRAVE_L = 'ì'; const val A_DIAERESIS_U = 'Ä' - const val A_RING_U = 'Å'; const val E_ACUTE_U = 'É'; const val AE_L = 'æ'; const val AE_U = 'Æ' - const val O_CIRCUMFLEX_L = 'ô'; const val O_DIAERESIS_L = 'ö'; const val O_GRAVE_L = 'ò' - const val U_CIRCUMFLEX_L = 'û'; const val U_GRAVE_L = 'ù'; const val Y_DIAERESIS_L = 'ÿ' - const val O_DIAERESIS_U = 'Ö'; const val U_DIAERESIS_U = 'Ü'; const val CENT = '¢' - const val POUND = '£'; const val YEN = '¥'; const val PESETA = '₧'; const val F_HOOK = 'ƒ' - const val A_ACUTE_L = 'á'; const val I_ACUTE_L = 'í'; const val O_ACUTE_L = 'ó' - const val U_ACUTE_L = 'ú'; const val N_TILDE_L = 'ñ'; const val N_TILDE_U = 'Ñ' - const val ORDINAL_F = 'ª'; const val ORDINAL_M = 'º'; const val Q_MARK_INV = '¿' - const val NOT_REV = '⌐'; const val NOT = '¬'; const val HALF = '½'; const val QUARTER = '¼' - const val EXCLAM_INV = '¡'; const val CHEVRON_L = '«'; const val CHEVRON_R = '»' - const val ALPHA = 'α'; const val BETA_SHARP_S = 'ß'; const val GAMMA_U = 'Γ'; const val PI = 'π' - const val SIGMA_U = 'Σ'; const val SIGMA_L = 'σ'; const val MU = 'µ'; const val TAU = 'τ' - const val PHI_U = 'Φ'; const val THETA_U = 'Θ'; const val OMEGA_U = 'Ω'; const val DELTA_L = 'δ' - const val INFINITY = '∞'; const val PHI_L = 'φ'; const val EPSILON = 'ε' - const val INTERSECTION = '∩'; const val TRIPLE_BAR = '≡'; const val PLUS_MINUS = '±' - const val GTE = '≥'; const val LTE = '≤'; const val INTEGRAL_T = '⌠'; const val INTEGRAL_B = '⌡' - const val DIV = '÷'; const val ALMOST_EQ = '≈'; const val DEGREE = '°' - const val BULLET_OP = '∙'; const val INTERPUNCT = '·'; const val SQRT = '√' - const val POWER_N = 'ⁿ'; const val SQUARE = '²'; const val CURSOR_B = '■' - } - - object AsciiRamp { - const val SIMPLE = "@%#*+=-:. " // Darkest to lightest - const val DETAILED = "\$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " - } - - /** - * Interesting Unicode characters and sequences for special effects. - */ - object Tricks { - // For creating "corrupted" or "glitch" text (Zalgo) - val ZALGO_UP = charArrayOf('\u030d', '\u030e', '\u0304', '\u0305', '\u033f', '\u0311', '\u0306', '\u0310', '\u0352', '\u0357', '\u0351', '\u0307', '\u0308', '\u030a', '\u0342', '\u0343', '\u0344', '\u034a', '\u034b', '\u034c', '\u0350', '\u0358', '\u035b', '\u035d', '\u035e', '\u035f', '\u0360', '\u0361', '\u0362') - val ZALGO_DOWN = charArrayOf('\u0316', '\u0317', '\u0318', '\u0319', '\u031c', '\u031d', '\u031e', '\u031f', '\u0320', '\u0324', '\u0325', '\u0326', '\u0329', '\u032a', '\u032b', '\u032c', '\u032d', '\u032e', '\u032f', '\u0330', '\u0331', '\u0332', '\u0333', '\u0339', '\u033a', '\u033b', '\u033c') - val ZALGO_MID = charArrayOf('\u0334', '\u0335', '\u0336', '\u0337', '\u0338', '\u033d', '\u033e', '\u0345', '\u0346', '\u0347', '\u0348', '\u0349', '\u034d', '\u034e', '\u0353', '\u0354', '\u0355', '\u0356', '\u0359', '\u035a', '\u035c') - } - - // --- Helper Functions and Tricks --- - - fun line(char: Char, length: Int): String { - if (length <= 0) return "" - return char.toString().repeat(length) - } - - fun colorize(text: String, colorCode: Int): String { - val code = colorCode.coerceIn(0, 255) - return "$ESC[38;5;${code}m$text$ESC[0m" - } - - private fun rgbToAnsi256(r: Int, g: Int, b: Int): Int { - if (abs(r - g) < 8 && abs(g - b) < 8) { - val gray = (r + g + b) / 3 - if (gray > 238) return 231; if (gray < 18) return 16 - return 232 + ((gray - 8) / 10) - } - val rAnsi = (r * 5 / 255); val gAnsi = (g * 5 / 255); val bAnsi = (b * 5 / 255) - return 16 + (36 * rAnsi) + (6 * gAnsi) + bAnsi - } - - fun imageToAnsiColor(bitmap: Bitmap, targetWidth: Int): String { - if (targetWidth <= 0) return "" - val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() - val targetHeight = (targetWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) - val scaledBitmap = bitmap.scale(targetWidth, targetHeight) - val sb = StringBuilder() - for (y in 0 until scaledBitmap.height) { - for (x in 0 until scaledBitmap.width) { - val pixel = scaledBitmap[x, y] - val colorCode = rgbToAnsi256(Color.red(pixel), Color.green(pixel), Color.blue(pixel)) - sb.append(colorize(Block.FULL.toString(), colorCode)) - } - sb.append('\n') - } - return sb.toString() - } - - /** - * Converts an image to grayscale ASCII art. - * @param ramp The string of characters to use for shading, from darkest to lightest. - */ - fun imageToGrayscale(bitmap: Bitmap, targetWidth: Int, ramp: String = AsciiRamp.SIMPLE): String { - if (targetWidth <= 0) return "" - val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() - val targetHeight = (targetWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) - val scaledBitmap = bitmap.scale(targetWidth, targetHeight) - val sb = StringBuilder() - for (y in 0 until scaledBitmap.height) { - for (x in 0 until scaledBitmap.width) { - val pixel = scaledBitmap[x, y] - val gray = (Color.red(pixel) * 0.299 + Color.green(pixel) * 0.587 + Color.blue(pixel) * 0.114).toInt() - val rampIndex = (gray * (ramp.length - 1)) / 255 - sb.append(ramp[rampIndex]) - } - sb.append('\n') - } - return sb.toString() - } - - /** - * Converts an image to high-resolution text art using Braille characters. - * Each Braille char is a 2x4 matrix of dots. - */ - fun imageToBraille(bitmap: Bitmap, targetWidth: Int, invert: Boolean = false): String { - if (targetWidth <= 0) return "" - val charWidth = targetWidth * 2 - val aspectRatio = bitmap.height.toDouble() / bitmap.width.toDouble() - val charHeight = (charWidth * aspectRatio * 0.5).toInt().coerceAtLeast(1) - val scaledBitmap = bitmap.scale(charWidth, charHeight) - val sb = StringBuilder() - - for (y in 0 until charHeight step 4) { - for (x in 0 until charWidth step 2) { - var brailleCode = 0x2800 - var dotValue = 0 - // Braille dots are numbered 1-8, column by column - if (y + 0 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y]) > 128) 1 else 0; brailleCode += dotValue * 1 - if (y + 1 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y + 1]) > 128) 1 else 0; brailleCode += dotValue * 2 - if (y + 2 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y + 2]) > 128) 1 else 0; brailleCode += dotValue * 4 - if (y + 0 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y]) > 128) 1 else 0; brailleCode += dotValue * 8 - if (y + 1 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y + 1]) > 128) 1 else 0; brailleCode += dotValue * 16 - if (y + 2 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y + 2]) > 128) 1 else 0; brailleCode += dotValue * 32 - if (y + 3 < charHeight && x + 0 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x, y + 3]) > 128) 1 else 0; brailleCode += dotValue * 64 - if (y + 3 < charHeight && x + 1 < charWidth) dotValue = if (Color.alpha(scaledBitmap[x + 1, y + 3]) > 128) 1 else 0; brailleCode += dotValue * 128 - - if (invert) brailleCode = 0x28FF - (brailleCode - 0x2800) - if (brailleCode == 0x2800) sb.append(EMPTY_CHAR) else sb.append(brailleCode.toChar()) - } - sb.append('\n') - } - return sb.toString() - } - - /** - * Applies "Zalgo" effect to text, making it look corrupted or demonic. - */ - fun zalgo(text: String, up: Int = 3, mid: Int = 2, down: Int = 3): String { - val sb = StringBuilder() - text.forEach { char -> - sb.append(char) - repeat(Random.nextInt(up)) { sb.append(Tricks.ZALGO_UP.random()) } - repeat(Random.nextInt(mid)) { sb.append(Tricks.ZALGO_MID.random()) } - repeat(Random.nextInt(down)) { sb.append(Tricks.ZALGO_DOWN.random()) } - } - return sb.toString() - } -} diff --git a/app/src/main/java/com/bitchat/android/util/AnsiGrid.kt b/app/src/main/java/com/bitchat/android/util/AnsiGrid.kt deleted file mode 100644 index 6d1a3ba61..000000000 --- a/app/src/main/java/com/bitchat/android/util/AnsiGrid.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.bitchat.android.util - -import android.graphics.Canvas -import android.graphics.Paint - -/** - * A helper class to manage a grid of characters for ANSI-style art. - * This makes it easier to "draw" text, shapes, and other elements onto a character-based canvas. - */ -class AnsiGrid( - val width: Int, - val height: Int, - val textPaint: Paint -) { - // A grid of characters. - private val grid: Array> = Array(height) { - Array(width) { AnsiChars.EMPTY_CHAR } - } - - fun setChar(x: Int, y: Int, char: Char) { - if (x in 0 until width && y in 0 until height) { - grid[y][x] = char - } - } - - fun drawText(x: Int, y: Int, text: String) { - text.forEachIndexed { index, char -> - setChar(x + index, y, char) - } - } - - fun clear() { - for (y in 0 until height) { - for (x in 0 until width) { - grid[y][x] = AnsiChars.EMPTY_CHAR - } - } - } - - /** - * Renders the entire grid onto a Canvas, drawing character-by-character to enforce a strict grid. - * @param canvas The Android Canvas to draw on. - * @param charWidth The calculated width of a single character cell in pixels. - * @param charHeight The calculated height of a single character cell in pixels. - */ - fun render(canvas: Canvas, charWidth: Float, charHeight: Float) { - val baselineOffset = textPaint.fontMetrics.descent - - for (y in 0 until height) { - for (x in 0 until width) { - val charToDraw = grid[y][x] - // Skip drawing blank characters for a minor performance improvement. - if (charToDraw == AnsiChars.EMPTY_CHAR || charToDraw == AnsiChars.BRAILLE_BLANK) { - continue - } - - // Calculate the exact X and Y position for this character in the grid. - val drawX = x * charWidth - val drawY = (y + 1) * charHeight - baselineOffset - - // Draw the single character at the calculated position. - canvas.drawText(charToDraw.toString(), drawX, drawY, textPaint) - } - } - } -} diff --git a/app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt b/app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt deleted file mode 100644 index 68fa988af..000000000 --- a/app/src/main/java/com/bitchat/android/util/ProximityDrawable.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.bitchat.android.ui.drawables - -import android.graphics.* -import android.graphics.drawable.Drawable -import androidx.core.graphics.drawable.toBitmap - -/** - * A custom drawable that renders a 4-bar signal strength indicator. - * The number of active bars is determined by the proximity level. - * - * @param proximity An integer from 0 to 4 representing signal strength. - * @param activeColor The color of the active bars. - * @param inactiveColor The color of the inactive bars. - */ -class ProximityDrawable( - private val proximity: Int, - private val activeColor: Int, - private val inactiveColor: Int -) : Drawable() { - - private val paint = Paint().apply { - style = Paint.Style.FILL - } - - // Defines the relative heights of the four bars. - private val barHeightFactors = listOf(0.4f, 0.6f, 0.8f, 1.0f) - - override fun draw(canvas: Canvas) { - val width = bounds.width().toFloat() - val height = bounds.height().toFloat() - val barWidth = width / 7f // Leaves space between bars - val barSpacing = barWidth / 2f - - for (i in 0..3) { - paint.color = if (i < proximity) activeColor else inactiveColor - - val barHeight = height * barHeightFactors[i] - val left = (barWidth + barSpacing) * i - val top = height - barHeight - - canvas.drawRoundRect(left, top, left + barWidth, height, 2f, 2f, paint) - } - } - - // Helper to convert this drawable to a Bitmap, which RemoteViews requires. - fun toBitmap(width: Int, height: Int): Bitmap { - return this.toBitmap(width, height, Bitmap.Config.ARGB_8888) - } - - override fun setAlpha(alpha: Int) { - paint.alpha = alpha - } - - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.colorFilter = colorFilter - } - - @Deprecated("Deprecated in Java") - override fun getOpacity(): Int = PixelFormat.TRANSLUCENT -} \ No newline at end of file diff --git a/app/src/main/res/drawable/close_24px.xml b/app/src/main/res/drawable/close_24px.xml deleted file mode 100644 index 7a0ff35df..000000000 --- a/app/src/main/res/drawable/close_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/notification_bg_terminal.xml b/app/src/main/res/drawable/notification_bg_terminal.xml deleted file mode 100644 index ffb838463..000000000 --- a/app/src/main/res/drawable/notification_bg_terminal.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/notifications_active_24px.xml b/app/src/main/res/drawable/notifications_active_24px.xml deleted file mode 100644 index 266bf0c12..000000000 --- a/app/src/main/res/drawable/notifications_active_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/notifications_off_24px.xml b/app/src/main/res/drawable/notifications_off_24px.xml deleted file mode 100644 index 462629adb..000000000 --- a/app/src/main/res/drawable/notifications_off_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/reply_24px.xml b/app/src/main/res/drawable/reply_24px.xml deleted file mode 100644 index 718f8e912..000000000 --- a/app/src/main/res/drawable/reply_24px.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - From 151093112e8f9dcf57b40830713dc3f784cdf068 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 19:29:58 -0700 Subject: [PATCH 09/13] repairing diff --- .../java/com/bitchat/android/MainActivity.kt | 317 ++++++++---------- .../java/com/bitchat/android/ui/ChatScreen.kt | 32 +- 2 files changed, 156 insertions(+), 193 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index ed662ee10..d3d27546a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -16,21 +16,21 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.Lifecycle import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import com.bitchat.android.mesh.BluetoothMeshService 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.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 @@ -42,8 +42,6 @@ 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.ExitConfirmationDialog -import com.bitchat.android.ui.NotificationManager import com.bitchat.android.ui.theme.BitchatTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -64,9 +62,6 @@ class MainActivity : ComponentActivity() { @Volatile private var foregroundService: ForegroundService? = null @Volatile private var isServiceBound = false - // State for the exit confirmation dialog - private var showExitDialog by mutableStateOf(false) - private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { viewModelFactory { @@ -100,8 +95,20 @@ class MainActivity : ComponentActivity() { } override fun onServiceStopping() { - Log.w(TAG, "ForegroundService is stopping, closing the app.") - finishAndRemoveTask() + Log.w(TAG, "ForegroundService stopping") + finish() + } + } + + 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) { + startForegroundService(serviceIntent) + } + // Bind to the service to get a reference to it. + if (!isServiceBound) { + bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE) } } @@ -134,18 +141,7 @@ class MainActivity : ComponentActivity() { onOnboardingComplete = ::handleOnboardingComplete, onOnboardingFailed = ::handleOnboardingFailed ) - - // Handle back press - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // If the chat view model handles the back press (e.g., closing a sidebar), - // do nothing. Otherwise, show our exit confirmation dialog. - if (!chatViewModel.handleBackPressed()) { - showExitDialog = true - } - } - }) - + setContent { BitchatTheme { Surface( @@ -153,49 +149,26 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { OnboardingFlowScreen() - - // Display the exit dialog when its state is true - ExitConfirmationDialog( - show = showExitDialog, - onDismiss = { showExitDialog = false }, - onConfirmBackground = { - showExitDialog = false - moveTaskToBack(true) - }, - onConfirmExit = { - showExitDialog = false - stopServiceAndExit() - } - ) } } } - + // Collect state changes in a lifecycle-aware manner lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - // Listen for onboarding state changes - launch { - mainViewModel.onboardingState.collect { state -> - handleOnboardingStateChange(state) - } - } - // Listen for shutdown requests from the ViewModel - launch { - chatViewModel.shutdownRequest.collect { - stopServiceAndExit() - } + 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() @@ -206,12 +179,12 @@ class MainActivity : ComponentActivity() { val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState() val isLocationLoading by mainViewModel.isLocationLoading.collectAsState() val isBatteryOptimizationLoading by mainViewModel.isBatteryOptimizationLoading.collectAsState() - + when (onboardingState) { OnboardingState.CHECKING -> { InitializingScreen() } - + OnboardingState.BLUETOOTH_CHECK -> { BluetoothCheckScreen( status = bluetoothStatus, @@ -225,7 +198,7 @@ class MainActivity : ComponentActivity() { isLoading = isBluetoothLoading ) } - + OnboardingState.LOCATION_CHECK -> { LocationCheckScreen( status = locationStatus, @@ -239,7 +212,7 @@ class MainActivity : ComponentActivity() { isLoading = isLocationLoading ) } - + OnboardingState.BATTERY_OPTIMIZATION_CHECK -> { BatteryOptimizationScreen( status = batteryOptimizationStatus, @@ -257,7 +230,7 @@ class MainActivity : ComponentActivity() { isLoading = isBatteryOptimizationLoading ) } - + OnboardingState.PERMISSION_EXPLANATION -> { PermissionExplanationScreen( permissionCategories = permissionManager.getCategorizedPermissions(), @@ -267,20 +240,20 @@ class MainActivity : ComponentActivity() { } ) } - + OnboardingState.PERMISSION_REQUESTING -> { InitializingScreen() } - + OnboardingState.INITIALIZING -> { InitializingScreen() startAndBindService() } - + OnboardingState.COMPLETE -> { ChatScreen(viewModel = chatViewModel) } - + OnboardingState.ERROR -> { InitializationErrorScreen( errorMessage = errorMessage, @@ -295,51 +268,51 @@ class MainActivity : ComponentActivity() { } } } - + private fun handleOnboardingStateChange(state: OnboardingState) { when (state) { OnboardingState.COMPLETE -> { // App is fully initialized, mesh service is running - Log.d(TAG, "Onboarding completed - app ready") + android.util.Log.d("MainActivity", "Onboarding completed - app ready") } OnboardingState.ERROR -> { - Log.e(TAG, "Onboarding error state reached") + android.util.Log.e("MainActivity", "Onboarding error state reached") } else -> {} } } - + private fun checkOnboardingStatus() { - Log.d(TAG, "Checking onboarding status") - + Log.d("MainActivity", "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(TAG, "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()) { - Log.d(TAG, "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() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) - + when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next @@ -347,46 +320,47 @@ class MainActivity : ComponentActivity() { } BluetoothStatus.DISABLED -> { // Show Bluetooth enable screen (should have permissions as existing user) - Log.d(TAG, "Bluetooth disabled, showing enable screen") + Log.d("MainActivity", "Bluetooth disabled, showing enable screen") mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateBluetoothLoading(false) } BluetoothStatus.NOT_SUPPORTED -> { // Device doesn't support Bluetooth - Log.e(TAG, "Bluetooth not supported") + android.util.Log.e("MainActivity", "Bluetooth not supported") mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateBluetoothLoading(false) } } } - + /** - * Proceed with permission checking + * Proceed with permission checking */ private fun proceedWithPermissionCheck() { - Log.d(TAG, "Proceeding with permission check") - + Log.d("MainActivity", "Proceeding with permission check") + lifecycleScope.launch { delay(200) // Small delay for smooth transition - + if (permissionManager.isFirstTimeLaunch()) { - Log.d(TAG, "First time launch, showing permission explanation") + Log.d("MainActivity", "First time launch, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else if (permissionManager.areAllPermissionsGranted()) { - Log.d(TAG, "Existing user with permissions, initializing app") + Log.d("MainActivity", "Existing user with permissions, initializing app") mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) + initializeApp() } else { - Log.d(TAG, "Existing user missing permissions, showing explanation") + Log.d("MainActivity", "Existing user missing permissions, showing explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } } } - + /** * Handle Bluetooth enabled callback */ private fun handleBluetoothEnabled() { - Log.d(TAG, "Bluetooth enabled by user") + Log.d("MainActivity", "Bluetooth enabled by user") mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED) checkLocationAndProceed() @@ -396,20 +370,20 @@ class MainActivity : ComponentActivity() { * Check Location services status and proceed with onboarding flow */ private fun checkLocationAndProceed() { - Log.d(TAG, "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()) { - Log.d(TAG, "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() mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) - + when (mainViewModel.locationStatus.value) { LocationStatus.ENABLED -> { // Location services enabled, check battery optimization next @@ -417,13 +391,13 @@ class MainActivity : ComponentActivity() { } LocationStatus.DISABLED -> { // Show location enable screen (should have permissions as existing user) - Log.d(TAG, "Location services disabled, showing enable screen") + 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) - Log.e(TAG, "Location services not available") + Log.e("MainActivity", "Location services not available") mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } @@ -434,7 +408,7 @@ class MainActivity : ComponentActivity() { * Handle Location enabled callback */ private fun handleLocationEnabled() { - Log.d(TAG, "Location services enabled by user") + Log.d("MainActivity", "Location services enabled by user") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(LocationStatus.ENABLED) checkBatteryOptimizationAndProceed() @@ -444,7 +418,7 @@ class MainActivity : ComponentActivity() { * Handle Location disabled callback */ private fun handleLocationDisabled(message: String) { - Log.w(TAG, "Location services disabled or failed: $message") + Log.w("MainActivity", "Location services disabled or failed: $message") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) @@ -460,15 +434,15 @@ class MainActivity : ComponentActivity() { } } } - + /** * Handle Bluetooth disabled callback */ private fun handleBluetoothDisabled(message: String) { - Log.w(TAG, "Bluetooth disabled or failed: $message") + Log.w("MainActivity", "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 @@ -478,12 +452,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(TAG, "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 - Log.d(TAG, "Bluetooth enable requires permissions, showing permission explanation") + Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else -> { @@ -492,10 +466,10 @@ class MainActivity : ComponentActivity() { } } } - + private fun handleOnboardingComplete() { - Log.d(TAG, "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 Bluetooth, Location, and Battery Optimization status val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentLocationStatus = locationStatusManager.checkLocationStatus() @@ -504,57 +478,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(TAG, "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") + 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 - Log.d(TAG, "Permissions granted, but Location services still disabled. Showing Location enable screen.") + 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 - Log.d(TAG, "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.") + android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.") mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateBatteryOptimizationLoading(false) } else -> { // Both are enabled, proceed to app initialization - Log.d(TAG, "Both Bluetooth and Location services are enabled, proceeding to initialization") + Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization") mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) + initializeApp() } } } - + private fun handleOnboardingFailed(message: String) { - Log.e(TAG, "Onboarding failed: $message") + 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() { - Log.d(TAG, "Checking battery optimization status") - + 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()) { - Log.d(TAG, "First-time launch, skipping battery optimization check - will check after permissions") + 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 { @@ -563,7 +538,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 @@ -571,28 +546,28 @@ class MainActivity : ComponentActivity() { } BatteryOptimizationStatus.ENABLED -> { // Show battery optimization disable screen - Log.d(TAG, "Battery optimization enabled, showing 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() { - Log.d(TAG, "Battery optimization disabled by user") + 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) { - Log.w(TAG, "Battery optimization disable failed: $message") + android.util.Log.w("MainActivity", "Battery optimization disable failed: $message") mainViewModel.updateBatteryOptimizationLoading(false) val currentStatus = when { !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED @@ -600,55 +575,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(TAG, "Starting app initialization") - + Log.d("MainActivity", "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(TAG, "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() - Log.w(TAG, "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 } - + + // Set up mesh service delegate and start services foregroundService?.getMeshService()?.let { chatViewModel.initialize(it) } - + + Log.d("MainActivity", "Mesh service started successfully") + + // Handle any notification intent + handleNotificationIntent(intent) + // Small delay to ensure mesh service is fully initialized delay(500) - Log.d(TAG, "App initialization complete") + Log.d("MainActivity", "App initialization complete") mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) } catch (e: Exception) { - Log.e(TAG, "Failed to initialize app", e) + Log.e("MainActivity", "Failed to initialize app", e) handleOnboardingFailed("Failed to initialize the app: ${e.message}") } } } - - 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) { - startForegroundService(serviceIntent) - } - // Bind to the service to get a reference to it. - if (!isServiceBound) { - bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE) - } - } - + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running @@ -656,7 +625,7 @@ class MainActivity : ComponentActivity() { handleNotificationIntent(intent) } } - + override fun onResume() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly @@ -664,21 +633,22 @@ class MainActivity : ComponentActivity() { // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() if (currentBluetoothStatus != BluetoothStatus.ENABLED) { - Log.w(TAG, "Bluetooth disabled while app was backgrounded") + 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) { - Log.w(TAG, "Location services disabled while app was backgrounded") + Log.w("MainActivity", "Location services disabled while app was backgrounded") mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } + startAndBindService() } } @@ -692,55 +662,48 @@ class MainActivity : ComponentActivity() { 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( - NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, + com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, false ) - + if (shouldOpenPrivateChat) { - val peerID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID) - val senderNickname = intent.getStringExtra(NotificationManager.EXTRA_SENDER_NICKNAME) - + 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(TAG, "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) - + // 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.") - val intent = Intent(ForegroundService.ACTION_SHUTDOWN).apply { - // Ensure the broadcast is delivered only to our app's receiver - `package` = packageName - } - sendBroadcast(intent) - } - - override fun onDestroy() { super.onDestroy() - // Cleanup location status manager try { locationStatusManager.cleanup() - Log.d(TAG, "Location status manager cleaned up successfully") + Log.d("MainActivity", "Location status manager cleaned up successfully") } catch (e: Exception) { - Log.w(TAG, "Error cleaning up location status manager: ${e.message}") + Log.w("MainActivity", "Error cleaning up location status manager: ${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/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index ee974e142..c4dd311c8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -65,31 +65,31 @@ fun ChatScreen(viewModel: ChatViewModel) { val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val showAppInfo by viewModel.showAppInfo.observeAsState(false) - + var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } - + // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { showPasswordDialog = showPasswordPrompt } - + val isConnected by viewModel.isConnected.observeAsState(false) val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) - + // Determine what messages to show val displayMessages = when { selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList() currentChannel != null -> channelMessages[currentChannel] ?: emptyList() else -> messages } - + // Use WindowInsets to handle keyboard properly Box(modifier = Modifier.fillMaxSize()) { val headerHeight = 42.dp - + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -99,7 +99,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { // Header spacer - creates space for the floating header Spacer(modifier = Modifier.height(headerHeight)) - + // Messages area - takes up available space, will compress when keyboard appears MessagesList( messages = displayMessages, @@ -107,7 +107,7 @@ fun ChatScreen(viewModel: ChatViewModel) { meshService = viewModel.meshService, modifier = Modifier.weight(1f) ) - + // Input area - stays at bottom ChatInputSection( messageText = messageText, @@ -136,7 +136,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme ) } - + // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( headerHeight = headerHeight, @@ -149,7 +149,7 @@ fun ChatScreen(viewModel: ChatViewModel) { onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() } ) - + // Sidebar overlay AnimatedVisibility( visible = showSidebar, @@ -161,7 +161,7 @@ fun ChatScreen(viewModel: ChatViewModel) { targetOffsetX = { it }, animationSpec = tween(250, easing = EaseInCubic) ) + fadeOut(animationSpec = tween(250)), - modifier = Modifier.zIndex(2f) + modifier = Modifier.zIndex(2f) ) { SidebarOverlay( viewModel = viewModel, @@ -170,7 +170,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) } } - + // Dialogs ChatDialogs( showPasswordDialog = showPasswordDialog, @@ -215,7 +215,7 @@ private fun ChatInputSection( ) { Column { HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - + // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { CommandSuggestionsBox( @@ -226,7 +226,7 @@ private fun ChatInputSection( HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) } - + MessageInput( value = messageText, onValueChange = onMessageTextChange, @@ -285,7 +285,7 @@ private fun ChatFloatingHeader( ) ) } - + // Divider under header HorizontalDivider( modifier = Modifier @@ -316,7 +316,7 @@ private fun ChatDialogs( onConfirm = onPasswordConfirm, onDismiss = onPasswordDismiss ) - + // App info dialog AppInfoDialog( show = showAppInfo, From 9bec5184b0cd3fca706ac3cbcaa51792d21c5d59 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 20:38:09 -0700 Subject: [PATCH 10/13] fixed UI background/shutdown logic --- .../java/com/bitchat/android/MainActivity.kt | 132 ++++++----- .../bitchat/android/mesh/ForegroundService.kt | 8 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 10 + .../java/com/bitchat/android/ui/ChatState.kt | 32 ++- .../com/bitchat/android/ui/ChatViewModel.kt | 213 +++++++++--------- 5 files changed, 230 insertions(+), 165 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index d3d27546a..6ab5e3c43 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -7,7 +7,6 @@ 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 @@ -17,20 +16,18 @@ 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.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.Lifecycle import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.ForegroundService -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.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 @@ -161,12 +158,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 @@ -282,25 +294,25 @@ class MainActivity : ComponentActivity() { else -> {} } } - + private fun checkOnboardingStatus() { Log.d("MainActivity", "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") - + // For first-time users, skip Bluetooth check and go straight to permissions // We'll check Bluetooth after permissions are granted if (permissionManager.isFirstTimeLaunch()) { @@ -308,11 +320,11 @@ class MainActivity : ComponentActivity() { 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 @@ -332,16 +344,16 @@ class MainActivity : ComponentActivity() { } } } - + /** - * Proceed with permission checking + * Proceed with permission checking */ private fun proceedWithPermissionCheck() { Log.d("MainActivity", "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") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) @@ -355,7 +367,7 @@ class MainActivity : ComponentActivity() { } } } - + /** * Handle Bluetooth enabled callback */ @@ -371,7 +383,7 @@ class MainActivity : ComponentActivity() { */ private fun checkLocationAndProceed() { 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()) { @@ -379,11 +391,11 @@ class MainActivity : ComponentActivity() { 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 @@ -434,7 +446,7 @@ class MainActivity : ComponentActivity() { } } } - + /** * Handle Bluetooth disabled callback */ @@ -442,7 +454,7 @@ class MainActivity : ComponentActivity() { Log.w("MainActivity", "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 @@ -466,10 +478,10 @@ class MainActivity : ComponentActivity() { } } } - + private fun handleOnboardingComplete() { Log.d("MainActivity", "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() @@ -478,7 +490,7 @@ 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 @@ -509,19 +521,19 @@ class MainActivity : ComponentActivity() { } } } - + private fun handleOnboardingFailed(message: String) { 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()) { @@ -529,7 +541,7 @@ class MainActivity : ComponentActivity() { proceedWithPermissionCheck() return } - + // For existing users, check battery optimization status batteryOptimizationManager.logBatteryOptimizationStatus() val currentBatteryOptimizationStatus = when { @@ -538,7 +550,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 @@ -546,23 +558,23 @@ class MainActivity : ComponentActivity() { } BatteryOptimizationStatus.ENABLED -> { // Show battery optimization disable screen - android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen") + 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") + Log.d("MainActivity", "Battery optimization disabled by user") mainViewModel.updateBatteryOptimizationLoading(false) mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED) proceedWithPermissionCheck() } - + /** * Handle Battery Optimization failed callback */ @@ -575,22 +587,22 @@ 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") - + 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") - + // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { val missing = permissionManager.getMissingPermissions() @@ -598,15 +610,15 @@ class MainActivity : ComponentActivity() { handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.") return@launch } - + // Set up mesh service delegate and start services foregroundService?.getMeshService()?.let { chatViewModel.initialize(it) } - + Log.d("MainActivity", "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") @@ -617,7 +629,7 @@ class MainActivity : ComponentActivity() { } } } - + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running @@ -625,7 +637,7 @@ class MainActivity : ComponentActivity() { handleNotificationIntent(intent) } } - + override fun onResume() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly @@ -639,7 +651,7 @@ class MainActivity : ComponentActivity() { mainViewModel.updateBluetoothLoading(false) return } - + // Check if location services were disabled while app was backgrounded val currentLocationStatus = locationStatusManager.checkLocationStatus() if (currentLocationStatus != LocationStatus.ENABLED) { @@ -662,32 +674,42 @@ class MainActivity : ComponentActivity() { 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") - + // 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 diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index 9d5d433ee..ba101c8ad 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -219,7 +219,11 @@ class ForegroundService : Service(), BluetoothMeshDelegate { .setStyle(inboxStyle) .setContentIntent(createMainPendingIntent()) .setOngoing(true) - .addAction(0, getString(R.string.notification_action_shutdown), createActionPendingIntent(ACTION_SHUTDOWN)) + .addAction( + 0, + getString( + R.string.notification_action_shutdown), + createActionPendingIntent(ACTION_SHUTDOWN)) if (alert) { builder.setOnlyAlertOnce(false) @@ -272,7 +276,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { } } - private fun shutdownService() { + internal fun shutdownService() { Log.i(TAG, "Shutdown action triggered. Stopping service.") serviceListener?.onServiceStopping() stopForeground(STOP_FOREGROUND_REMOVE) 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 c4dd311c8..2c21a1508 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import androidx.lifecycle.viewmodel.compose.viewModel import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.BluetoothMeshService @@ -65,6 +66,7 @@ fun ChatScreen(viewModel: ChatViewModel) { val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) val commandSuggestions by viewModel.commandSuggestions.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) } @@ -193,6 +195,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 a5a6e17a3..4b396b616 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 @@ -96,7 +98,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() @@ -133,7 +147,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 @@ -229,4 +245,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 50fedc2f1..3d7e6cbf5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1,24 +1,18 @@ 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 -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.application import androidx.lifecycle.viewModelScope -import com.bitchat.android.BitchatApplication import com.bitchat.android.mesh.BluetoothMeshDelegate 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 kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import java.util.* +import kotlinx.coroutines.launch +import java.util.Date import kotlin.random.Random /** @@ -38,25 +32,25 @@ class ChatViewModel( // State management private val state = ChatState() - + // Specialized managers private val dataManager = DataManager(application.applicationContext) private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) - + // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) - override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) + override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) override fun broadcastNoiseIdentityAnnouncement() = meshService.broadcastNoiseIdentityAnnouncement() override fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte) = meshService.sendHandshakeRequest(targetPeerID, pendingCount) override fun getMyPeerID(): String = meshService.myPeerID } - + 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, @@ -69,7 +63,7 @@ class ChatViewModel( getMyPeerID = { meshService.myPeerID }, getMeshService = { meshService } ) - + // Expose state through LiveData (maintaining the same interface) val messages: LiveData> = state.messages val connectedPeers: LiveData> = state.connectedPeers @@ -94,31 +88,27 @@ class ChatViewModel( val peerSessionStates: LiveData> = state.peerSessionStates val peerFingerprints: LiveData> = state.peerFingerprints val showAppInfo: LiveData = state.showAppInfo - - // New LiveData to control the exit confirmation dialog - private val _showExitDialog = MutableLiveData(false) - val showExitDialog: LiveData = _showExitDialog - - // Flow to signal a shutdown request to the Activity - private val _shutdownRequest = MutableSharedFlow() - val shutdownRequest = _shutdownRequest.asSharedFlow() + 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() { - viewModelScope.launch { - _shutdownRequest.emit(Unit) - } - _showExitDialog.value = false + state.setShutdownRequest() + } + + fun requestBackground() { + state.setBackgroundRequest() } /** * Dismisses the exit confirmation dialog. */ fun dismissExitConfirmation() { - _showExitDialog.value = false + state.setShowExitDialog(false) } fun initialize(meshService: BluetoothMeshService) { @@ -132,12 +122,12 @@ class ChatViewModel( // Load nickname val nickname = dataManager.loadNickname() state.setNickname(nickname) - + // Load data val (joinedChannels, protectedChannels) = channelManager.loadChannelData() state.setJoinedChannels(joinedChannels) state.setPasswordProtectedChannels(protectedChannels) - + // Initialize channel messages joinedChannels.forEach { channel -> if (!state.getChannelMessagesValue().containsKey(channel)) { @@ -146,16 +136,16 @@ class ChatViewModel( state.setChannelMessages(updatedChannelMessages) } } - + // Load other data dataManager.loadFavorites() state.setFavoritePeers(dataManager.favoritePeers) dataManager.loadBlockedUsers() - + // Log all favorites at startup dataManager.logAllFavorites() logCurrentFavoriteState() - + // Initialize session state monitoring initializeSessionStateMonitoring() @@ -173,37 +163,37 @@ class ChatViewModel( } } } - + override fun onCleared() { super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } - + // MARK: - Nickname Management - + fun setNickname(newNickname: String) { state.setNickname(newNickname) dataManager.saveNickname(newNickname) meshService.sendBroadcastAnnounce() } - + // MARK: - Channel Management (delegated) - + fun joinChannel(channel: String, password: String? = null): Boolean { return channelManager.joinChannel(channel, password, meshService.myPeerID) } - + fun switchToChannel(channel: String?) { channelManager.switchToChannel(channel) } - + fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) meshService.sendMessage("left $channel") } - + // MARK: - Private Chat Management (delegated) - + fun startPrivateChat(peerID: String) { val success = privateChatManager.startPrivateChat(peerID, meshService) if (success) { @@ -213,18 +203,18 @@ class ChatViewModel( clearNotificationsForSender(peerID) } } - + fun endPrivateChat() { privateChatManager.endPrivateChat() // Notify notification manager that no private chat is active setCurrentPrivateChatPeer(null) } - + // MARK: - Message Sending - + fun sendMessage(content: String) { if (content.isEmpty()) return - + // Check for commands if (content.startsWith("/")) { commandProcessor.processCommand(content, meshService, meshService.myPeerID) { messageContent, mentions, channel -> @@ -232,26 +222,26 @@ class ChatViewModel( } return } - + val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue()) val channels = messageManager.parseChannels(content) - + // Auto-join mentioned channels channels.forEach { channel -> if (!state.getJoinedChannelsValue().contains(channel)) { joinChannel(channel) } } - + val selectedPeer = state.getSelectedPrivateChatPeerValue() val currentChannelValue = state.getCurrentChannelValue() - + if (selectedPeer != null) { // Send private message val recipientNickname = meshService.getPeerNicknames()[selectedPeer] privateChatManager.sendPrivateMessage( - content, - selectedPeer, + content, + selectedPeer, recipientNickname, state.getNicknameValue(), meshService.myPeerID @@ -269,16 +259,16 @@ class ChatViewModel( mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) - + if (currentChannelValue != null) { channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) - + // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { channelManager.sendEncryptedChannelMessage( - content, - mentions, - currentChannelValue, + content, + mentions, + currentChannelValue, state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> @@ -298,21 +288,21 @@ class ChatViewModel( } } } - + // MARK: - Utility Functions - + fun getPeerIDForNickname(nickname: String): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - + fun toggleFavorite(peerID: String) { Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) - + // Log current state after toggle logCurrentFavoriteState() } - + private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") @@ -320,7 +310,7 @@ class ChatViewModel( Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") } - + /** * Initialize session state monitoring for reactive UI updates */ @@ -332,133 +322,133 @@ class ChatViewModel( } } } - + /** * Update reactive states for all connected peers (session states and fingerprints) */ private fun updateReactiveStates() { val currentPeers = state.getConnectedPeersValue() - + // Update session states val sessionStates = currentPeers.associateWith { peerID -> meshService.getSessionState(peerID).toString() } state.setPeerSessionStates(sessionStates) - + // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) } - + // MARK: - Debug and Troubleshooting - + fun getDebugStatus(): String { return meshService.getDebugStatus() } - + // Note: Mesh service restart is now handled by MainActivity // This function is no longer needed - + // FIXME: Move DM notifications to foreground service fun setAppBackgroundState(inBackground: Boolean) { // Forward to notification manager for notification logic notificationManager.setAppBackgroundState(inBackground) } - + fun setCurrentPrivateChatPeer(peerID: String?) { // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } - + fun clearNotificationsForSender(peerID: String) { // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } - + // MARK: - Command Autocomplete (delegated) - + fun updateCommandSuggestions(input: String) { commandProcessor.updateCommandSuggestions(input) } - + fun selectCommandSuggestion(suggestion: CommandSuggestion): String { return commandProcessor.selectCommandSuggestion(suggestion) } - + // MARK: - BluetoothMeshDelegate Implementation (delegated) - + override fun didReceiveMessage(message: BitchatMessage) { meshDelegateHandler.didReceiveMessage(message) } - + override fun didConnectToPeer(peerID: String) { meshDelegateHandler.didConnectToPeer(peerID) } - + override fun didDisconnectFromPeer(peerID: String) { meshDelegateHandler.didDisconnectFromPeer(peerID) } - + override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } - + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } - + override fun didReceiveDeliveryAck(ack: DeliveryAck) { meshDelegateHandler.didReceiveDeliveryAck(ack) } - + override fun didReceiveReadReceipt(receipt: ReadReceipt) { meshDelegateHandler.didReceiveReadReceipt(receipt) } - + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) } - + override fun getNickname(): String? { return meshDelegateHandler.getNickname() } - + override fun isFavorite(peerID: String): Boolean { return meshDelegateHandler.isFavorite(peerID) } - + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager - + // MARK: - Emergency Clear - + fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") - + // Clear all UI managers messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() dataManager.clearAllData() - + // Clear all mesh service data clearAllMeshServiceData() - + // Clear all cryptographic data clearAllCryptographicData() - + // Clear all notifications notificationManager.clearAllNotifications() - + // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - + Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared") - + // Note: Mesh service restart is now handled by MainActivity // This method now only clears data, not mesh service lifecycle } - + /** * Clear all mesh service related data */ @@ -466,13 +456,13 @@ class ChatViewModel( try { // Request mesh service to clear all its internal data meshService.clearAllInternalData() - + Log.d(TAG, "✅ Cleared all mesh service data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}") } } - + /** * Clear all cryptographic data including persistent identity */ @@ -480,7 +470,7 @@ class ChatViewModel( try { // Clear encryption service persistent identity (Ed25519 signing keys) meshService.clearAllEncryptionData() - + // Clear secure identity state (if used) try { val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) @@ -489,31 +479,39 @@ class ChatViewModel( } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") } - + Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") } } - + // MARK: - Navigation Management - + fun showAppInfo() { state.setShowAppInfo(true) } - + fun hideAppInfo() { state.setShowAppInfo(false) } - + fun showSidebar() { state.setShowSidebar(true) } - + fun hideSidebar() { state.setShowSidebar(false) } + fun showExitDialog() { + state.setShowExitDialog(true) + } + + fun shutdown() { + state.setShutdownRequest() + } + /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system @@ -547,7 +545,10 @@ class ChatViewModel( true } // No special navigation state - let system handle (usually exits app) - else -> false + else -> { + showExitDialog() + true + } } } } From 4f53fcaf50af52d1b923dc8b154aab9778d1ac19 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 21:14:54 -0700 Subject: [PATCH 11/13] patched notification state for now, DMs have separate channel --- .../java/com/bitchat/android/MainActivity.kt | 21 +++++- .../bitchat/android/mesh/ForegroundService.kt | 14 ++-- .../java/com/bitchat/android/ui/ChatScreen.kt | 65 ++++++++++--------- 3 files changed, 61 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6ab5e3c43..9bd563942 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -97,14 +97,31 @@ class MainActivity : ComponentActivity() { } } + + /** + * 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) } } @@ -261,7 +278,7 @@ class MainActivity : ComponentActivity() { InitializingScreen() startAndBindService() } - + OnboardingState.COMPLETE -> { ChatScreen(viewModel = chatViewModel) } @@ -640,6 +657,7 @@ class MainActivity : ComponentActivity() { 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) { // Check if Bluetooth was disabled while app was backgrounded @@ -667,6 +685,7 @@ class MainActivity : ComponentActivity() { override fun onPause() { super.onPause() + chatViewModel.setAppBackgroundState(inBackground = true) // Only unbind if the service is actually bound if (isServiceBound) { unbindService(serviceConnection) diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index ba101c8ad..ff83705cc 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -16,7 +16,6 @@ import android.util.Log import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import com.bitchat.android.BuildConfig import com.bitchat.android.R import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryAck @@ -28,7 +27,6 @@ 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. @@ -52,12 +50,11 @@ class ForegroundService : Service(), BluetoothMeshDelegate { 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 + private const val MOCK_PEERS_ENABLED = true 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 @@ -67,6 +64,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { override fun onCreate() { super.onCreate() + Log.d(TAG, "Foreground Service onCreate") isServiceRunning = true notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager @@ -193,13 +191,14 @@ class ForegroundService : Service(), BluetoothMeshDelegate { 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 contentText = resources.getQuantityString(R.plurals.unread_messages, unreadMessageCount, unreadMessageCount) + val summaryText = resources.getQuantityString(R.plurals.unread_messages, unreadMessageCount, unreadMessageCount) // Expanded view style using InboxStyle val inboxStyle = NotificationCompat.InboxStyle() .setBigContentTitle(contentTitle) - .setSummaryText(getString(R.string.notification_summary)) + .setSummaryText(summaryText) // Add each peer to the expanded view if (activePeers.isNotEmpty()) { @@ -208,7 +207,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { inboxStyle.addLine("${peer.nickname} $proximityBars") } } else { - inboxStyle.addLine(getString(R.string.notification_scanning)) + inboxStyle.addLine(contentText) } val builder = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) @@ -216,6 +215,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { .setColor(colors.primary.toArgb()) .setContentTitle(contentTitle) .setContentText(contentText) + .setNumber(unreadMessageCount) .setStyle(inboxStyle) .setContentIntent(createMainPendingIntent()) .setOngoing(true) 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 2c21a1508..db4f4f4ce 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,43 +1,46 @@ package com.bitchat.android.ui -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInCubic +import androidx.compose.animation.core.EaseOutCubic +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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 -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.lifecycle.viewmodel.compose.viewModel -import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.model.DeliveryStatus -import com.bitchat.android.mesh.BluetoothMeshService -import java.text.SimpleDateFormat -import java.util.* /** * Main ChatScreen - REFACTORED to use component-based architecture From 53a6ce49735b482e000b6cb6fb674efd8979a8ef Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 21:43:34 -0700 Subject: [PATCH 12/13] cleanup --- .../bitchat/android/mesh/ForegroundService.kt | 2 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 39 +++---------------- .../com/bitchat/android/ui/ChatViewModel.kt | 1 - app/src/main/res/values/strings.xml | 6 +-- 4 files changed, 9 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt index ff83705cc..62db01618 100644 --- a/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/ForegroundService.kt @@ -50,7 +50,7 @@ class ForegroundService : Service(), BluetoothMeshDelegate { 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 = true + 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" 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 d65c530a4..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,41 +1,14 @@ package com.bitchat.android.ui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.EaseInCubic -import androidx.compose.animation.core.EaseOutCubic -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextRange 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 1996383e1..678512599 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -357,7 +357,6 @@ class ChatViewModel( // Note: Mesh service restart is now handled by MainActivity // This function is no longer needed - // FIXME: Move DM notifications to foreground service fun setAppBackgroundState(inBackground: Boolean) { // Forward to notification manager for notification logic notificationManager.setAppBackgroundState(inBackground) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5328e629a..7b9655b93 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,7 +38,6 @@ Retry Skip - %d peer nearby @@ -46,11 +45,10 @@ - %d unread message - %d unread messages + %d unread + %d unread - Bitchat is active Scanning for nearby peers… Shutdown From 6900a090754375ca156492869acfd87f41648036 Mon Sep 17 00:00:00 2001 From: JaredBanyard Date: Tue, 5 Aug 2025 21:53:46 -0700 Subject: [PATCH 13/13] removal of unused code --- .../com/bitchat/android/ui/ChatViewModel.kt | 64 +------------------ 1 file changed, 3 insertions(+), 61 deletions(-) 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 678512599..d30a00821 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -21,7 +21,7 @@ import kotlin.random.Random */ class ChatViewModel( application: Application -) : AndroidViewModel(application), BluetoothMeshDelegate { +) : AndroidViewModel(application) { companion object { private const val TAG = "ChatViewModel" @@ -50,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 @@ -118,7 +105,6 @@ class ChatViewModel( fun initialize(meshService: BluetoothMeshService) { Log.d(TAG, "Initializing ChatViewModel") this.meshService = meshService - this.meshService.delegate = this loadAndInitialize() } @@ -391,51 +377,7 @@ class ChatViewModel( fun selectMentionSuggestion(nickname: String, currentText: String): String { return commandProcessor.selectMentionSuggestion(nickname, currentText) } - - // MARK: - BluetoothMeshDelegate Implementation (delegated) - - override fun didReceiveMessage(message: BitchatMessage) { - meshDelegateHandler.didReceiveMessage(message) - } - - override fun didConnectToPeer(peerID: String) { - meshDelegateHandler.didConnectToPeer(peerID) - } - - override fun didDisconnectFromPeer(peerID: String) { - meshDelegateHandler.didDisconnectFromPeer(peerID) - } - - override fun didUpdatePeerList(peers: List) { - meshDelegateHandler.didUpdatePeerList(peers) - } - - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { - meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) - } - - override fun didReceiveDeliveryAck(ack: DeliveryAck) { - meshDelegateHandler.didReceiveDeliveryAck(ack) - } - - override fun didReceiveReadReceipt(receipt: ReadReceipt) { - meshDelegateHandler.didReceiveReadReceipt(receipt) - } - - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { - return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) - } - - override fun getNickname(): String? { - return meshDelegateHandler.getNickname() - } - - override fun isFavorite(peerID: String): Boolean { - return meshDelegateHandler.isFavorite(peerID) - } - - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager - + // MARK: - Emergency Clear fun panicClearAllData() {