diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30ecc6cc1..7ee18f932 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.serialization) + } android { @@ -62,6 +64,12 @@ android { } dependencies { + implementation(libs.androidx.datastore.preferences) + // optional - RxJava2 support + implementation(libs.androidx.datastore.preferences.rxjava2) + // optional - RxJava3 support + implementation(libs.androidx.datastore.preferences.rxjava3) + implementation(libs.kotlinx.serialization.json) // Core Android dependencies implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 134dcdfa0..71b2a876e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,7 +40,7 @@ create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T - } - } - } - + private val mainViewModel: MainViewModel by viewModels { AppVMProvider.Factory } + private val chatViewModel: ChatViewModel by viewModels { AppVMProvider.Factory } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Enable edge-to-edge display for modern Android look + + val themePreferenceRepo: ThemePreferenceRepo = + ThemePrefRepoImpl(this.applicationContext, lifecycleScope) enableEdgeToEdge() // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -98,21 +91,26 @@ class MainActivity : ComponentActivity() { onOnboardingComplete = ::handleOnboardingComplete, onOnboardingFailed = ::handleOnboardingFailed ) - setContent { - BitchatTheme { + val themePref by themePreferenceRepo.theme.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + + BitchatTheme( + themePref = themePref + ) { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background ) { innerPadding -> - OnboardingFlowScreen(modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) + OnboardingFlowScreen( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), themePref + ) { coroutineScope.launch { themePreferenceRepo.updateTheme(it) } } } } } - + // Collect state changes in a lifecycle-aware manner lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -121,16 +119,19 @@ 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(modifier: Modifier = Modifier) { + private fun OnboardingFlowScreen( + modifier: Modifier = Modifier, themePref: ThemePreference, + onChangeTheme: (ThemePreference) -> Unit + ) { val context = LocalContext.current val onboardingState by mainViewModel.onboardingState.collectAsState() val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState() @@ -158,7 +159,7 @@ class MainActivity : ComponentActivity() { context.unregisterReceiver(receiver) Log.d("BluetoothStatusUI", "BroadcastReceiver unregistered") } catch (e: IllegalStateException) { - Log.w("BluetoothStatusUI", "Receiver was not registered") + Log.w("BluetoothStatusUI", "Receiver was not registered ${e.printStackTrace()}") } } } @@ -167,7 +168,7 @@ class MainActivity : ComponentActivity() { OnboardingState.PERMISSION_REQUESTING -> { InitializingScreen(modifier) } - + OnboardingState.BLUETOOTH_CHECK -> { BluetoothCheckScreen( modifier = modifier, @@ -182,7 +183,7 @@ class MainActivity : ComponentActivity() { isLoading = isBluetoothLoading ) } - + OnboardingState.LOCATION_CHECK -> { LocationCheckScreen( modifier = modifier, @@ -197,7 +198,7 @@ class MainActivity : ComponentActivity() { isLoading = isLocationLoading ) } - + OnboardingState.BATTERY_OPTIMIZATION_CHECK -> { BatteryOptimizationScreen( modifier = modifier, @@ -216,7 +217,7 @@ class MainActivity : ComponentActivity() { isLoading = isBatteryOptimizationLoading ) } - + OnboardingState.PERMISSION_EXPLANATION -> { PermissionExplanationScreen( modifier = modifier, @@ -246,9 +247,11 @@ class MainActivity : ComponentActivity() { // Add the callback - this will be automatically removed when the activity is destroyed onBackPressedDispatcher.addCallback(this, backCallback) - ChatScreen(viewModel = chatViewModel) + ChatScreen( + viewModel = chatViewModel, themePref = themePref, onChangeTheme = onChangeTheme + ) } - + OnboardingState.ERROR -> { InitializationErrorScreen( modifier = modifier, @@ -264,80 +267,87 @@ 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("MainActivity", "Onboarding completed - app ready") } + OnboardingState.ERROR -> { - android.util.Log.e("MainActivity", "Onboarding error state reached") + Log.e("MainActivity", "Onboarding error state reached") } + 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()) { - Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions") + Log.d( + "MainActivity", + "First-time launch, skipping Bluetooth check - will check after permissions" + ) proceedWithPermissionCheck() return } - + // For existing users, check Bluetooth status first bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) - + when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next checkLocationAndProceed() } + BluetoothStatus.DISABLED -> { // Show Bluetooth enable screen (should have permissions as existing user) Log.d("MainActivity", "Bluetooth disabled, showing enable screen") mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateBluetoothLoading(false) } + BluetoothStatus.NOT_SUPPORTED -> { // Device doesn't support Bluetooth - android.util.Log.e("MainActivity", "Bluetooth not supported") + 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("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) @@ -351,7 +361,7 @@ class MainActivity : ComponentActivity() { } } } - + /** * Handle Bluetooth enabled callback */ @@ -367,30 +377,35 @@ 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()) { - Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions") + Log.d( + "MainActivity", + "First-time launch, skipping location check - will check after permissions" + ) proceedWithPermissionCheck() return } - + // For existing users, check location status locationStatusManager.logLocationStatus() mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) - + when (mainViewModel.locationStatus.value) { LocationStatus.ENABLED -> { // Location services enabled, check battery optimization next checkBatteryOptimizationAndProceed() } + LocationStatus.DISABLED -> { // Show location enable screen (should have permissions as existing user) 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("MainActivity", "Location services not available") @@ -424,13 +439,14 @@ class MainActivity : ComponentActivity() { mainViewModel.updateErrorMessage(message) mainViewModel.updateOnboardingState(OnboardingState.ERROR) } + else -> { // Stay on location check screen for retry mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) } } } - + /** * Handle Bluetooth disabled callback */ @@ -438,34 +454,46 @@ 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 mainViewModel.updateErrorMessage(message) mainViewModel.updateOnboardingState(OnboardingState.ERROR) } + message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> { // During first-time onboarding, if Bluetooth enable fails due to permissions, // proceed to permission explanation screen where user will grant permissions first - Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation") + Log.d( + "MainActivity", + "Bluetooth enable requires permissions, proceeding to permission explanation" + ) proceedWithPermissionCheck() } + message.contains("Permission") -> { // For existing users, redirect to permission explanation to grant missing permissions - Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation") + Log.d( + "MainActivity", + "Bluetooth enable requires permissions, showing permission explanation" + ) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } + else -> { // Stay on Bluetooth check screen for retry mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) } } } - + private fun handleOnboardingComplete() { - Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app") - + Log.d( + "MainActivity", + "Onboarding completed, checking Bluetooth and Location before initializing app" + ) + // After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentLocationStatus = locationStatusManager.checkLocationStatus() @@ -474,65 +502,86 @@ 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( + "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("MainActivity", "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 - android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.") + Log.d( + "MainActivity", + "Permissions granted, but battery optimization still enabled. Showing battery optimization screen." + ) mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateBatteryOptimizationLoading(false) } + else -> { // Both are enabled, proceed to app initialization - Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization") + 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("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") - + Log.d("MainActivity", "Checking battery optimization status") + // For first-time users, skip battery optimization check and go straight to permissions // We'll check battery optimization after permissions are granted if (permissionManager.isFirstTimeLaunch()) { - android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions") + Log.d( + "MainActivity", + "First-time launch, skipping battery optimization check - will check after permissions" + ) proceedWithPermissionCheck() return } - + // Check if user has previously skipped battery optimization if (BatteryOptimizationPreferenceManager.isSkipped(this)) { - android.util.Log.d("MainActivity", "User previously skipped battery optimization, proceeding to permissions") + Log.d( + "MainActivity", + "User previously skipped battery optimization, proceeding to permissions" + ) proceedWithPermissionCheck() return } - + // For existing users, check battery optimization status batteryOptimizationManager.logBatteryOptimizationStatus() val currentBatteryOptimizationStatus = when { @@ -541,36 +590,40 @@ 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 proceedWithPermissionCheck() } + 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 */ private fun handleBatteryOptimizationFailed(message: String) { - android.util.Log.w("MainActivity", "Battery optimization disable failed: $message") + Log.w("MainActivity", "Battery optimization disable failed: $message") mainViewModel.updateBatteryOptimizationLoading(false) val currentStatus = when { !batteryOptimizationManager.isBatteryOptimizationSupported() -> BatteryOptimizationStatus.NOT_SUPPORTED @@ -578,26 +631,26 @@ 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") - + // Initialize PoW preferences early in the initialization process PoWPreferenceManager.init(this@MainActivity) Log.d("MainActivity", "PoW preferences initialized") - + // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { val missing = permissionManager.getMissingPermissions() @@ -607,14 +660,15 @@ class MainActivity : ComponentActivity() { } // Set up mesh service delegate and start services - meshService.delegate = chatViewModel - meshService.startServices() - + + chatViewModel.startMeshServices() + chatViewModel.setUpDelegate() + 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") @@ -625,7 +679,7 @@ class MainActivity : ComponentActivity() { } } } - + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running @@ -633,13 +687,14 @@ 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.changeMeshServiceBGState(false) + chatViewModel.setAppBackgroundState(false) // Check if Bluetooth was disabled while app was backgrounded @@ -651,7 +706,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,53 +717,59 @@ class MainActivity : ComponentActivity() { } } } - - override fun onPause() { + + 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.changeMeshServiceBGState(true) chatViewModel.setAppBackgroundState(true) } } - + /** * Handle intents from notification clicks - open specific private chat or geohash 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 ) - + val shouldOpenGeohashChat = intent.getBooleanExtra( com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_GEOHASH_CHAT, false ) - + when { 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(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( + "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) } } - + shouldOpenGeohashChat -> { - val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH) - + val geohash = + intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH) + if (geohash != null) { Log.d("MainActivity", "Opening geohash chat #$geohash from notification") - + // Switch to the geohash channel - create appropriate geohash channel level val level = when (geohash.length) { 7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK @@ -721,10 +782,10 @@ class MainActivity : ComponentActivity() { val geohashChannel = com.bitchat.android.geohash.GeohashChannel(level, geohash) val channelId = com.bitchat.android.geohash.ChannelID.Location(geohashChannel) chatViewModel.selectLocationChannel(channelId) - + // Update current geohash state for notifications chatViewModel.setCurrentGeohash(geohash) - + // Clear notifications for this geohash since user is now viewing it chatViewModel.clearNotificationsForGeohash(geohash) } @@ -732,10 +793,10 @@ class MainActivity : ComponentActivity() { } } - + override fun onDestroy() { super.onDestroy() - + // Cleanup location status manager try { locationStatusManager.cleanup() @@ -743,11 +804,11 @@ class MainActivity : ComponentActivity() { } catch (e: Exception) { Log.w("MainActivity", "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() + chatViewModel.stopMeshServices() Log.d("MainActivity", "Mesh services stopped successfully") } catch (e: Exception) { Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 35125d855..3d45983e6 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -67,4 +67,6 @@ class MainViewModel : ViewModel() { fun updateBatteryOptimizationLoading(loading: Boolean) { _isBatteryOptimizationLoading.value = loading } + + } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/application/AppContainer.kt b/app/src/main/java/com/bitchat/android/application/AppContainer.kt new file mode 100644 index 000000000..9ebccf52f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/application/AppContainer.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.application + +import android.app.Application +import android.content.Context +import com.bitchat.android.mesh.BluetoothMeshDelegate +import com.bitchat.android.mesh.BluetoothMeshDelegateImpl +import com.bitchat.android.mesh.BluetoothMeshService +import kotlinx.coroutines.CoroutineScope + +interface AppContainer { + val meshService : BluetoothMeshService + val bmd : BluetoothMeshDelegate +} + +class AppDataContainer( + private val scope: CoroutineScope, private val context: Context, + override val meshService: BluetoothMeshService, private val app: Application +) : AppContainer { + override val bmd by lazy { + BluetoothMeshDelegateImpl(scope, context, meshService, app) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/application/AppVMProvider.kt b/app/src/main/java/com/bitchat/android/application/AppVMProvider.kt new file mode 100644 index 000000000..63587d832 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/application/AppVMProvider.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.application + +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.bitchat.android.MainViewModel +import com.bitchat.android.ui.ChatViewModel + +object AppVMProvider { + val Factory = viewModelFactory { + initializer { + MainViewModel() + } + initializer { + ChatViewModel( + bitchatApplication(), + bitchatApplication().container.meshService, + bitchatApplication().container.bmd + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/application/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/application/BitchatApplication.kt new file mode 100644 index 000000000..7896b7dd4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/application/BitchatApplication.kt @@ -0,0 +1,63 @@ +package com.bitchat.android.application + +import android.app.Application +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.nostr.RelayDirectory +import com.bitchat.android.net.TorManager +import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.ui.debug.DebugPreferenceManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * Main application class for bitchat Android + */ +class BitchatApplication : Application() { + + lateinit var container: AppContainer + private val applicationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + override fun onCreate() { + super.onCreate() + + // Initialize Tor first so any early network goes over Tor + try { + TorManager.init(this) + } catch (_: Exception) { + } + + // Initialize relay directory (loads assets/nostr_relays.csv) + RelayDirectory.initialize(this) + + // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup + try { + FavoritesPersistenceService.initialize(this) + } catch (_: Exception) { + } + + // Warm up Nostr identity to ensure npub is available for favorite notifications + try { + NostrIdentityBridge.getCurrentNostrIdentity(this) + } catch (_: Exception) { + } + + // Initialize debug preference manager (persists debug toggles) + try { + DebugPreferenceManager.init(this) + } catch (_: Exception) { + } + + // TorManager already initialized above + + container = AppDataContainer( + applicationScope, this.applicationContext, + BluetoothMeshService(this), this + ) + } +} + +fun CreationExtras.bitchatApplication(): BitchatApplication = + (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as BitchatApplication) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshDelegate.kt new file mode 100644 index 000000000..9a51e590f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshDelegate.kt @@ -0,0 +1,76 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChannelManager +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.CommandSuggestion +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NotificationManager +import com.bitchat.android.ui.PrivateChatManager + + +/** + * Delegate interface for mesh service callbacks (maintains exact same interface) + */ +interface BluetoothMeshDelegate { + val dataManager: DataManager + val privateChatManager: PrivateChatManager + val meshDelegateHandler: MeshDelegateHandler + val messageManager: MessageManager + val channelManager: ChannelManager + val notificationManager: NotificationManager + val state: ChatState + fun setNickname(newNickname: String) + fun didReceiveMessage(message: BitchatMessage) + fun didUpdatePeerList(peers: List) + fun didReceiveChannelLeave(channel: String, fromPeer: String) + fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) + fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? + fun getNickname(): String? + fun isFavorite(peerID: String): Boolean + fun cancelMediaSend(messageId: String) + fun joinChannel(channel: String, password: String? = null): Boolean + + fun switchToChannel(channel: String?) + + fun leaveChannel(channel: String) + + fun setAppBackgroundState(inBackground: Boolean) + + fun setCurrentPrivateChatPeer(peerID: String?) + + fun setCurrentGeohash(geohash: String?) + + fun clearNotificationsForSender(peerID: String) + + fun clearNotificationsForGeohash(geohash: String) + fun changeMeshServiceBGState(b: Boolean) + fun startMeshServices() + fun stopMeshServices() + + fun clearMeshMentionNotifications() + fun updateCommandSuggestions(input: String) + + fun selectCommandSuggestion(suggestion: CommandSuggestion): String + + fun updateMentionSuggestions(input: String) + fun selectMentionSuggestion(nickname: String, currentText: String): String + fun panicClearAllData(onPanicReset: () -> Unit) + fun openLatestUnreadPrivateChat(onEnsureGeohashDMSubscription: (String) -> Unit) + fun startPrivateChat(peerID: String, onEnsureGeohashDMSubscription: () -> Unit) + fun endPrivateChat() + fun loadAndInitialize( + logCurrentFavoriteState: () -> Unit, initializeSessionStateMonitoring: () -> Unit, + initializeGeoHashVM: () -> Unit + ) + fun updateReactiveStates() + + fun toggleFavorite(peerID: String, logCurrentFavoriteState: () -> Unit) + fun sendMessage(content: String, onSendGeohashMessage: (String, GeohashChannel) -> Unit) + +// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshDelegateImpl.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshDelegateImpl.kt new file mode 100644 index 000000000..4cbf985d6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshDelegateImpl.kt @@ -0,0 +1,729 @@ +package com.bitchat.android.mesh + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.LiveData +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.logWarn +import com.bitchat.android.ui.ChannelManager +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.ChatViewModelUtils +import com.bitchat.android.ui.CommandProcessor +import com.bitchat.android.ui.CommandSuggestion +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.GeoPerson +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.NotificationManager +import com.bitchat.android.ui.PrivateChatManager +import com.bitchat.android.util.NotificationIntervalManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date +import kotlin.collections.forEach +import kotlin.random.Random + + +class BluetoothMeshDelegateImpl( + private val scope: CoroutineScope, + context: Context, + private val meshService: BluetoothMeshService, + private val application: Application +) : BluetoothMeshDelegate { + companion object { + private const val TAG = "BluetoothMeshDelegate" + + } + override val dataManager = DataManager(context) + override val state = ChatState() + override val messageManager = MessageManager(state) + private val noiseSessionDelegate = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String) = + meshService.hasEstablishedSession(peerID) + + override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) + override fun getMyPeerID(): String = meshService.myPeerID + } + override val channelManager = ChannelManager(state, messageManager, dataManager, scope) + override val privateChatManager = + PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) + override val notificationManager = NotificationManager( + context, NotificationManagerCompat.from(context), NotificationIntervalManager() + ) + private val commandProcessor = + CommandProcessor(state, messageManager, channelManager, privateChatManager) + + override val meshDelegateHandler = MeshDelegateHandler( + state = state, + messageManager = messageManager, + channelManager = channelManager, + privateChatManager = privateChatManager, + notificationManager = notificationManager, + coroutineScope = scope, + onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(context) }, + getMyPeerID = { meshService.myPeerID }, + getMeshService = { meshService } + ) + private val transferMessageMap = mutableMapOf() + private val messageTransferMap = mutableMapOf() + private val geohashPeople: LiveData> = state.geohashPeople + val selectedLocationChannel: LiveData = state.selectedLocationChannel + + override fun setNickname(newNickname: String) { + state.setNickname(newNickname) + dataManager.saveNickname(newNickname) + meshService.sendBroadcastAnnounce() + } + + override fun didReceiveMessage(message: BitchatMessage) = + meshDelegateHandler.didReceiveMessage(message) + + override fun didUpdatePeerList(peers: List) = + meshDelegateHandler.didUpdatePeerList(peers) + + override fun didReceiveChannelLeave(channel: String, fromPeer: String) = + meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) + + + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) = + meshDelegateHandler.didReceiveDeliveryAck(messageID, recipientPeerID) + + + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) = + meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) + + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String) = + meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) + + override fun getNickname() = meshDelegateHandler.getNickname() + + + override fun isFavorite(peerID: String) = meshDelegateHandler.isFavorite(peerID) + + override fun openLatestUnreadPrivateChat(onEnsureGeohashDMSubscription: (String) -> Unit) { + try { + val unreadKeys = state.getUnreadPrivateMessagesValue() + if (unreadKeys.isEmpty()) return + + val me = state.getNicknameValue() ?: meshService.myPeerID + val chats = state.getPrivateChatsValue() + + // Pick the latest incoming message among unread conversations + var bestKey: String? = null + var bestTime: Long = Long.MIN_VALUE + + unreadKeys.forEach { key -> + val list = chats[key] + if (!list.isNullOrEmpty()) { + // Prefer the latest incoming message (sender != me), fallback to last message + val latestIncoming = list.lastOrNull { it.sender != me } + val candidateTime = (latestIncoming ?: list.last()).timestamp.time + if (candidateTime > bestTime) { + bestTime = candidateTime + bestKey = key + } + } + } + + val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return + + val openPeer: String = if (targetKey.startsWith("nostr_")) { + // Use the exact conversation key for geohash DMs and ensure DM subscription + onEnsureGeohashDMSubscription(targetKey) + targetKey + } else { + // Resolve to a canonical mesh peer if needed + val canonical = + com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = targetKey, + connectedPeers = state.getConnectedPeersValue(), + meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + nostrPubHexForAlias = { alias -> + com.bitchat.android.nostr.GeohashAliasRegistry.get( + alias + ) + }, + findNoiseKeyForNostr = { key -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey( + key + ) + } + ) + canonical ?: targetKey + } + + startPrivateChat(openPeer) { onEnsureGeohashDMSubscription(openPeer) } + // If sidebar visible, hide it to focus on the private chat + if (state.getShowSidebarValue()) { + state.setShowSidebar(false) + } + } catch (e: Exception) { + Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") + } + } + + override fun startPrivateChat(peerID: String, onEnsureGeohashDMSubscription: () -> Unit) { + // For geohash conversation keys, ensure DM subscription is active + if (peerID.startsWith("nostr_")) { + onEnsureGeohashDMSubscription() + } + + val success = privateChatManager.startPrivateChat(peerID, meshService) + if (success) { + // Notify notification manager about current private chat + setCurrentPrivateChatPeer(peerID) + // Clear notifications for this sender since user is now viewing the chat + clearNotificationsForSender(peerID) + + // Persistently mark all messages in this conversation as read so Nostr fetches + // after app restarts won't re-mark them as unread. + try { + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) + val chats = state.getPrivateChatsValue() + val messages = chats[peerID] ?: emptyList() + messages.forEach { msg -> + try { + seen.markRead(msg.id) + } catch (_: Exception) { + } + } + } catch (_: Exception) { + } + } + } + + override fun endPrivateChat() { + privateChatManager.endPrivateChat() + // Notify notification manager that no private chat is active + setCurrentPrivateChatPeer(null) + // Clear mesh mention notifications since user is now back in mesh chat + clearMeshMentionNotifications() + } + + override fun cancelMediaSend(messageId: String) { + val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] } + if (transferId != null) { + val cancelled = meshService.cancelFileTransfer(transferId) + if (cancelled) { + // Remove the message from chat upon explicit cancel + messageManager.removeMessageById(messageId) + synchronized(transferMessageMap) { + transferMessageMap.remove(transferId) + messageTransferMap.remove(messageId) + } + } + } + } + + override fun updateReactiveStates() { + val currentPeers = state.getConnectedPeersValue() + + // Update session states + val prevStates = state.getPeerSessionStatesValue() + val sessionStates = currentPeers.associateWith { peerID -> + meshService.getSessionState(peerID).toString() + } + state.setPeerSessionStates(sessionStates) + // Detect new established sessions and flush router outbox for them and their noiseHex aliases + sessionStates.forEach { (peerID, newState) -> + val old = prevStates[peerID] + if (old != "established" && newState == "established") { + com.bitchat.android.services.MessageRouter + .getInstance(application, meshService) + .onSessionEstablished(peerID) + } + } + // Update fingerprint mappings from centralized manager + val fingerprints = privateChatManager.getAllPeerFingerprints() + state.setPeerFingerprints(fingerprints) + + val nicknames = meshService.getPeerNicknames() + state.setPeerNicknames(nicknames) + + val rssiValues = meshService.getPeerRSSI() + state.setPeerRSSI(rssiValues) + + // Update directness per peer (driven by PeerManager state) + try { + val directMap = state.getConnectedPeersValue().associateWith { pid -> + meshService.getPeerInfo(pid)?.isDirectConnection == true + } + state.setPeerDirect(directMap) + } catch (_: Exception) { + } + } + + override fun joinChannel(channel: String, password: String?): Boolean { + return channelManager.joinChannel(channel, password, meshService.myPeerID) + } + + override fun switchToChannel(channel: String?) = channelManager.switchToChannel(channel) + + + override fun leaveChannel(channel: String) { + channelManager.leaveChannel(channel) + meshService.sendMessage("left $channel") + } + + /** Forward to notification manager for notification logic */ + override fun setAppBackgroundState(inBackground: Boolean) = + notificationManager.setAppBackgroundState(inBackground) + + + override fun setCurrentPrivateChatPeer(peerID: String?) { + // Update notification manager with current private chat peer + notificationManager.setCurrentPrivateChatPeer(peerID) + } + + override fun setCurrentGeohash(geohash: String?) { + // Update notification manager with current geohash for notification logic + notificationManager.setCurrentGeohash(geohash) + } + + override fun clearNotificationsForSender(peerID: String) { + // Clear notifications when user opens a chat + notificationManager.clearNotificationsForSender(peerID) + } + + override fun clearNotificationsForGeohash(geohash: String) { + // Clear notifications when user opens a geohash chat + notificationManager.clearNotificationsForGeohash(geohash) + } + + /** + * Clear mesh mention notifications when user opens mesh chat + */ + override fun clearMeshMentionNotifications() { + notificationManager.clearMeshMentionNotifications() + } + + // MARK: - Command Autocomplete (delegated) + + override fun updateCommandSuggestions(input: String) { + commandProcessor.updateCommandSuggestions(input) + } + + override fun selectCommandSuggestion(suggestion: CommandSuggestion): String { + return commandProcessor.selectCommandSuggestion(suggestion) + } + + // MARK: - Mention Autocomplete + + override fun updateMentionSuggestions(input: String) { + commandProcessor.updateMentionSuggestions( + input, meshService, Pair(geohashPeople.value, selectedLocationChannel.value) + ) + } + + override fun selectMentionSuggestion(nickname: String, currentText: String) = + commandProcessor.selectMentionSuggestion(nickname, currentText) + + override fun changeMeshServiceBGState(b: Boolean) = + meshService.connectionManager.setAppBackgroundState(b) + + override fun startMeshServices() = meshService.startServices() + override fun stopMeshServices() = meshService.startServices() + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager + + // MARK: - Emergency Clear + + override fun panicClearAllData(onPanicReset: () -> Unit) { + 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() + + // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch + try { + // Clear geohash bookmarks too (panic should remove everything) + try { + val store = + com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(application) + store.clearAll() + } catch (_: Exception) { + } + + onPanicReset() + } catch (e: Exception) { + Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") + } + + // 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 + */ + private fun clearAllMeshServiceData() { + 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 + */ + private fun clearAllCryptographicData() { + 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(application) + identityManager.clearIdentityData() + // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) + try { + identityManager.clearSecureValues( + "favorite_relationships", + "favorite_peerid_index" + ) + } catch (_: Exception) { + } + Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") + } catch (e: Exception) { + Log.d( + TAG, + "SecureIdentityStateManager not available or already cleared: ${e.message}" + ) + } + + // Clear FavoritesPersistenceService persistent relationships + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") + } catch (_: Exception) { + } + + Log.d(TAG, "✅ Cleared all cryptographic data") + } catch (e: Exception) { + Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") + } + } + + override fun loadAndInitialize( + logCurrentFavoriteState: () -> Unit, initializeSessionStateMonitoring: () -> Unit, + initializeGeoHashVM: () -> Unit + ) { + // 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)) { + val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() + updatedChannelMessages[channel] = emptyList() + state.setChannelMessages(updatedChannelMessages) + } + } + + // Load other data + dataManager.loadFavorites() + state.setFavoritePeers(dataManager.favoritePeers.toSet()) + dataManager.loadBlockedUsers() + dataManager.loadGeohashBlockedUsers() + + // Log all favorites at startup + dataManager.logAllFavorites() + logCurrentFavoriteState() + + // Initialize session state monitoring + initializeSessionStateMonitoring() + + // Bridge DebugSettingsManager -> Chat messages when verbose logging is on + scope.launch { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().debugMessages.collect { msgs -> + if (com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().verboseLoggingEnabled.value) { + // Only show debug logs in the Mesh chat timeline to avoid leaking into geohash chats + val selectedLocation = state.selectedLocationChannel.value + if (selectedLocation is ChannelID.Mesh) { + // Append only latest debug message as system message to avoid flooding + msgs.lastOrNull()?.let { dm -> + messageManager.addSystemMessage(dm.content) + } + } + } + } + } + + // Initialize new geohash architecture + initializeGeoHashVM() + + // Initialize favorites persistence service + com.bitchat.android.favorites.FavoritesPersistenceService.initialize(application) + + + // Ensure NostrTransport knows our mesh peer ID for embedded packets + try { + val nostrTransport = + com.bitchat.android.nostr.NostrTransport.getInstance(application) + nostrTransport.senderPeerID = meshService.myPeerID + } catch (_: Exception) { + } + + // Note: Mesh service is now started by MainActivity + + // Show welcome message if no peers after delay + scope.launch(Dispatchers.Main) { + delay(10000) + if (state.getConnectedPeersValue().isEmpty() && state.getMessagesValue().isEmpty()) { + try { + val welcomeMessage = BitchatMessage( + sender = "system", + content = "get people around you to download bitchat and chat with them here!", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(welcomeMessage) + } catch (e: Exception) { + logWarn("${e.printStackTrace()}") + } + } + } + + // BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch. + } + + override fun toggleFavorite(peerID: String, logCurrentFavoriteState: () -> Unit) { + Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") + privateChatManager.toggleFavorite(peerID) + + // Persist relationship in FavoritesPersistenceService + try { + var noiseKey: ByteArray? = null + var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID + + // Case 1: Live mesh peer with known info + val peerInfo = meshService.getPeerInfo(peerID) + if (peerInfo?.noisePublicKey != null) { + noiseKey = peerInfo.noisePublicKey + nickname = peerInfo.nickname + } else { + // Case 2: Offline favorite entry using 64-hex noise public key as peerID + if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + try { + noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + // Prefer nickname from favorites store if available + val rel = + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus( + noiseKey + ) + if (rel != null) nickname = rel.peerNickname + } catch (_: Exception) { + } + } + } + + if (noiseKey != null) { + // Determine current favorite state from DataManager using fingerprint + val identityManager = + com.bitchat.android.identity.SecureIdentityStateManager(application) + val fingerprint = identityManager.generateFingerprint(noiseKey) + val isNowFavorite = dataManager.favoritePeers.contains(fingerprint) + + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus( + noisePublicKey = noiseKey, + nickname = nickname, + isFavorite = isNowFavorite + ) + + // Send favorite notification via mesh or Nostr with our npub if available + try { + val myNostr = + com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity( + application + ) + val announcementContent = + if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}" + // Prefer mesh if session established, else try Nostr + if (meshService.hasEstablishedSession(peerID)) { + // Reuse existing private message path for notifications + meshService.sendPrivateMessage( + announcementContent, + peerID, + nickname, + java.util.UUID.randomUUID().toString() + ) + } else { + val nostrTransport = + com.bitchat.android.nostr.NostrTransport.getInstance(application) + nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.sendFavoriteNotification(peerID, isNowFavorite) + } + } catch (_: Exception) { + } + } + } catch (_: Exception) { + } + + // Log current state after toggle + logCurrentFavoriteState() + } + + override fun sendMessage( + content: String, + onSendGeohashMessage: (String, GeohashChannel) -> Unit + ) { + if (content.isEmpty()) return + + // Check for commands + if (content.startsWith("/")) { + val selectedLocationForCommand = state.selectedLocationChannel.value + commandProcessor.processCommand( + content, meshService, meshService.myPeerID, + onSendMessage = { messageContent, mentions, channel -> + if (selectedLocationForCommand is ChannelID.Location) { + // Route command-generated public messages via Nostr in geohash channels + onSendGeohashMessage(messageContent, selectedLocationForCommand.channel) + + } else { + // Default: route via mesh + meshService.sendMessage(messageContent, mentions, channel) + } + }) + return + } + + val mentions = messageManager.parseMentions( + content, + meshService.getPeerNicknames().values.toSet(), + state.getNicknameValue() + ) + // REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions + // This was causing messages like "test @jack#1234 test" to auto-join channel "#1234" + + var selectedPeer = state.getSelectedPrivateChatPeerValue() + val currentChannelValue = state.getCurrentChannelValue() + + if (selectedPeer != null) { + // If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target + selectedPeer = + com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = selectedPeer, + connectedPeers = state.getConnectedPeersValue(), + meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + nostrPubHexForAlias = { alias -> + com.bitchat.android.nostr.GeohashAliasRegistry.get( + alias + ) + }, + findNoiseKeyForNostr = { key -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey( + key + ) + } + ).also { canonical -> + if (canonical != state.getSelectedPrivateChatPeerValue()) { + privateChatManager.startPrivateChat(canonical, meshService) + } + } + // Send private message + val recipientNickname = meshService.getPeerNicknames()[selectedPeer] + privateChatManager.sendPrivateMessage( + content, + selectedPeer, + recipientNickname, + state.getNicknameValue(), + meshService.myPeerID + ) { messageContent, peerID, recipientNicknameParam, messageId -> + // Route via MessageRouter (mesh when connected+established, else Nostr) + val router = com.bitchat.android.services.MessageRouter.getInstance( + application, + meshService + ) + router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) + } + } else { + // Check if we're in a location channel + val selectedLocationChannel = state.selectedLocationChannel.value + if (selectedLocationChannel is ChannelID.Location) { + // Send to geohash channel via Nostr ephemeral event + onSendGeohashMessage(content, selectedLocationChannel.channel) + } else { + // Send public/channel message via mesh + val message = BitchatMessage( + sender = state.getNicknameValue() ?: meshService.myPeerID, + content = content, + timestamp = Date(), + isRelay = false, + senderPeerID = meshService.myPeerID, + mentions = mentions.ifEmpty { 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, + state.getNicknameValue(), + meshService.myPeerID, + onEncryptedPayload = { encryptedData -> + // This would need proper mesh service integration + meshService.sendMessage(content, mentions, currentChannelValue) + }, + onFallback = { + meshService.sendMessage(content, mentions, currentChannelValue) + } + ) + } else { + meshService.sendMessage(content, mentions, currentChannelValue) + } + } else { + messageManager.addMessage(message) + meshService.sendMessage(content, mentions, null) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 98d7a6227..1741a0151 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -1,10 +1,13 @@ package com.bitchat.android.mesh +import android.app.Application import android.content.Context import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.LiveData import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.geohash.ChannelID import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.protocol.BitchatPacket @@ -12,6 +15,19 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.ui.ChannelManager +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.ChatViewModelUtils +import com.bitchat.android.ui.CommandProcessor +import com.bitchat.android.ui.CommandSuggestion +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.GeoPerson +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.NotificationManager +import com.bitchat.android.ui.PrivateChatManager +import com.bitchat.android.util.NotificationIntervalManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* @@ -21,10 +37,10 @@ import kotlin.random.Random /** * Bluetooth mesh service - REFACTORED to use component-based architecture * 100% compatible with iOS version and maintains exact same UUIDs, packet format, and protocol logic - * + * * This is now a coordinator that orchestrates the following components: * - PeerManager: Peer lifecycle management - * - FragmentManager: Message fragmentation and reassembly + * - FragmentManager: Message fragmentation and reassembly * - SecurityManager: Security, duplicate detection, encryption * - StoreForwardManager: Offline message caching * - MessageHandler: Message type processing and relay logic @@ -32,13 +48,19 @@ import kotlin.random.Random * - PacketProcessor: Incoming packet routing */ class BluetoothMeshService(private val context: Context) { - private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } - + private val debugManager by lazy { + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + } catch (e: Exception) { + null + } + } + companion object { private const val TAG = "BluetoothMeshService" private const val MAX_TTL: UByte = 7u } - + // Core components - each handling specific responsibilities private val encryptionService = EncryptionService(context) @@ -49,19 +71,20 @@ class BluetoothMeshService(private val context: Context) { private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() private val messageHandler = MessageHandler(myPeerID, context.applicationContext) - internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access + internal val connectionManager = + BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) private lateinit var gossipSyncManager: GossipSyncManager - + // Service state management private var isActive = false - + // Delegate for message callbacks (maintains same interface) var delegate: BluetoothMeshDelegate? = null - + // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + init { setupDelegates() messageHandler.packetProcessor = packetProcessor @@ -74,15 +97,21 @@ class BluetoothMeshService(private val context: Context) { configProvider = object : GossipSyncManager.ConfigProvider { override fun seenCapacity(): Int = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) - } catch (_: Exception) { 500 } + } catch (_: Exception) { + 500 + } override fun gcsMaxBytes(): Int = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) - } catch (_: Exception) { 400 } + } catch (_: Exception) { + 400 + } override fun gcsTargetFpr(): Double = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsFprPercent(1.0) / 100.0 - } catch (_: Exception) { 0.01 } + } catch (_: Exception) { + 0.01 + } } ) @@ -91,15 +120,17 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { connectionManager.broadcastPacket(RoutedPacket(packet)) } + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { connectionManager.sendPacketToPeer(peerID, packet) } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { return signPacketBeforeBroadcast(packet) } } } - + /** * Start periodic debug logging every 10 seconds */ @@ -110,7 +141,10 @@ class BluetoothMeshService(private val context: Context) { delay(10000) // 10 seconds if (isActive) { // Double-check before logging val debugInfo = getDebugStatus() - Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===") + Log.d( + TAG, + "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===" + ) } } catch (e: Exception) { Log.e(TAG, "Error in periodic debug logging: ${e.message}") @@ -134,7 +168,7 @@ class BluetoothMeshService(private val context: Context) { } } } - + /** * Setup delegate connections between components */ @@ -142,14 +176,19 @@ class BluetoothMeshService(private val context: Context) { // Provide nickname resolver to BLE broadcaster for detailed logs try { connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) } - } catch (_: Exception) { } + } catch (_: Exception) { + } // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { delegate?.didUpdatePeerList(peerIDs) } + override fun onPeerRemoved(peerID: String) { - try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + try { + gossipSyncManager.removeAnnouncementForPeer(peerID) + } catch (_: Exception) { + } // Also drop any Noise session state for this peer when they go offline try { encryptionService.removePeer(peerID) @@ -159,7 +198,7 @@ class BluetoothMeshService(private val context: Context) { } } } - + // SecurityManager delegate for key exchange notifications securityManager.delegate = object : SecurityManagerDelegate { override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { @@ -167,12 +206,12 @@ class BluetoothMeshService(private val context: Context) { serviceScope.launch { delay(100) sendAnnouncementToPeer(peerID) - + delay(1000) storeForwardManager.sendCachedMessages(peerID) } } - + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { // Send Noise handshake response val responsePacket = BitchatPacket( @@ -189,99 +228,118 @@ class BluetoothMeshService(private val context: Context) { connectionManager.broadcastPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") } - + override fun getPeerInfo(peerID: String): PeerInfo? { return peerManager.getPeerInfo(peerID) } } - + // StoreForwardManager delegates storeForwardManager.delegate = object : StoreForwardManagerDelegate { override fun isFavorite(peerID: String): Boolean { return delegate?.isFavorite(peerID) ?: false } - + override fun isPeerOnline(peerID: String): Boolean { return peerManager.isPeerActive(peerID) } - + override fun sendPacket(packet: BitchatPacket) { connectionManager.broadcastPacket(RoutedPacket(packet)) } } - + // MessageHandler delegates messageHandler.delegate = object : MessageHandlerDelegate { // Peer management override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { return peerManager.addOrUpdatePeer(peerID, nickname) } - + override fun removePeer(peerID: String) { peerManager.removePeer(peerID) } - + override fun updatePeerNickname(peerID: String, nickname: String) { peerManager.addOrUpdatePeer(peerID, nickname) } - + override fun getPeerNickname(peerID: String): String? { return peerManager.getPeerNickname(peerID) } - + override fun getNetworkSize(): Int { return peerManager.getActivePeerCount() } - + override fun getMyNickname(): String? { return delegate?.getNickname() } - + override fun getPeerInfo(peerID: String): PeerInfo? { return peerManager.getPeerInfo(peerID) } - - override fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean { - return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean { + return peerManager.updatePeerInfo( + peerID, + nickname, + noisePublicKey, + signingPublicKey, + isVerified + ) } - + // Packet operations override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) } - + override fun relayPacket(routed: RoutedPacket) { connectionManager.broadcastPacket(routed) } - + override fun getBroadcastRecipient(): ByteArray { return SpecialRecipients.BROADCAST } - + // Cryptographic operations override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { return securityManager.verifySignature(packet, peerID) } - + override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? { return securityManager.encryptForPeer(data, recipientPeerID) } - - override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + + override fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): ByteArray? { return securityManager.decryptFromPeer(encryptedData, senderPeerID) } - - override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + + override fun verifyEd25519Signature( + signature: ByteArray, + data: ByteArray, + publicKey: ByteArray + ): Boolean { return encryptionService.verifyEd25519Signature(signature, data, publicKey) } - + // Noise protocol operations override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } - + override fun initiateNoiseHandshake(peerID: String) { try { // Initiate proper Noise handshake with specific peer @@ -301,17 +359,23 @@ class BluetoothMeshService(private val context: Context) { // Sign the handshake packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) - Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") + Log.d( + TAG, + "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)" + ) } else { Log.w(TAG, "Failed to generate Noise handshake data for $peerID") } - + } catch (e: Exception) { Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}") } } - - override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? { + + override fun processNoiseHandshakeMessage( + payload: ByteArray, + peerID: String + ): ByteArray? { return try { encryptionService.processHandshakeMessage(payload, peerID) } catch (e: Exception) { @@ -319,86 +383,107 @@ class BluetoothMeshService(private val context: Context) { null } } - - override fun updatePeerIDBinding(newPeerID: String, nickname: String, - publicKey: ByteArray, previousPeerID: String?) { - Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...") + override fun updatePeerIDBinding( + newPeerID: String, nickname: String, + publicKey: ByteArray, previousPeerID: String? + ) { + + Log.d( + TAG, + "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${ + publicKey.toHexString().take(16) + }..." + ) // Update peer mapping in the PeerManager for peer ID rotation support peerManager.addOrUpdatePeer(newPeerID, nickname) - + // Store fingerprint for the peer via centralized fingerprint manager val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) // Index existing Nostr mapping by the new peerID if we have it try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey( + publicKey + )?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID( + newPeerID, + npub + ) } - } catch (_: Exception) { } - + } catch (_: Exception) { + } + // If there was a previous peer ID, remove it to avoid duplicates previousPeerID?.let { oldPeerID -> peerManager.removePeer(oldPeerID) } - - Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...") + + Log.d( + TAG, + "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${ + fingerprint.take(16) + }..." + ) } - + // Message operations - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + override fun decryptChannelMessage( + encryptedContent: ByteArray, + channel: String + ): String? { return delegate?.decryptChannelMessage(encryptedContent, channel) } - + // Callbacks override fun onMessageReceived(message: BitchatMessage) { delegate?.didReceiveMessage(message) } - + override fun onChannelLeave(channel: String, fromPeer: String) { delegate?.didReceiveChannelLeave(channel, fromPeer) } - + override fun onDeliveryAckReceived(messageID: String, peerID: String) { delegate?.didReceiveDeliveryAck(messageID, peerID) } - + override fun onReadReceiptReceived(messageID: String, peerID: String) { delegate?.didReceiveReadReceipt(messageID, peerID) } } - + // PacketProcessor delegates packetProcessor.delegate = object : PacketProcessorDelegate { override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean { return securityManager.validatePacket(packet, peerID) } - + override fun updatePeerLastSeen(peerID: String) { peerManager.updatePeerLastSeen(peerID) } - + override fun getPeerNickname(peerID: String): String? { return peerManager.getPeerNickname(peerID) } - + // Network information for relay manager override fun getNetworkSize(): Int { return peerManager.getActivePeerCount() } - + override fun getBroadcastRecipient(): ByteArray { return SpecialRecipients.BROADCAST } - + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { return runBlocking { securityManager.handleNoiseHandshake(routed) } } - + override fun handleNoiseEncrypted(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } } - + override fun handleAnnounce(routed: RoutedPacket) { serviceScope.launch { // Process the announce @@ -422,54 +507,68 @@ class BluetoothMeshService(private val context: Context) { // Also push reactive directness state to UI (best-effort) try { // Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer - } catch (_: Exception) { } + } catch (_: Exception) { + } } - } catch (_: Exception) { } + } catch (_: Exception) { + } // Schedule initial sync for this new directly connected peer only - try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + try { + gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) + } catch (_: Exception) { + } } } // Track for sync - try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + try { + gossipSyncManager.onPublicPacketSeen(routed.packet) + } catch (_: Exception) { + } } } - + override fun handleMessage(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleMessage(routed) } // Track broadcast messages for sync try { val pkt = routed.packet - val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + val isBroadcast = + (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { gossipSyncManager.onPublicPacketSeen(pkt) } - } catch (_: Exception) { } + } catch (_: Exception) { + } } - + override fun handleLeave(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleLeave(routed) } } - + override fun handleFragment(packet: BitchatPacket): BitchatPacket? { // Track broadcast fragments for gossip sync try { - val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + val isBroadcast = + (packet.recipientID == null || packet.recipientID.contentEquals( + SpecialRecipients.BROADCAST + )) if (isBroadcast && packet.type == MessageType.FRAGMENT.value) { gossipSyncManager.onPublicPacketSeen(packet) } - } catch (_: Exception) { } + } catch (_: Exception) { + } return fragmentManager.handleFragment(packet) } - + override fun sendAnnouncementToPeer(peerID: String) { this@BluetoothMeshService.sendAnnouncementToPeer(peerID) } - + override fun sendCachedMessages(peerID: String) { storeForwardManager.sendCachedMessages(peerID) } - + override fun relayPacket(routed: RoutedPacket) { connectionManager.broadcastPacket(routed) } @@ -481,13 +580,17 @@ class BluetoothMeshService(private val context: Context) { gossipSyncManager.handleRequestSync(fromPeer, req) } } - + // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: android.bluetooth.BluetoothDevice? + ) { packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) } - + override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { // Send initial announcements after services are ready serviceScope.launch { @@ -500,8 +603,14 @@ class BluetoothMeshService(private val context: Context) { val peer = connectionManager.addressPeerMap[addr] val nick = peer?.let { peerManager.getPeerNickname(it) } ?: "unknown" com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() - .logPeerConnection(peer ?: "unknown", nick, addr, isInbound = !connectionManager.isClientConnection(addr)!!) - } catch (_: Exception) { } + .logPeerConnection( + peer ?: "unknown", + nick, + addr, + isInbound = !connectionManager.isClientConnection(addr)!! + ) + } catch (_: Exception) { + } } override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { @@ -514,17 +623,21 @@ class BluetoothMeshService(private val context: Context) { val stillMapped = connectionManager.addressPeerMap.values.any { it == peer } if (!stillMapped) { // Peer might still be reachable indirectly; mark as not-direct - try { peerManager.setDirectConnection(peer, false) } catch (_: Exception) { } + try { + peerManager.setDirectConnection(peer, false) + } catch (_: Exception) { + } } // Verbose debug: device disconnected try { val nick = peerManager.getPeerNickname(peer) ?: "unknown" com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() .logPeerDisconnection(peer, nick, addr) - } catch (_: Exception) { } + } catch (_: Exception) { + } } } - + override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { // Find the peer ID for this device address and update RSSI in PeerManager connectionManager.addressPeerMap[deviceAddress]?.let { peerID -> @@ -533,7 +646,7 @@ class BluetoothMeshService(private val context: Context) { } } } - + /** * Start the mesh service */ @@ -543,12 +656,12 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } - + Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") - + if (connectionManager.startServices()) { isActive = true - + // Start periodic announcements for peer discovery and connectivity sendPeriodicBroadcastAnnounce() Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") @@ -558,7 +671,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Failed to start Bluetooth services") } } - + /** * Stop all mesh services */ @@ -567,16 +680,16 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service not active, ignoring stop request") return } - + Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false - + // Send leave announcement sendLeaveAnnouncement() - + serviceScope.launch { delay(200) // Give leave message time to send - + // Stop all components gossipSyncManager.stop() connectionManager.stopServices() @@ -586,17 +699,21 @@ class BluetoothMeshService(private val context: Context) { storeForwardManager.shutdown() messageHandler.shutdown() packetProcessor.shutdown() - + serviceScope.cancel() } } - + /** * Send public message */ - fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + fun sendMessage( + content: String, + mentions: List = emptyList(), + channel: String? = null + ) { if (content.isEmpty()) return - + serviceScope.launch { val packet = BitchatPacket( version = 1u, @@ -613,7 +730,10 @@ class BluetoothMeshService(private val context: Context) { val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) // Track our own broadcast message for sync - try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + try { + gossipSyncManager.onPublicPacketSeen(signedPacket) + } catch (_: Exception) { + } } } @@ -629,24 +749,27 @@ class BluetoothMeshService(private val context: Context) { return } Log.d(TAG, "📦 Encoded payload: ${payload.size} bytes") - serviceScope.launch { - val packet = BitchatPacket( - version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files - type = MessageType.FILE_TRANSFER.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = payload, - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(packet) - // Use a stable transferId based on the file TLV payload for progress tracking - val transferId = sha256Hex(payload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - } catch (e: Exception) { + serviceScope.launch { + val packet = BitchatPacket( + version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(packet) + // Use a stable transferId based on the file TLV payload for progress tracking + val transferId = sha256Hex(payload) + connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + try { + gossipSyncManager.onPublicPacketSeen(signed) + } catch (_: Exception) { + } + } + } catch (e: Exception) { Log.e(TAG, "❌ sendFileBroadcast failed: ${e.message}", e) Log.e(TAG, "❌ File: name=${file.fileName}, size=${file.fileSize}") } @@ -655,10 +778,16 @@ class BluetoothMeshService(private val context: Context) { /** * Send a file as an encrypted private message using Noise protocol */ - fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { + fun sendFilePrivate( + recipientPeerID: String, + file: com.bitchat.android.model.BitchatFilePacket + ) { try { - Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}") - + Log.d( + TAG, + "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}" + ) + serviceScope.launch { // Check if we have an established Noise session if (encryptionService.hasEstablishedSession(recipientPeerID)) { @@ -670,21 +799,22 @@ class BluetoothMeshService(private val context: Context) { return@launch } Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes") - + // Create NoisePayload wrapper (type byte + file TLV data) - same as iOS val noisePayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER, data = filePayload ) - + // Encrypt the payload using Noise - val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID) + val encrypted = + encryptionService.encrypt(noisePayload.encode(), recipientPeerID) if (encrypted == null) { Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID") return@launch } Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes") - + // Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!) val packet = BitchatPacket( version = 1u, @@ -696,20 +826,28 @@ class BluetoothMeshService(private val context: Context) { signature = null, ttl = 7u ) - + // Sign and send the encrypted packet val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the unencrypted file TLV payload for progress tracking val transferId = sha256Hex(filePayload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + connectionManager.broadcastPacket( + RoutedPacket( + signed, + transferId = transferId + ) + ) Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") - + } catch (e: Exception) { Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e) } } else { // No session - initiate handshake but don't queue file - Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake") + Log.w( + TAG, + "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake" + ) messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) } } @@ -728,21 +866,28 @@ class BluetoothMeshService(private val context: Context) { val md = java.security.MessageDigest.getInstance("SHA-256") md.update(bytes) md.digest().joinToString("") { "%02x".format(it) } - } catch (_: Exception) { bytes.size.toString(16) } - + } catch (_: Exception) { + bytes.size.toString(16) + } + /** - * Send private message - SIMPLIFIED iOS-compatible version + * Send private message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService */ - fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + fun sendPrivateMessage( + content: String, + recipientPeerID: String, + recipientNickname: String, + messageID: String? = null + ) { if (content.isEmpty() || recipientPeerID.isEmpty()) return if (recipientNickname.isEmpty()) return - + serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() - + Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...") - + // Check if we have an established Noise session if (encryptionService.hasEstablishedSession(recipientPeerID)) { try { @@ -751,22 +896,23 @@ class BluetoothMeshService(private val context: Context) { messageID = finalMessageID, content = content ) - + val tlvData = privateMessage.encode() if (tlvData == null) { Log.e(TAG, "Failed to encode private message with TLV") return@launch } - + // Create message payload with NoisePayloadType prefix: [type byte] + [TLV data] val messagePayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, data = tlvData ) - + // Encrypt the payload - val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID) - + val encrypted = + encryptionService.encrypt(messagePayload.encode(), recipientPeerID) + // Create NOISE_ENCRYPTED packet exactly like iOS val packet = BitchatPacket( version = 1u, @@ -778,30 +924,36 @@ class BluetoothMeshService(private val context: Context) { signature = null, ttl = MAX_TTL ) - + // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) - Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)") - + Log.d( + TAG, + "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)" + ) + // FIXED: Don't send didReceiveMessage for our own sent messages // This was causing self-notifications - iOS doesn't do this // The UI handles showing sent messages through its own message sending logic - + } catch (e: Exception) { - Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}") + Log.e( + TAG, + "Failed to encrypt private message for $recipientPeerID: ${e.message}" + ) } } else { // Fire and forget - initiate handshake but don't queue exactly like iOS Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake") messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) - + // FIXED: Don't send didReceiveMessage for our own sent messages // The UI will handle showing the message in the chat interface } } } - + /** * Send read receipt for a received private message - NEW NoisePayloadType implementation * Uses same encryption approach as iOS SimplifiedBluetoothService @@ -809,28 +961,35 @@ class BluetoothMeshService(private val context: Context) { fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { serviceScope.launch { Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") - + // Route geohash read receipts via MessageRouter instead of here - val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() + val geo = + runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() val isGeoAlias = try { val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() map.containsKey(recipientPeerID) - } catch (_: Exception) { false } + } catch (_: Exception) { + false + } if (isGeoAlias && geo != null) { - geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) + geo.sendReadReceipt( + com.bitchat.android.model.ReadReceipt(messageID), + recipientPeerID + ) return@launch } - + try { // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, data = messageID.toByteArray(Charsets.UTF_8) ) - + // Encrypt the payload - val encrypted = encryptionService.encrypt(readReceiptPayload.encode(), recipientPeerID) - + val encrypted = + encryptionService.encrypt(readReceiptPayload.encode(), recipientPeerID) + // Create NOISE_ENCRYPTED packet exactly like iOS val packet = BitchatPacket( version = 1u, @@ -842,18 +1001,18 @@ class BluetoothMeshService(private val context: Context) { signature = null, ttl = 7u // Same TTL as iOS messageTTL ) - + // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") - + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") } } } - + /** * Send broadcast announce with TLV-encoded identity announcement - exactly like iOS */ @@ -861,21 +1020,21 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Sending broadcast announce") serviceScope.launch { val nickname = delegate?.getNickname() ?: myPeerID - + // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() if (staticKey == null) { Log.e(TAG, "No static public key available for announcement") return@launch } - + // Get the signing public key for the announcement val signingKey = encryptionService.getSigningPublicKey() if (signingKey == null) { Log.e(TAG, "No signing public key available for announcement") return@launch } - + // Create iOS-compatible IdentityAnnouncement with TLV encoding val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) val tlvPayload = announcement.encode() @@ -883,48 +1042,52 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Failed to encode announcement as TLV") return@launch } - + val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, ttl = MAX_TTL, senderID = myPeerID, payload = tlvPayload ) - + // Sign the packet using our signing key (exactly like iOS) - val signedPacket = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!)?.let { signature -> - announcePacket.copy(signature = signature) - } ?: announcePacket - + val signedPacket = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!) + ?.let { signature -> + announcePacket.copy(signature = signature) + } ?: announcePacket + connectionManager.broadcastPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)") // Track announce for sync - try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + try { + gossipSyncManager.onPublicPacketSeen(signedPacket) + } catch (_: Exception) { + } } } - + /** * Send announcement to specific peer with TLV-encoded identity announcement - exactly like iOS */ fun sendAnnouncementToPeer(peerID: String) { if (peerManager.hasAnnouncedToPeer(peerID)) return - + val nickname = delegate?.getNickname() ?: myPeerID - + // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() if (staticKey == null) { Log.e(TAG, "No static public key available for peer announcement") return } - + // Get the signing public key for the announcement val signingKey = encryptionService.getSigningPublicKey() if (signingKey == null) { Log.e(TAG, "No signing public key available for peer announcement") return } - + // Create iOS-compatible IdentityAnnouncement with TLV encoding val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) val tlvPayload = announcement.encode() @@ -932,25 +1095,32 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Failed to encode peer announcement as TLV") return } - + val packet = BitchatPacket( type = MessageType.ANNOUNCE.value, ttl = MAX_TTL, senderID = myPeerID, payload = tlvPayload ) - + // Sign the packet using our signing key (exactly like iOS) - val signedPacket = encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let { signature -> - packet.copy(signature = signature) - } ?: packet - + val signedPacket = + encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let { signature -> + packet.copy(signature = signature) + } ?: packet + connectionManager.broadcastPacket(RoutedPacket(signedPacket)) peerManager.markPeerAsAnnouncedTo(peerID) - Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") + Log.d( + TAG, + "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)" + ) // Track announce for sync - try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + try { + gossipSyncManager.onPublicPacketSeen(signedPacket) + } catch (_: Exception) { + } } /** @@ -964,36 +1134,36 @@ class BluetoothMeshService(private val context: Context) { senderID = myPeerID, payload = nickname.toByteArray() ) - + // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) } - + /** * Get peer nicknames */ fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() - + /** - * Get peer RSSI values + * Get peer RSSI values */ fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() - + /** - * Check if we have an established Noise session with a peer + * Check if we have an established Noise session with a peer */ fun hasEstablishedSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } - + /** * Get session state for a peer (for UI state display) */ fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { return encryptionService.getSessionState(peerID) } - + /** * Initiate Noise handshake with a specific peer (public API) */ @@ -1001,7 +1171,7 @@ class BluetoothMeshService(private val context: Context) { // Delegate to the existing implementation in the MessageHandler delegate messageHandler.delegate?.initiateNoiseHandshake(peerID) } - + /** * Get peer fingerprint for identity management */ @@ -1026,23 +1196,29 @@ class BluetoothMeshService(private val context: Context) { signingPublicKey: ByteArray, isVerified: Boolean ): Boolean { - return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + return peerManager.updatePeerInfo( + peerID, + nickname, + noisePublicKey, + signingPublicKey, + isVerified + ) } - + /** * Get our identity fingerprint */ fun getIdentityFingerprint(): String { return encryptionService.getIdentityFingerprint() } - + /** * Check if encryption icon should be shown for a peer */ fun shouldShowEncryptionIcon(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } - + /** * Get all peers with established encrypted sessions */ @@ -1051,21 +1227,21 @@ class BluetoothMeshService(private val context: Context) { // This method is not critical for the session retention fix return emptyList() } - + /** * Get device address for a specific peer ID */ fun getDeviceAddressForPeer(peerID: String): String? { return connectionManager.addressPeerMap.entries.find { it.value == peerID }?.key } - + /** * Get all device addresses mapped to their peer IDs */ fun getDeviceAddressToPeerMapping(): Map { return connectionManager.addressPeerMap.toMap() } - + /** * Print device addresses for all connected peers */ @@ -1098,7 +1274,7 @@ class BluetoothMeshService(private val context: Context) { appendLine(packetProcessor.getDebugInfo()) } } - + /** * Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS */ @@ -1106,7 +1282,7 @@ class BluetoothMeshService(private val context: Context) { val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes var tempID = hexString var index = 0 - + while (tempID.length >= 2 && index < 8) { val hexByte = tempID.substring(0, 2) val byte = hexByte.toIntOrNull(16)?.toByte() @@ -1116,10 +1292,10 @@ class BluetoothMeshService(private val context: Context) { tempID = tempID.substring(2) index++ } - + return result } - + /** * Sign packet before broadcasting using our signing private key */ @@ -1128,14 +1304,20 @@ class BluetoothMeshService(private val context: Context) { // Get the canonical packet data for signing (without signature) val packetDataForSigning = packet.toBinaryDataForSigning() if (packetDataForSigning == null) { - Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned") + Log.w( + TAG, + "Failed to encode packet type ${packet.type} for signing, sending unsigned" + ) return packet } - + // Sign the packet data using our signing key val signature = encryptionService.signData(packetDataForSigning) if (signature != null) { - Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)") + Log.d( + TAG, + "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)" + ) packet.copy(signature = signature) } else { Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned") @@ -1146,9 +1328,9 @@ class BluetoothMeshService(private val context: Context) { packet } } - + // MARK: - Panic Mode Support - + /** * Clear all internal mesh service data (for panic mode) */ @@ -1166,7 +1348,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "❌ Error clearing mesh service internal data: ${e.message}") } } - + /** * Clear all encryption and cryptographic data (for panic mode) */ @@ -1181,18 +1363,3 @@ class BluetoothMeshService(private val context: Context) { } } } - -/** - * Delegate interface for mesh service callbacks (maintains exact same interface) - */ -interface BluetoothMeshDelegate { - fun didReceiveMessage(message: BitchatMessage) - fun didUpdatePeerList(peers: List) - fun didReceiveChannelLeave(channel: String, fromPeer: String) - fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) - fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) - fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? - fun getNickname(): String? - fun isFavorite(peerID: String): Boolean - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager -} diff --git a/app/src/main/java/com/bitchat/android/model/Misc.kt b/app/src/main/java/com/bitchat/android/model/Misc.kt new file mode 100644 index 000000000..390263d02 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/Misc.kt @@ -0,0 +1,7 @@ +package com.bitchat.android.model + +import android.util.Log + +fun logWarn(msg: String) { + Log.w("Bitchat", msg) +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index bce3ef1c2..0c2faba65 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.ui.debug.DebugSettingsSheet +import com.bitchat.android.ui.theme.ThemePreference /** * About Sheet for bitchat app information @@ -38,8 +39,8 @@ import com.bitchat.android.ui.debug.DebugSettingsSheet @Composable fun AboutSheet( isPresented: Boolean, - onDismiss: () -> Unit, - onShowDebug: (() -> Unit)? = null, + onDismiss: () -> Unit, onShowDebug: (() -> Unit)? = null, + themePref: ThemePreference, onChangeTheme: (ThemePreference) -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -70,6 +71,7 @@ fun AboutSheet( label = "topBarAlpha" ) + // Color scheme matching LocationChannelsSheet val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f @@ -239,24 +241,23 @@ fun AboutSheet( .padding(horizontal = 24.dp) .padding(top = 24.dp, bottom = 8.dp) ) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() Row( modifier = Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { FilterChip( selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + onClick = { onChangeTheme(ThemePreference.System) }, label = { Text("system", fontFamily = FontFamily.Monospace) } ) FilterChip( selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + onClick = { onChangeTheme(ThemePreference.Light) }, label = { Text("light", fontFamily = FontFamily.Monospace) } ) FilterChip( selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + onClick = { onChangeTheme(ThemePreference.Dark) }, label = { Text("dark", fontFamily = FontFamily.Monospace) } ) } 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 72a9e1e44..7c1daec34 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.media.FullScreenImageViewer +import com.bitchat.android.ui.theme.ThemePreference /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -38,7 +39,9 @@ import com.bitchat.android.ui.media.FullScreenImageViewer * - ChatUIUtils: Utility functions for formatting and colors */ @Composable -fun ChatScreen(viewModel: ChatViewModel) { +fun ChatScreen( + viewModel: ChatViewModel, themePref: ThemePreference, onChangeTheme: (ThemePreference) -> Unit +) { val colorScheme = MaterialTheme.colorScheme val messages by viewModel.messages.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) @@ -104,7 +107,7 @@ fun ChatScreen(viewModel: ChatViewModel) { .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { val headerHeight = 42.dp - + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -130,26 +133,27 @@ fun ChatScreen(viewModel: ChatViewModel) { onNicknameClick = { fullSenderName -> // Single click - mention user in text input val currentText = messageText.text - + // Extract base nickname and hash suffix from full sender name val (baseName, hashSuffix) = splitSuffix(fullSenderName) - + // Check if we're in a geohash channel to include hash suffix val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) { - // In geohash chat - include the hash suffix from the full display name - "@$baseName$hashSuffix" - } else { - // Regular chat - just the base nickname - "@$baseName" - } - + val mentionText = + if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) { + // In geohash chat - include the hash suffix from the full display name + "@$baseName$hashSuffix" + } else { + // Regular chat - just the base nickname + "@$baseName" + } + val newText = when { currentText.isEmpty() -> "$mentionText " currentText.endsWith(" ") -> "$currentText$mentionText " else -> "$currentText $mentionText " } - + messageText = TextFieldValue( text = newText, selection = TextRange(newText.length) @@ -173,42 +177,42 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) // Input area - stays at bottom - // Bridge file share from lower-level input to ViewModel - androidx.compose.runtime.LaunchedEffect(Unit) { - com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> - viewModel.sendFileNote(peer, channel, path) - } - } - - ChatInputSection( - messageText = messageText, - onMessageTextChange = { newText: TextFieldValue -> - messageText = newText - viewModel.updateCommandSuggestions(newText.text) - viewModel.updateMentionSuggestions(newText.text) - }, - onSend = { - if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) - messageText = TextFieldValue("") - forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll + // Bridge file share from lower-level input to ViewModel + androidx.compose.runtime.LaunchedEffect(Unit) { + com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> + viewModel.sendFileNote(peer, channel, path) + } } - }, - onSendVoiceNote = { peer, onionOrChannel, path -> - viewModel.sendVoiceNote(peer, onionOrChannel, path) - }, - onSendImageNote = { peer, onionOrChannel, path -> - viewModel.sendImageNote(peer, onionOrChannel, path) - }, - onSendFileNote = { peer, onionOrChannel, path -> - viewModel.sendFileNote(peer, onionOrChannel, path) - }, - - showCommandSuggestions = showCommandSuggestions, - commandSuggestions = commandSuggestions, - showMentionSuggestions = showMentionSuggestions, - mentionSuggestions = mentionSuggestions, - onCommandSuggestionClick = { suggestion: CommandSuggestion -> + + ChatInputSection( + messageText = messageText, + onMessageTextChange = { newText: TextFieldValue -> + messageText = newText + viewModel.updateCommandSuggestions(newText.text) + viewModel.updateMentionSuggestions(newText.text) + }, + onSend = { + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) + messageText = TextFieldValue("") + forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll + } + }, + onSendVoiceNote = { peer, onionOrChannel, path -> + viewModel.sendVoiceNote(peer, onionOrChannel, path) + }, + onSendImageNote = { peer, onionOrChannel, path -> + viewModel.sendImageNote(peer, onionOrChannel, path) + }, + onSendFileNote = { peer, onionOrChannel, path -> + viewModel.sendFileNote(peer, onionOrChannel, path) + }, + + showCommandSuggestions = showCommandSuggestions, + commandSuggestions = commandSuggestions, + showMentionSuggestions = showMentionSuggestions, + mentionSuggestions = mentionSuggestions, + onCommandSuggestionClick = { suggestion: CommandSuggestion -> val commandText = viewModel.selectCommandSuggestion(suggestion) messageText = TextFieldValue( text = commandText, @@ -349,12 +353,13 @@ fun ChatScreen(viewModel: ChatViewModel) { showPasswordDialog = false passwordInput = "" }, + onChangeTheme = onChangeTheme, themePref = themePref, showAppInfo = showAppInfo, onAppInfoDismiss = { viewModel.hideAppInfo() }, showLocationChannelsSheet = showLocationChannelsSheet, onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false }, showUserSheet = showUserSheet, - onUserSheetDismiss = { + onUserSheetDismiss = { showUserSheet = false selectedMessageForSheet = null // Reset message when dismissing }, @@ -422,6 +427,7 @@ private fun ChatInputSection( } } } + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatFloatingHeader( @@ -479,6 +485,8 @@ private fun ChatDialogs( onPasswordConfirm: () -> Unit, onPasswordDismiss: () -> Unit, showAppInfo: Boolean, + onChangeTheme: (ThemePreference) -> Unit, + themePref: ThemePreference, onAppInfoDismiss: () -> Unit, showLocationChannelsSheet: Boolean, onLocationChannelsSheetDismiss: () -> Unit, @@ -503,7 +511,8 @@ private fun ChatDialogs( AboutSheet( isPresented = showAppInfo, onDismiss = onAppInfoDismiss, - onShowDebug = { showDebugSheet = true } + onShowDebug = { showDebugSheet = true }, + onChangeTheme = onChangeTheme, themePref = themePref ) if (showDebugSheet) { com.bitchat.android.ui.debug.DebugSettingsSheet( @@ -512,7 +521,7 @@ private fun ChatDialogs( meshService = viewModel.meshService ) } - + // Location channels sheet if (showLocationChannelsSheet) { LocationChannelsSheet( @@ -521,7 +530,7 @@ private fun ChatDialogs( viewModel = viewModel ) } - + // User action sheet if (showUserSheet) { ChatUserSheet( 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 4224d9c1f..cdafdda3d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -2,23 +2,14 @@ package com.bitchat.android.ui import android.app.Application import android.util.Log -import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.model.BitchatMessageType -import com.bitchat.android.protocol.BitchatPacket - - import kotlinx.coroutines.launch -import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.Date -import kotlin.random.Random /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -26,9 +17,15 @@ import kotlin.random.Random */ class ChatViewModel( application: Application, - val meshService: BluetoothMeshService -) : AndroidViewModel(application), BluetoothMeshDelegate { - private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } + val meshService: BluetoothMeshService, private val bmd: BluetoothMeshDelegate +) : AndroidViewModel(application) { + private val debugManager by lazy { + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + } catch (e: Exception) { + null + } + } companion object { private const val TAG = "ChatViewModel" @@ -46,97 +43,59 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } - // MARK: - State management - private val state = ChatState() - - // Transfer progress tracking - private val transferMessageMap = mutableMapOf() - private val messageTransferMap = mutableMapOf() - - // 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 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, - NotificationManagerCompat.from(application.applicationContext), - NotificationIntervalManager() - ) - // Media file sending manager - private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) - + private val mediaSendingManager = + MediaSendingManager(bmd.state, bmd.messageManager, bmd.channelManager, meshService) + // 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 } - ) - + // New Geohash architecture ViewModel (replaces God object service usage in UI path) val geohashViewModel = GeohashViewModel( application = application, - state = state, - messageManager = messageManager, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, - dataManager = dataManager, - notificationManager = notificationManager + state = bmd.state, + messageManager = bmd.messageManager, + privateChatManager = bmd.privateChatManager, + meshDelegateHandler = bmd.meshDelegateHandler, + dataManager = bmd.dataManager, + notificationManager = bmd.notificationManager ) - - // Expose state through LiveData (maintaining the same interface) - val messages: LiveData> = state.messages - val connectedPeers: LiveData> = state.connectedPeers - val nickname: LiveData = state.nickname - val isConnected: LiveData = state.isConnected - val privateChats: LiveData>> = state.privateChats - val selectedPrivateChatPeer: LiveData = state.selectedPrivateChatPeer - val unreadPrivateMessages: LiveData> = state.unreadPrivateMessages - val joinedChannels: LiveData> = state.joinedChannels - val currentChannel: LiveData = state.currentChannel - val channelMessages: LiveData>> = state.channelMessages - val unreadChannelMessages: LiveData> = state.unreadChannelMessages - val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels - val showPasswordPrompt: LiveData = state.showPasswordPrompt - val passwordPromptChannel: LiveData = state.passwordPromptChannel - val showSidebar: LiveData = state.showSidebar - val hasUnreadChannels = state.hasUnreadChannels - val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages - val showCommandSuggestions: LiveData = state.showCommandSuggestions - val commandSuggestions: LiveData> = state.commandSuggestions - val showMentionSuggestions: LiveData = state.showMentionSuggestions - val mentionSuggestions: LiveData> = state.mentionSuggestions - val favoritePeers: LiveData> = state.favoritePeers - val peerSessionStates: LiveData> = state.peerSessionStates - val peerFingerprints: LiveData> = state.peerFingerprints - val peerNicknames: LiveData> = state.peerNicknames - val peerRSSI: LiveData> = state.peerRSSI - val peerDirect: LiveData> = state.peerDirect - val showAppInfo: LiveData = state.showAppInfo - val selectedLocationChannel: LiveData = state.selectedLocationChannel - val isTeleported: LiveData = state.isTeleported - val geohashPeople: LiveData> = state.geohashPeople - val teleportedGeo: LiveData> = state.teleportedGeo - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val messages: LiveData> = bmd.state.messages + val connectedPeers: LiveData> = bmd.state.connectedPeers + val nickname: LiveData = bmd.state.nickname + val isConnected: LiveData = bmd.state.isConnected + val privateChats: LiveData>> = bmd.state.privateChats + val selectedPrivateChatPeer: LiveData = bmd.state.selectedPrivateChatPeer + val unreadPrivateMessages: LiveData> = bmd.state.unreadPrivateMessages + val joinedChannels: LiveData> = bmd.state.joinedChannels + val currentChannel: LiveData = bmd.state.currentChannel + val channelMessages: LiveData>> = bmd.state.channelMessages + val unreadChannelMessages: LiveData> = bmd.state.unreadChannelMessages + val passwordProtectedChannels: LiveData> = bmd.state.passwordProtectedChannels + val showPasswordPrompt: LiveData = bmd.state.showPasswordPrompt + val passwordPromptChannel: LiveData = bmd.state.passwordPromptChannel + val showSidebar: LiveData = bmd.state.showSidebar + val hasUnreadChannels = bmd.state.hasUnreadChannels + val hasUnreadPrivateMessages = bmd.state.hasUnreadPrivateMessages + val showCommandSuggestions: LiveData = bmd.state.showCommandSuggestions + val commandSuggestions: LiveData> = bmd.state.commandSuggestions + val showMentionSuggestions: LiveData = bmd.state.showMentionSuggestions + val mentionSuggestions: LiveData> = bmd.state.mentionSuggestions + val favoritePeers: LiveData> = bmd.state.favoritePeers + val peerSessionStates: LiveData> = bmd.state.peerSessionStates + val peerFingerprints: LiveData> = bmd.state.peerFingerprints + val peerNicknames: LiveData> = bmd.state.peerNicknames + val peerRSSI: LiveData> = bmd.state.peerRSSI + val peerDirect: LiveData> = bmd.state.peerDirect + val showAppInfo: LiveData = bmd.state.showAppInfo + val selectedLocationChannel: LiveData = + bmd.state.selectedLocationChannel + val isTeleported: LiveData = bmd.state.isTeleported + val geohashPeople: LiveData> = bmd.state.geohashPeople + val teleportedGeo: LiveData> = bmd.state.teleportedGeo + val geohashParticipantCounts: LiveData> = bmd.state.geohashParticipantCounts init { // Note: Mesh service delegate is now set by MainActivity @@ -149,114 +108,23 @@ class ChatViewModel( } } - fun cancelMediaSend(messageId: String) { - val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] } - if (transferId != null) { - val cancelled = meshService.cancelFileTransfer(transferId) - if (cancelled) { - // Remove the message from chat upon explicit cancel - messageManager.removeMessageById(messageId) - synchronized(transferMessageMap) { - transferMessageMap.remove(transferId) - messageTransferMap.remove(messageId) - } - } - } - } - - private fun loadAndInitialize() { - // 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)) { - val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() - updatedChannelMessages[channel] = emptyList() - state.setChannelMessages(updatedChannelMessages) - } - } - - // Load other data - dataManager.loadFavorites() - state.setFavoritePeers(dataManager.favoritePeers.toSet()) - dataManager.loadBlockedUsers() - dataManager.loadGeohashBlockedUsers() - - // Log all favorites at startup - dataManager.logAllFavorites() - logCurrentFavoriteState() - - // Initialize session state monitoring - initializeSessionStateMonitoring() - - // Bridge DebugSettingsManager -> Chat messages when verbose logging is on - viewModelScope.launch { - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().debugMessages.collect { msgs -> - if (com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().verboseLoggingEnabled.value) { - // Only show debug logs in the Mesh chat timeline to avoid leaking into geohash chats - val selectedLocation = state.selectedLocationChannel.value - if (selectedLocation is com.bitchat.android.geohash.ChannelID.Mesh) { - // Append only latest debug message as system message to avoid flooding - msgs.lastOrNull()?.let { dm -> - messageManager.addSystemMessage(dm.content) - } - } - } - } - } - - // Initialize new geohash architecture - geohashViewModel.initialize() + fun cancelMediaSend(messageId: String) = bmd.cancelMediaSend(messageId) - // Initialize favorites persistence service - com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + private fun loadAndInitialize() = bmd.loadAndInitialize( + logCurrentFavoriteState = { logCurrentFavoriteState() }, + initializeSessionStateMonitoring = { initializeSessionStateMonitoring() }, + initializeGeoHashVM = { geohashViewModel.initialize() } + ) +// override fun onCleared() { +// super.onCleared() +// // Note: Mesh service lifecycle is now managed by MainActivity +// } - // Ensure NostrTransport knows our mesh peer ID for embedded packets - try { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID - } catch (_: Exception) { } + // MARK: - Nickname Management - // Note: Mesh service is now started by MainActivity - - // Show welcome message if no peers after delay - viewModelScope.launch { - delay(10000) - if (state.getConnectedPeersValue().isEmpty() && state.getMessagesValue().isEmpty()) { - val welcomeMessage = BitchatMessage( - sender = "system", - content = "get people around you to download bitchat and chat with them here!", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(welcomeMessage) - } - } + fun setNickname(newNickname: String) = bmd.setNickname(newNickname) - // BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch. - } - - 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() - } - /** * Ensure Nostr DM subscription for a geohash conversation key if known * Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline @@ -265,15 +133,22 @@ class ChatViewModel( try { val repoField = GeohashViewModel::class.java.getDeclaredField("repo") repoField.isAccessible = true - val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository + val repo = + repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository val gh = repo.getConversationGeohash(convKey) if (!gh.isNullOrEmpty()) { - val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager") + val subMgrField = + GeohashViewModel::class.java.getDeclaredField("subscriptionManager") subMgrField.isAccessible = true - val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication()) + val subMgr = + subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( + gh, + getApplication() + ) val subId = "geo-dm-$gh" - val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId") + val currentDmSubField = + GeohashViewModel::class.java.getDeclaredField("currentDmSubId") currentDmSubField.isAccessible = true val currentId = currentDmSubField.get(geohashViewModel) as String? if (currentId != subId) { @@ -284,9 +159,11 @@ class ChatViewModel( sinceMs = System.currentTimeMillis() - 172800000L, id = subId, handler = { event -> - val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler") + val dmHandlerField = + GeohashViewModel::class.java.getDeclaredField("dmHandler") dmHandlerField.isAccessible = true - val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler + val dmHandler = + dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler dmHandler.onGiftWrap(event, gh, identity) } ) @@ -298,301 +175,61 @@ class ChatViewModel( } // 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) { - // For geohash conversation keys, ensure DM subscription is active - if (peerID.startsWith("nostr_")) { - ensureGeohashDMSubscriptionIfNeeded(peerID) - } - - val success = privateChatManager.startPrivateChat(peerID, meshService) - if (success) { - // Notify notification manager about current private chat - setCurrentPrivateChatPeer(peerID) - // Clear notifications for this sender since user is now viewing the chat - clearNotificationsForSender(peerID) - - // Persistently mark all messages in this conversation as read so Nostr fetches - // after app restarts won't re-mark them as unread. - try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val chats = state.getPrivateChatsValue() - val messages = chats[peerID] ?: emptyList() - messages.forEach { msg -> - try { seen.markRead(msg.id) } catch (_: Exception) { } - } - } catch (_: Exception) { } - } - } - - fun endPrivateChat() { - privateChatManager.endPrivateChat() - // Notify notification manager that no private chat is active - setCurrentPrivateChatPeer(null) - // Clear mesh mention notifications since user is now back in mesh chat - clearMeshMentionNotifications() - } - // MARK: - Open Latest Unread Private Chat + fun joinChannel(channel: String, password: String? = null) = + bmd.joinChannel(channel, password) - fun openLatestUnreadPrivateChat() { - try { - val unreadKeys = state.getUnreadPrivateMessagesValue() - if (unreadKeys.isEmpty()) return - - val me = state.getNicknameValue() ?: meshService.myPeerID - val chats = state.getPrivateChatsValue() - - // Pick the latest incoming message among unread conversations - var bestKey: String? = null - var bestTime: Long = Long.MIN_VALUE - - unreadKeys.forEach { key -> - val list = chats[key] - if (!list.isNullOrEmpty()) { - // Prefer the latest incoming message (sender != me), fallback to last message - val latestIncoming = list.lastOrNull { it.sender != me } - val candidateTime = (latestIncoming ?: list.last()).timestamp.time - if (candidateTime > bestTime) { - bestTime = candidateTime - bestKey = key - } - } - } - val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return - - val openPeer: String = if (targetKey.startsWith("nostr_")) { - // Use the exact conversation key for geohash DMs and ensure DM subscription - ensureGeohashDMSubscriptionIfNeeded(targetKey) - targetKey - } else { - // Resolve to a canonical mesh peer if needed - val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( - selectedPeerID = targetKey, - connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, - nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } - ) - canonical ?: targetKey - } + fun switchToChannel(channel: String?) = bmd.switchToChannel(channel) - startPrivateChat(openPeer) - // If sidebar visible, hide it to focus on the private chat - if (state.getShowSidebarValue()) { - state.setShowSidebar(false) - } - } catch (e: Exception) { - Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") - } + fun leaveChannel(channel: String) = bmd.leaveChannel(channel) + + + // MARK: - Private Chat Management (delegated) + + fun startPrivateChat(peerID: String) = bmd.startPrivateChat(peerID) { + ensureGeohashDMSubscriptionIfNeeded(peerID) } + fun endPrivateChat() = bmd.endPrivateChat() + + // MARK: - Open Latest Unread Private Chat + + fun openLatestUnreadPrivateChat() = bmd.openLatestUnreadPrivateChat( + onEnsureGeohashDMSubscription = { ensureGeohashDMSubscriptionIfNeeded(it) } + ) + // END - Open Latest Unread Private Chat - + // MARK: - Message Sending - - fun sendMessage(content: String) { - if (content.isEmpty()) return - - // Check for commands - if (content.startsWith("/")) { - val selectedLocationForCommand = state.selectedLocationChannel.value - commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel -> - if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { - // Route command-generated public messages via Nostr in geohash channels - geohashViewModel.sendGeohashMessage( - messageContent, - selectedLocationForCommand.channel, - meshService.myPeerID, - state.getNicknameValue() - ) - } else { - // Default: route via mesh - meshService.sendMessage(messageContent, mentions, channel) - } - }) - return - } - - val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue()) - // REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions - // This was causing messages like "test @jack#1234 test" to auto-join channel "#1234" - - var selectedPeer = state.getSelectedPrivateChatPeerValue() - val currentChannelValue = state.getCurrentChannelValue() - - if (selectedPeer != null) { - // If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target - selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( - selectedPeerID = selectedPeer, - connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, - nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } - ).also { canonical -> - if (canonical != state.getSelectedPrivateChatPeerValue()) { - privateChatManager.startPrivateChat(canonical, meshService) - } - } - // Send private message - val recipientNickname = meshService.getPeerNicknames()[selectedPeer] - privateChatManager.sendPrivateMessage( - content, - selectedPeer, - recipientNickname, - state.getNicknameValue(), - meshService.myPeerID - ) { messageContent, peerID, recipientNicknameParam, messageId -> - // Route via MessageRouter (mesh when connected+established, else Nostr) - val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService) - router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) - } - } else { - // Check if we're in a location channel - val selectedLocationChannel = state.selectedLocationChannel.value - if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { - // Send to geohash channel via Nostr ephemeral event - geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) - } else { - // Send public/channel message via mesh - val message = BitchatMessage( - sender = state.getNicknameValue() ?: meshService.myPeerID, - content = content, - timestamp = Date(), - isRelay = false, - senderPeerID = meshService.myPeerID, - 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, - state.getNicknameValue(), - meshService.myPeerID, - onEncryptedPayload = { encryptedData -> - // This would need proper mesh service integration - meshService.sendMessage(content, mentions, currentChannelValue) - }, - onFallback = { - meshService.sendMessage(content, mentions, currentChannelValue) - } - ) - } else { - meshService.sendMessage(content, mentions, currentChannelValue) - } - } else { - messageManager.addMessage(message) - meshService.sendMessage(content, mentions, null) - } - } - } + fun sendMessage(content: String) = bmd.sendMessage(content) { msg, channel -> + geohashViewModel.sendGeohashMessage( + msg, channel, meshService.myPeerID, bmd.state.getNicknameValue() + ) } // 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) - - // Persist relationship in FavoritesPersistenceService - try { - var noiseKey: ByteArray? = null - var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID - - // Case 1: Live mesh peer with known info - val peerInfo = meshService.getPeerInfo(peerID) - if (peerInfo?.noisePublicKey != null) { - noiseKey = peerInfo.noisePublicKey - nickname = peerInfo.nickname - } else { - // Case 2: Offline favorite entry using 64-hex noise public key as peerID - if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - try { - noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - // Prefer nickname from favorites store if available - val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!) - if (rel != null) nickname = rel.peerNickname - } catch (_: Exception) { } - } - } - if (noiseKey != null) { - // Determine current favorite state from DataManager using fingerprint - val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) - val fingerprint = identityManager.generateFingerprint(noiseKey!!) - val isNowFavorite = dataManager.favoritePeers.contains(fingerprint) + fun toggleFavorite(peerID: String) = bmd.toggleFavorite(peerID) { logCurrentFavoriteState() } - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus( - noisePublicKey = noiseKey!!, - nickname = nickname, - isFavorite = isNowFavorite - ) - - // Send favorite notification via mesh or Nostr with our npub if available - try { - val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}" - // Prefer mesh if session established, else try Nostr - if (meshService.hasEstablishedSession(peerID)) { - // Reuse existing private message path for notifications - meshService.sendPrivateMessage( - announcementContent, - peerID, - nickname, - java.util.UUID.randomUUID().toString() - ) - } else { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID - nostrTransport.sendFavoriteNotification(peerID, isNowFavorite) - } - } catch (_: Exception) { } - } - } catch (_: Exception) { } - - // Log current state after toggle - logCurrentFavoriteState() - } - private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") - Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") - Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") + Log.i("ChatViewModel", "DataManager favorite peers: ${bmd.dataManager.favoritePeers}") + Log.i( + "ChatViewModel", + "Peer fingerprints: ${bmd.privateChatManager.getAllPeerFingerprints()}" + ) Log.i("ChatViewModel", "==============================") } - + /** * Initialize session state monitoring for reactive UI updates */ @@ -604,234 +241,64 @@ class ChatViewModel( } } } - + /** * Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI) */ - private fun updateReactiveStates() { - val currentPeers = state.getConnectedPeersValue() - - // Update session states - val prevStates = state.getPeerSessionStatesValue() - val sessionStates = currentPeers.associateWith { peerID -> - meshService.getSessionState(peerID).toString() - } - state.setPeerSessionStates(sessionStates) - // Detect new established sessions and flush router outbox for them and their noiseHex aliases - sessionStates.forEach { (peerID, newState) -> - val old = prevStates[peerID] - if (old != "established" && newState == "established") { - com.bitchat.android.services.MessageRouter - .getInstance(getApplication(), meshService) - .onSessionEstablished(peerID) - } - } - // Update fingerprint mappings from centralized manager - val fingerprints = privateChatManager.getAllPeerFingerprints() - state.setPeerFingerprints(fingerprints) - - val nicknames = meshService.getPeerNicknames() - state.setPeerNicknames(nicknames) - - val rssiValues = meshService.getPeerRSSI() - state.setPeerRSSI(rssiValues) - - // Update directness per peer (driven by PeerManager state) - try { - val directMap = state.getConnectedPeersValue().associateWith { pid -> - meshService.getPeerInfo(pid)?.isDirectConnection == true - } - state.setPeerDirect(directMap) - } catch (_: Exception) { } - } + private fun updateReactiveStates() = bmd.updateReactiveStates() // 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 setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic - notificationManager.setCurrentGeohash(geohash) - } - fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat - notificationManager.clearNotificationsForSender(peerID) - } - - fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat - notificationManager.clearNotificationsForGeohash(geohash) - } + fun setAppBackgroundState(inBackground: Boolean) = bmd.setAppBackgroundState(inBackground) + + fun changeMeshServiceBGState(b: Boolean) = bmd.changeMeshServiceBGState(b) + fun startMeshServices() = bmd.startMeshServices() + fun setUpDelegate() { meshService.delegate = bmd } + fun stopMeshServices() = bmd.startMeshServices() + + fun setCurrentPrivateChatPeer(peerID: String?) = bmd.setCurrentPrivateChatPeer(peerID) + + + fun setCurrentGeohash(geohash: String?) = bmd.setCurrentGeohash(geohash) + + fun clearNotificationsForSender(peerID: String) = bmd.clearNotificationsForSender(peerID) + + fun clearNotificationsForGeohash(geohash: String) = bmd.clearNotificationsForGeohash(geohash) /** * Clear mesh mention notifications when user opens mesh chat */ - fun clearMeshMentionNotifications() { - notificationManager.clearMeshMentionNotifications() - } + fun clearMeshMentionNotifications() = bmd.clearMeshMentionNotifications() + // MARK: - Command Autocomplete (delegated) - - fun updateCommandSuggestions(input: String) { - commandProcessor.updateCommandSuggestions(input) - } - - fun selectCommandSuggestion(suggestion: CommandSuggestion): String { - return commandProcessor.selectCommandSuggestion(suggestion) - } - + + fun updateCommandSuggestions(input: String) = bmd.updateCommandSuggestions(input) + + + fun selectCommandSuggestion(suggestion: CommandSuggestion) = + bmd.selectCommandSuggestion(suggestion) + // MARK: - Mention Autocomplete - - fun updateMentionSuggestions(input: String) { - commandProcessor.updateMentionSuggestions(input, meshService, this) - } - - 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 didUpdatePeerList(peers: List) { - meshDelegateHandler.didUpdatePeerList(peers) - } - - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { - meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) - } - - override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { - meshDelegateHandler.didReceiveDeliveryAck(messageID, recipientPeerID) - } - - override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { - meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) - } - - 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() - - // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch - try { - // Clear geohash bookmarks too (panic should remove everything) - try { - val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) - store.clearAll() - } catch (_: Exception) { } - geohashViewModel.panicReset() - } catch (e: Exception) { - Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") - } + fun updateMentionSuggestions(input: String) = bmd.updateMentionSuggestions(input) - // 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 - */ - private fun clearAllMeshServiceData() { - 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 - */ - private fun clearAllCryptographicData() { - 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()) - identityManager.clearIdentityData() - // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } - Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") - } catch (e: Exception) { - Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") - } + fun selectMentionSuggestion(nickname: String, currentText: String) = + bmd.selectMentionSuggestion(nickname, currentText) - // Clear FavoritesPersistenceService persistent relationships - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() - Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } - - Log.d(TAG, "✅ Cleared all cryptographic data") - } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") - } - } + + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager + + // MARK: - Emergency Clear + + fun panicClearAllData() = bmd.panicClearAllData { geohashViewModel.panicReset() } /** * Get participant count for a specific geohash (5-minute activity window) @@ -882,23 +349,19 @@ class ChatViewModel( } // MARK: - Navigation Management - - fun showAppInfo() { - state.setShowAppInfo(true) - } - - fun hideAppInfo() { - state.setShowAppInfo(false) - } - - fun showSidebar() { - state.setShowSidebar(true) - } - - fun hideSidebar() { - state.setShowSidebar(false) - } - + + fun showAppInfo() = bmd.state.setShowAppInfo(true) + + + fun hideAppInfo() = bmd.state.setShowAppInfo(false) + + + fun showSidebar() = bmd.state.setShowSidebar(true) + + + fun hideSidebar() = bmd.state.setShowSidebar(false) + + /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system @@ -906,28 +369,28 @@ class ChatViewModel( fun handleBackPressed(): Boolean { return when { // Close app info dialog - state.getShowAppInfoValue() -> { + bmd.state.getShowAppInfoValue() -> { hideAppInfo() true } // Close sidebar - state.getShowSidebarValue() -> { + bmd.state.getShowSidebarValue() -> { hideSidebar() true } // Close password dialog - state.getShowPasswordPromptValue() -> { - state.setShowPasswordPrompt(false) - state.setPasswordPromptChannel(null) + bmd.state.getShowPasswordPromptValue() -> { + bmd.state.setShowPasswordPrompt(false) + bmd.state.setPasswordPromptChannel(null) true } // Exit private chat - state.getSelectedPrivateChatPeerValue() != null -> { + bmd.state.getSelectedPrivateChatPeerValue() != null -> { endPrivateChat() true } // Exit channel view - state.getCurrentChannelValue() != null -> { + bmd.state.getCurrentChannelValue() != null -> { switchToChannel(null) true } @@ -950,7 +413,10 @@ class ChatViewModel( /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { + fun colorForNostrPubkey( + pubkeyHex: String, + isDark: Boolean + ): androidx.compose.ui.graphics.Color { return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) } } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 499b39261..54abddb33 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import com.bitchat.android.geohash.ChannelID import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import java.util.Date @@ -13,7 +14,7 @@ class CommandProcessor( private val channelManager: ChannelManager, private val privateChatManager: PrivateChatManager ) { - + // Available commands list private val baseCommands = listOf( CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"), @@ -26,12 +27,18 @@ class CommandProcessor( CommandSuggestion("/unblock", emptyList(), "", "unblock a peer"), CommandSuggestion("/w", emptyList(), null, "see who's online") ) - + // MARK: - Command Processing - - fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { + + fun processCommand( + command: String, + meshService: BluetoothMeshService, + myPeerID: String, + onSendMessage: (String, List, String?) -> Unit, + viewModel: ChatViewModel? = null + ): Boolean { if (!command.startsWith("/")) return false - + val parts = command.split(" ") val cmd = parts.first().lowercase() when (cmd) { @@ -42,15 +49,31 @@ class CommandProcessor( "/pass" -> handlePassCommand(parts, myPeerID) "/block" -> handleBlockCommand(parts, meshService) "/unblock" -> handleUnblockCommand(parts, meshService) - "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage) - "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage) + "/hug" -> handleActionCommand( + parts, + "gives", + "a warm hug 🫂", + meshService, + myPeerID, + onSendMessage + ) + + "/slap" -> handleActionCommand( + parts, + "slaps", + "around a bit with a large trout 🐟", + meshService, + myPeerID, + onSendMessage + ) + "/channels" -> handleChannelsCommand() else -> handleUnknownCommand(cmd) } - + return true } - + private fun handleJoinCommand(parts: List, myPeerID: String) { if (parts.size > 1) { val channelName = parts[1] @@ -76,28 +99,34 @@ class CommandProcessor( messageManager.addMessage(systemMessage) } } - + private fun handleMessageCommand(parts: List, meshService: BluetoothMeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") val peerID = getPeerIDForNickname(targetName, meshService) - + if (peerID != null) { val success = privateChatManager.startPrivateChat(peerID, meshService) - + if (success) { if (parts.size > 2) { val messageContent = parts.drop(2).joinToString(" ") val recipientNickname = getPeerNickname(peerID, meshService) privateChatManager.sendPrivateMessage( - messageContent, - peerID, + messageContent, + peerID, recipientNickname, state.getNicknameValue(), getMyPeerID(meshService) ) { content, peerIdParam, recipientNicknameParam, messageId -> // This would trigger the actual mesh service send - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) + sendPrivateMessageVia( + meshService, + content, + peerIdParam, + recipientNicknameParam, + messageId + ) } } else { val systemMessage = BitchatMessage( @@ -128,8 +157,11 @@ class CommandProcessor( messageManager.addMessage(systemMessage) } } - - private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + + private fun handleWhoCommand( + meshService: BluetoothMeshService, + viewModel: ChatViewModel? = null + ) { // Channel-aware who command (matches iOS behavior) val (peerList, contextDescription) = if (viewModel != null) { when (val selectedChannel = viewModel.selectedLocationChannel.value) { @@ -142,12 +174,12 @@ class CommandProcessor( } Pair(peerList, "online users") } - + is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: show geohash participants val geohashPeople = viewModel.geohashPeople.value ?: emptyList() val currentNickname = state.getNicknameValue() - + val participantList = geohashPeople.mapNotNull { person -> val displayName = person.displayName // Exclude self from list @@ -157,7 +189,7 @@ class CommandProcessor( displayName } }.joinToString(", ") - + Pair(participantList, "participants in ${selectedChannel.channel.geohash}") } } @@ -169,7 +201,7 @@ class CommandProcessor( } Pair(peerList, "online users") } - + val systemMessage = BitchatMessage( sender = "system", content = if (peerList.isEmpty()) { @@ -182,7 +214,7 @@ class CommandProcessor( ) messageManager.addMessage(systemMessage) } - + private fun handleClearCommand() { when { state.getSelectedPrivateChatPeerValue() != null -> { @@ -190,11 +222,13 @@ class CommandProcessor( val peerID = state.getSelectedPrivateChatPeerValue()!! messageManager.clearPrivateMessages(peerID) } + state.getCurrentChannelValue() != null -> { // Clear channel messages val channel = state.getCurrentChannelValue()!! messageManager.clearChannelMessages(channel) } + else -> { // Clear main messages messageManager.clearMessages() @@ -216,15 +250,15 @@ class CommandProcessor( return } - if (parts.size == 2){ - if(!channelManager.isChannelCreator(channel = currentChannel, peerID = peerID)){ + if (parts.size == 2) { + if (!channelManager.isChannelCreator(channel = currentChannel, peerID = peerID)) { val systemMessage = BitchatMessage( sender = "system", content = "you must be the channel creator to set a password.", timestamp = Date(), isRelay = false ) - channelManager.addChannelMessage(currentChannel,systemMessage,null) + channelManager.addChannelMessage(currentChannel, systemMessage, null) return } val newPassword = parts[1] @@ -235,19 +269,18 @@ class CommandProcessor( timestamp = Date(), isRelay = false ) - channelManager.addChannelMessage(currentChannel,systemMessage,null) - } - else{ + channelManager.addChannelMessage(currentChannel, systemMessage, null) + } else { val systemMessage = BitchatMessage( sender = "system", content = "usage: /pass ", timestamp = Date(), isRelay = false ) - channelManager.addChannelMessage(currentChannel,systemMessage,null) + channelManager.addChannelMessage(currentChannel, systemMessage, null) } } - + private fun handleBlockCommand(parts: List, meshService: BluetoothMeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") @@ -264,7 +297,7 @@ class CommandProcessor( messageManager.addMessage(systemMessage) } } - + private fun handleUnblockCommand(parts: List, meshService: BluetoothMeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") @@ -279,22 +312,24 @@ class CommandProcessor( messageManager.addMessage(systemMessage) } } - + private fun handleActionCommand( - parts: List, - verb: String, - object_: String, + parts: List, + verb: String, + object_: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit ) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") - val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *" + val actionMessage = + "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *" // If we're in a geohash location channel, don't add a local echo here. // GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata. - val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location + val isInLocationChannel = + state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location // Send as regular message if (state.getSelectedPrivateChatPeerValue() != null) { @@ -306,7 +341,13 @@ class CommandProcessor( state.getNicknameValue(), myPeerID ) { content, peerIdParam, recipientNicknameParam, messageId -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) + sendPrivateMessageVia( + meshService, + content, + peerIdParam, + recipientNicknameParam, + messageId + ) } } else if (isInLocationChannel) { // Let the transport layer add the echo; just send it out @@ -320,9 +361,13 @@ class CommandProcessor( senderPeerID = myPeerID, channel = state.getCurrentChannelValue() ) - + if (state.getCurrentChannelValue() != null) { - channelManager.addChannelMessage(state.getCurrentChannelValue()!!, message, myPeerID) + channelManager.addChannelMessage( + state.getCurrentChannelValue()!!, + message, + myPeerID + ) onSendMessage(actionMessage, emptyList(), state.getCurrentChannelValue()) } else { messageManager.addMessage(message) @@ -339,7 +384,7 @@ class CommandProcessor( messageManager.addMessage(systemMessage) } } - + private fun handleChannelsCommand() { val allChannels = channelManager.getJoinedChannelsList() val channelList = if (allChannels.isEmpty()) { @@ -347,7 +392,7 @@ class CommandProcessor( } else { "joined channels: ${allChannels.joinToString(", ")}" } - + val systemMessage = BitchatMessage( sender = "system", content = channelList, @@ -356,7 +401,7 @@ class CommandProcessor( ) messageManager.addMessage(systemMessage) } - + private fun handleUnknownCommand(cmd: String) { val systemMessage = BitchatMessage( sender = "system", @@ -366,7 +411,7 @@ class CommandProcessor( ) messageManager.addMessage(systemMessage) } - + // MARK: - Command Autocomplete fun updateCommandSuggestions(input: String) { @@ -375,13 +420,13 @@ class CommandProcessor( state.setCommandSuggestions(emptyList()) return } - + // Get all available commands based on context val allCommands = getAllAvailableCommands() - + // Filter commands based on input val filteredCommands = filterCommands(allCommands, input.lowercase()) - + if (filteredCommands.isNotEmpty()) { state.setCommandSuggestions(filteredCommands) state.setShowCommandSuggestions(true) @@ -390,40 +435,51 @@ class CommandProcessor( state.setCommandSuggestions(emptyList()) } } - + private fun getAllAvailableCommands(): List { // Add channel-specific commands if in a channel val channelCommands = if (state.getCurrentChannelValue() != null) { listOf( CommandSuggestion("/pass", emptyList(), "[password]", "change channel password"), CommandSuggestion("/save", emptyList(), null, "save channel messages locally"), - CommandSuggestion("/transfer", emptyList(), "", "transfer channel ownership") + CommandSuggestion( + "/transfer", + emptyList(), + "", + "transfer channel ownership" + ) ) } else { emptyList() } - + return baseCommands + channelCommands } - - private fun filterCommands(commands: List, input: String): List { + + private fun filterCommands( + commands: List, + input: String + ): List { return commands.filter { command -> // Check primary command command.command.startsWith(input) || - // Check aliases - command.aliases.any { it.startsWith(input) } + // Check aliases + command.aliases.any { it.startsWith(input) } }.sortedBy { it.command } } - + fun selectCommandSuggestion(suggestion: CommandSuggestion): String { state.setShowCommandSuggestions(false) state.setCommandSuggestions(emptyList()) return "${suggestion.command} " } - + // MARK: - Mention Autocomplete - - fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + + fun updateMentionSuggestions( + input: String, meshService: BluetoothMeshService, + pair: Pair?, ChannelID?>? = null + ) { // Check if input contains @ and we're at the end of a word or at the end of input val atIndex = input.lastIndexOf('@') if (atIndex == -1) { @@ -431,31 +487,31 @@ class CommandProcessor( state.setMentionSuggestions(emptyList()) return } - + // Get the text after the @ symbol val textAfterAt = input.substring(atIndex + 1) - + // If there's a space after @, don't show suggestions if (textAfterAt.contains(' ')) { state.setShowMentionSuggestions(false) state.setMentionSuggestions(emptyList()) return } - + // Get peer candidates based on active channel (matches iOS logic exactly) - val peerCandidates: List = if (viewModel != null) { - when (val selectedChannel = viewModel.selectedLocationChannel.value) { + val peerCandidates: List = if (pair != null) { + when (val selectedChannel = pair.second) { is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: use Bluetooth mesh peer nicknames meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } } - + is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: use geohash participants with collision-resistant suffixes - val geohashPeople = viewModel.geohashPeople.value ?: emptyList() + val geohashPeople = pair.first ?: emptyList() val currentNickname = state.getNicknameValue() - + geohashPeople.mapNotNull { person -> val displayName = person.displayName // Exclude self from suggestions @@ -471,12 +527,12 @@ class CommandProcessor( // Fallback to mesh peers if no viewModel available meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } } - + // Filter nicknames based on the text after @ val filteredNicknames = peerCandidates.filter { nickname -> nickname.startsWith(textAfterAt, ignoreCase = true) }.sorted() - + if (filteredNicknames.isNotEmpty()) { state.setMentionSuggestions(filteredNicknames) state.setShowMentionSuggestions(true) @@ -485,37 +541,43 @@ class CommandProcessor( state.setMentionSuggestions(emptyList()) } } - + fun selectMentionSuggestion(nickname: String, currentText: String): String { state.setShowMentionSuggestions(false) state.setMentionSuggestions(emptyList()) - + // Find the last @ symbol position val atIndex = currentText.lastIndexOf('@') if (atIndex == -1) { return "$currentText@$nickname " } - + // Replace the text from the @ symbol to the end with the mention val textBeforeAt = currentText.substring(0, atIndex) return "$textBeforeAt@$nickname " } - + // MARK: - Utility Functions - + private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - + private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { return meshService.getPeerNicknames()[peerID] ?: peerID } - + private fun getMyPeerID(meshService: BluetoothMeshService): String { return meshService.myPeerID } - - private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) { + + private fun sendPrivateMessageVia( + meshService: BluetoothMeshService, + content: String, + peerID: String, + recipientNickname: String, + messageId: String + ) { meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index d6a9e7467..6207e3b35 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -1,11 +1,10 @@ package com.bitchat.android.ui -import com.bitchat.android.mesh.BluetoothMeshDelegate -import com.bitchat.android.ui.NotificationTextUtils import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.Date @@ -22,10 +21,10 @@ class MeshDelegateHandler( private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, private val getMeshService: () -> BluetoothMeshService -) : BluetoothMeshDelegate { +) { - override fun didReceiveMessage(message: BitchatMessage) { - coroutineScope.launch { + fun didReceiveMessage(message: BitchatMessage) { + coroutineScope.launch(Dispatchers.Main) { // FIXED: Deduplicate messages from dual connection paths val messageKey = messageManager.generateMessageKey(message) if (messageManager.isMessageProcessed(messageKey)) { @@ -83,8 +82,8 @@ class MeshDelegateHandler( } } - override fun didUpdatePeerList(peers: List) { - coroutineScope.launch { + fun didUpdatePeerList(peers: List) { + coroutineScope.launch(Dispatchers.Main) { state.setConnectedPeers(peers) state.setIsConnected(peers.isNotEmpty()) notificationManager.showActiveUserNotification(peers) @@ -187,32 +186,31 @@ class MeshDelegateHandler( private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List) { com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) } - - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { - coroutineScope.launch { + fun didReceiveChannelLeave(channel: String, fromPeer: String) { + coroutineScope.launch(Dispatchers.Main) { channelManager.removeChannelMember(channel, fromPeer) } } - override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { - coroutineScope.launch { + fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { + coroutineScope.launch(Dispatchers.Main) { messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date())) } } - override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { - coroutineScope.launch { + fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { + coroutineScope.launch(Dispatchers.Main) { messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date())) } } - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return channelManager.decryptChannelMessage(encryptedContent, channel) } - override fun getNickname(): String? = state.getNicknameValue() + fun getNickname(): String? = state.getNicknameValue() - override fun isFavorite(peerID: String): Boolean { + fun isFavorite(peerID: String): Boolean { return privateChatManager.isFavorite(peerID) } 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 76ef3130e..25256c7ce 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 @@ -45,11 +45,10 @@ private val LightColorScheme = lightColorScheme( @Composable fun BitchatTheme( - darkTheme: Boolean? = null, + darkTheme: Boolean? = null, themePref: ThemePreference, content: @Composable () -> Unit ) { // App-level override from ThemePreferenceManager - val themePref by ThemePreferenceManager.themeFlow.collectAsState(initial = ThemePreference.System) val shouldUseDark = when (darkTheme) { true -> true false -> false diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt b/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt index 7a040d9c2..85bbe3e8a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt @@ -1,42 +1,83 @@ package com.bitchat.android.ui.theme import android.content.Context -import kotlinx.coroutines.flow.MutableStateFlow +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.bitchat.android.model.logWarn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + + +private const val THEME = "SystemTheme" /** * App theme preference: System default, Light, or Dark. */ +@Serializable +@SerialName(THEME) enum class ThemePreference { + @SerialName("$THEME.default") System, + + @SerialName("$THEME.light") Light, + + @SerialName("$THEME.dark") Dark; - val isSystem : Boolean get() = this == System - val isLight : Boolean get() = this == Light - val isDark : Boolean get() = this == Dark + val isSystem: Boolean get() = this == System + val isLight: Boolean get() = this == Light + val isDark: Boolean get() = this == Dark } -/** - * Simple SharedPreferences-backed manager for theme preference with a StateFlow. - * Avoids adding DataStore dependency for now. - */ -object ThemePreferenceManager { - private const val PREFS_NAME = "bitchat_settings" - private const val KEY_THEME = "theme_preference" +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") - private val _themeFlow = MutableStateFlow(ThemePreference.System) - val themeFlow: StateFlow = _themeFlow +interface ThemePreferenceRepo { + val theme: StateFlow + suspend fun updateTheme(t: ThemePreference) +} - fun init(context: Context) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val saved = prefs.getString(KEY_THEME, ThemePreference.System.name) - _themeFlow.value = runCatching { ThemePreference.valueOf(saved!!) }.getOrDefault(ThemePreference.System) +class ThemePrefRepoImpl(private val context: Context, scope: CoroutineScope) : ThemePreferenceRepo { + companion object { + private val KEY_THEME = stringPreferencesKey("theme") + private const val PREFS_NAME = "bitchat_settings" } - fun set(context: Context, preference: ThemePreference) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putString(KEY_THEME, preference.name).apply() - _themeFlow.value = preference + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + override val theme = context.dataStore.data.map { preferences -> + (preferences[KEY_THEME] ?: prefs.getString("theme_preference", ThemePreference.System.name)) + ?.let { + try { + Json.decodeFromString(it) + } catch (_: Exception) { + ThemePreference.System + } + } ?: ThemePreference.System + }.stateIn( + scope = scope, started = SharingStarted.Eagerly, initialValue = ThemePreference.System + ) + + override suspend fun updateTheme(t: ThemePreference) = withContext(Dispatchers.IO) { + try { + context.dataStore.edit { preferences -> + preferences[KEY_THEME] = Json.encodeToString(ThemePreference.serializer(), t) + } + } catch (e: Exception) { + logWarn("Failed to update theme: ${e.printStackTrace()}") + return@withContext + } } } + + diff --git a/build.gradle.kts b/build.gradle.kts index 440826843..e90652532 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.serialization) apply false } tasks.whenTaskAdded { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6dda224a..ee189df50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,8 @@ tink-android = "1.10.0" # JSON gson = "2.13.1" +kotlinxSerializationJson = "1.6.0" + # Coroutines kotlinx-coroutines = "1.10.2" @@ -45,6 +47,9 @@ gms-location = "21.3.0" # Security security-crypto = "1.1.0-beta01" +datastorePreferences = "1.1.7" + + # Testing junit = "4.13.2" androidx-test-ext = "1.2.1" @@ -117,12 +122,22 @@ mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"} kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } + + +# Data store +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-datastore-preferences-rxjava2 = { module = "androidx.datastore:datastore-preferences-rxjava2", version.ref = "datastorePreferences" } +androidx-datastore-preferences-rxjava3 = { module = "androidx.datastore:datastore-preferences-rxjava3", version.ref = "datastorePreferences" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "kotlin-parcelize" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +# Serialization +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [bundles] compose = [