diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a749e861..83f837c73 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -257,13 +257,14 @@ android { val total = project.findProperty("testShardTotal")?.toString()?.toIntOrNull() if (shard != null && total != null && total > 1) { val testSourceDir = project.file("src/test/java") - val allTestClasses = project.fileTree(testSourceDir) { - include("**/*Test.kt") - }.files.map { f -> - f.relativeTo(testSourceDir) - .path.replace(File.separatorChar, '.') - .removeSuffix(".kt") - }.sorted() + val allTestClasses = + project.fileTree(testSourceDir) { + include("**/*Test.kt") + }.files.map { f -> + f.relativeTo(testSourceDir) + .path.replace(File.separatorChar, '.') + .removeSuffix(".kt") + }.sorted() val shardClasses = allTestClasses.filterIndexed { i, _ -> i % total == shard } shardClasses.forEach { cls -> it.filter.includeTestsMatching(cls) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e3cedf311..73e9bc11d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ + @@ -68,12 +69,16 @@ + + + + @@ -157,6 +162,19 @@ android:resource="@xml/usb_device_filter" /> + + + + + + + @@ -180,6 +198,16 @@ + + + + + + + { + val encodedLabel = Uri.encode(navigation.label) + val trailHash = navigation.senderHash?.let { Uri.encode(it) } ?: "" + navController.navigate( + "map_focus?lat=${navigation.latitude}&lon=${navigation.longitude}" + + "&label=$encodedLabel&type=SOS&height=${Float.NaN}" + + "&reachableOn=&port=-1&frequency=-1&bandwidth=-1" + + "&sf=-1&cr=-1&modulation=&status=&lastHeard=-1&hops=-1" + + "&sosTrailHash=$trailHash", + ) + Log.d("ColumbaNavigation", "Navigated to map for SOS: ${navigation.label}") + } is PendingNavigation.NomadNetBrowser -> { val encoded = Uri.encode(navigation.path) navController.navigate("nomadnet_browser/${navigation.nodeHash}?path=$encoded") @@ -1012,1103 +1050,1102 @@ fun ColumbaNavigation( @Suppress("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( bottomBar = { - if (shouldShowBottomNav) { - NavigationBar { - screens.forEachIndexed { index, screen -> - NavigationBarItem( - icon = { Icon(screen.icon, contentDescription = null) }, - label = { Text(screen.title) }, - selected = selectedTab == index, - onClick = { - selectedTab = index - navController.navigate(screen.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + Column { + if (shouldShowBottomNav) { + NavigationBar { + screens.forEachIndexed { index, screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = null) }, + label = { Text(screen.title) }, + selected = selectedTab == index, + onClick = { + selectedTab = index + navController.navigate(screen.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - }, - ) + }, + ) + } } } } }, + floatingActionButton = {}, ) { _ -> // Inner screens have their own Scaffolds with TopAppBars that handle content padding - Column( - modifier = Modifier.fillMaxSize(), - ) { - OfflineModeBanner( - networkStatus = settingsState.networkStatus, - isRestarting = settingsState.isRestarting, - onReconnect = { settingsViewModel.restartService() }, - ) - NavHost( - modifier = Modifier.weight(1f), - navController = navController, - startDestination = startDestination, - enterTransition = { fadeIn(tween(150)) }, - exitTransition = { fadeOut(tween(75)) }, - popEnterTransition = { fadeIn(tween(150)) }, - popExitTransition = { fadeOut(tween(75)) }, + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), ) { - composable(Screen.Welcome.route) { - OnboardingPagerScreen( - onOnboardingComplete = { navigateToRNodeWizard -> - navController.navigate(Screen.Chats.route) { - popUpTo(Screen.Welcome.route) { inclusive = true } - } - // Navigate to RNode wizard if LoRa Radio was selected - if (navigateToRNodeWizard) { - navController.navigate("rnode_wizard") - } - }, - onImportData = { - navController.navigate("migration") - }, - ) - } + OfflineModeBanner( + networkStatus = settingsState.networkStatus, + isRestarting = settingsState.isRestarting, + onReconnect = { settingsViewModel.restartService() }, + ) + NavHost( + modifier = Modifier.weight(1f), + navController = navController, + startDestination = startDestination, + enterTransition = { fadeIn(tween(150)) }, + exitTransition = { fadeOut(tween(75)) }, + popEnterTransition = { fadeIn(tween(150)) }, + popExitTransition = { fadeOut(tween(75)) }, + ) { + composable(Screen.Welcome.route) { + OnboardingPagerScreen( + onOnboardingComplete = { navigateToRNodeWizard -> + navController.navigate(Screen.Chats.route) { + popUpTo(Screen.Welcome.route) { inclusive = true } + } + // Navigate to RNode wizard if LoRa Radio was selected + if (navigateToRNodeWizard) { + navController.navigate("rnode_wizard") + } + }, + onImportData = { + navController.navigate("migration") + }, + ) + } - composable(Screen.Chats.route) { - DoubleBackToExitHandler(Screen.Chats.route) - ChatsScreen( - onChatClick = { destinationHash, peerName -> - val encodedHash = Uri.encode(destinationHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - onViewPeerDetails = { peerHash -> - val encodedHash = Uri.encode(peerHash) - navController.navigate("announce_detail/$encodedHash") - }, - onLocateOnMap = { peerHash -> - mapViewModel.focusOnContact(peerHash) - navController.navigate(Screen.Map.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + composable(Screen.Chats.route) { + DoubleBackToExitHandler(Screen.Chats.route) + ChatsScreen( + onChatClick = { destinationHash, peerName -> + val encodedHash = Uri.encode(destinationHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + onViewPeerDetails = { peerHash -> + val encodedHash = Uri.encode(peerHash) + navController.navigate("announce_detail/$encodedHash") + }, + onLocateOnMap = { peerHash -> + mapViewModel.focusOnContact(peerHash) + navController.navigate(Screen.Map.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - }, - onNavigateToQrScanner = { - navController.navigate("qr_scanner") - }, - settingsViewModel = settingsViewModel, - ) - } + }, + onNavigateToQrScanner = { + navController.navigate("qr_scanner") + }, + settingsViewModel = settingsViewModel, + ) + } - composable( - route = "${Screen.Announces.route}?filterType={filterType}", - arguments = - listOf( - navArgument("filterType") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ), - ) { backStackEntry -> - val filterType = backStackEntry.arguments?.getString("filterType") - AnnounceStreamScreen( - initialFilterType = filterType, - onPeerClick = { destinationHash, _ -> - val encodedHash = Uri.encode(destinationHash) - navController.navigate("announce_detail/$encodedHash") - }, - onStartChat = { destinationHash, peerName -> - val encodedHash = Uri.encode(destinationHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - ) - } + composable( + route = "${Screen.Announces.route}?filterType={filterType}", + arguments = + listOf( + navArgument("filterType") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { backStackEntry -> + val filterType = backStackEntry.arguments?.getString("filterType") + AnnounceStreamScreen( + initialFilterType = filterType, + onPeerClick = { destinationHash, _ -> + val encodedHash = Uri.encode(destinationHash) + navController.navigate("announce_detail/$encodedHash") + }, + onStartChat = { destinationHash, peerName -> + val encodedHash = Uri.encode(destinationHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + ) + } - composable(Screen.Contacts.route) { - DoubleBackToExitHandler(Screen.Contacts.route) - val contactsViewModel: ContactsViewModel = hiltViewModel() - ContactsScreen( - onContactClick = { destinationHash, displayName -> - val encodedHash = Uri.encode(destinationHash) - navController.navigate("announce_detail/$encodedHash") - }, - onViewPeerDetails = { destinationHash -> - val encodedHash = Uri.encode(destinationHash) - navController.navigate("announce_detail/$encodedHash") - }, - onLocateOnMap = { peerHash -> - mapViewModel.focusOnContact(peerHash) - navController.navigate(Screen.Map.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + composable(Screen.Contacts.route) { + DoubleBackToExitHandler(Screen.Contacts.route) + val contactsViewModel: ContactsViewModel = hiltViewModel() + ContactsScreen( + onContactClick = { destinationHash, displayName -> + val encodedHash = Uri.encode(destinationHash) + navController.navigate("announce_detail/$encodedHash") + }, + onViewPeerDetails = { destinationHash -> + val encodedHash = Uri.encode(destinationHash) + navController.navigate("announce_detail/$encodedHash") + }, + onLocateOnMap = { peerHash -> + mapViewModel.focusOnContact(peerHash) + navController.navigate(Screen.Map.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - }, - onNavigateToQrScanner = { - navController.navigate("qr_scanner") - }, - pendingDeepLinkContact = pendingContactAdd, - onDeepLinkContactProcessed = { - pendingContactAdd = null - }, - onNavigateToConversation = { destinationHash -> - val contacts = contactsViewModel.contacts.value - val contact = contacts.find { it.destinationHash == destinationHash } - val peerName = contact?.displayName ?: destinationHash.take(16) - val encodedHash = Uri.encode(destinationHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - onStartChat = { destinationHash, peerName -> - val encodedHash = Uri.encode(destinationHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - ) - } + }, + onNavigateToQrScanner = { + navController.navigate("qr_scanner") + }, + pendingDeepLinkContact = pendingContactAdd, + onDeepLinkContactProcessed = { + pendingContactAdd = null + }, + onNavigateToConversation = { destinationHash -> + val contacts = contactsViewModel.contacts.value + val contact = contacts.find { it.destinationHash == destinationHash } + val peerName = contact?.displayName ?: destinationHash.take(16) + val encodedHash = Uri.encode(destinationHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + onStartChat = { destinationHash, peerName -> + val encodedHash = Uri.encode(destinationHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + ) + } - composable(Screen.Map.route) { - DoubleBackToExitHandler(Screen.Map.route) - MapScreen( - viewModel = mapViewModel, - onNavigateToConversation = { destinationHash -> - // Navigate to messaging screen with the contact - val encodedHash = Uri.encode(destinationHash) - // Use a placeholder name - the messaging screen will fetch the actual name - navController.navigate("messaging/$encodedHash/Contact") - }, - onNavigateToOfflineMaps = { - navController.navigate("offline_maps") - }, - onNavigateToRNodeWizardWithParams = { frequency, bandwidth, sf, cr -> - navController.navigate( - "rnode_wizard?loraFrequency=${frequency ?: -1L}" + - "&loraBandwidth=${bandwidth ?: -1}" + - "&loraSf=${sf ?: -1}" + - "&loraCr=${cr ?: -1}", + composable(Screen.Map.route) { + DoubleBackToExitHandler(Screen.Map.route) + MapScreen( + viewModel = mapViewModel, + onNavigateToConversation = { destinationHash -> + // Navigate to messaging screen with the contact + val encodedHash = Uri.encode(destinationHash) + // Use a placeholder name - the messaging screen will fetch the actual name + navController.navigate("messaging/$encodedHash/Contact") + }, + onNavigateToOfflineMaps = { + navController.navigate("offline_maps") + }, + onNavigateToRNodeWizardWithParams = { frequency, bandwidth, sf, cr -> + navController.navigate( + "rnode_wizard?loraFrequency=${frequency ?: -1L}" + + "&loraBandwidth=${bandwidth ?: -1}" + + "&loraSf=${sf ?: -1}" + + "&loraCr=${cr ?: -1}", + ) + }, + permissionSheetDismissed = mapPermissionSheetDismissed, + onPermissionSheetDismissed = { mapPermissionSheetDismissed = true }, + permissionCardDismissed = mapPermissionCardDismissed, + onPermissionCardDismissed = { mapPermissionCardDismissed = true }, + ) + } + + // Map with focus location (for discovered interfaces) + composable( + route = + "map_focus?lat={lat}&lon={lon}&label={label}&type={type}&height={height}" + + "&reachableOn={reachableOn}&port={port}&frequency={frequency}&bandwidth={bandwidth}" + + "&sf={sf}&cr={cr}&modulation={modulation}&status={status}&lastHeard={lastHeard}&hops={hops}" + + "&sosTrailHash={sosTrailHash}", + arguments = + listOf( + navArgument("lat") { + type = NavType.FloatType + defaultValue = 0f + }, + navArgument("lon") { + type = NavType.FloatType + defaultValue = 0f + }, + navArgument("label") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("type") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("height") { + type = NavType.FloatType + defaultValue = Float.NaN + }, + navArgument("reachableOn") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("port") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("frequency") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("bandwidth") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("sf") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("cr") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("modulation") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("status") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("lastHeard") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("hops") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("sosTrailHash") { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { backStackEntry -> + val lat = backStackEntry.arguments?.getFloat("lat")?.toDouble() + val lon = backStackEntry.arguments?.getFloat("lon")?.toDouble() + val label = backStackEntry.arguments?.getString("label") + val type = backStackEntry.arguments?.getString("type") + val height = backStackEntry.arguments?.getFloat("height")?.toDouble() + val reachableOn = backStackEntry.arguments?.getString("reachableOn") + val port = backStackEntry.arguments?.getInt("port") + val frequency = backStackEntry.arguments?.getLong("frequency") + val bandwidth = backStackEntry.arguments?.getInt("bandwidth") + val sf = backStackEntry.arguments?.getInt("sf") + val cr = backStackEntry.arguments?.getInt("cr") + val modulation = backStackEntry.arguments?.getString("modulation") + val status = backStackEntry.arguments?.getString("status") + val lastHeard = backStackEntry.arguments?.getLong("lastHeard") + val hops = backStackEntry.arguments?.getInt("hops") + val sosTrailHash = backStackEntry.arguments?.getString("sosTrailHash")?.ifEmpty { null } + + // Build FocusInterfaceDetails if we have valid lat/lon + val focusDetails = + buildFocusInterfaceDetails( + lat = lat, + lon = lon, + label = label, + type = type, + height = height, + reachableOn = reachableOn, + port = port, + frequency = frequency, + bandwidth = bandwidth, + sf = sf, + cr = cr, + modulation = modulation, + status = status, + lastHeard = lastHeard, + hops = hops, ) - }, - permissionSheetDismissed = mapPermissionSheetDismissed, - onPermissionSheetDismissed = { mapPermissionSheetDismissed = true }, - permissionCardDismissed = mapPermissionCardDismissed, - onPermissionCardDismissed = { mapPermissionCardDismissed = true }, - ) - } - // Map with focus location (for discovered interfaces) - composable( - route = - "map_focus?lat={lat}&lon={lon}&label={label}&type={type}&height={height}" + - "&reachableOn={reachableOn}&port={port}&frequency={frequency}&bandwidth={bandwidth}" + - "&sf={sf}&cr={cr}&modulation={modulation}&status={status}&lastHeard={lastHeard}&hops={hops}", - arguments = - listOf( - navArgument("lat") { - type = NavType.FloatType - defaultValue = 0f - }, - navArgument("lon") { - type = NavType.FloatType - defaultValue = 0f - }, - navArgument("label") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("type") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("height") { - type = NavType.FloatType - defaultValue = Float.NaN - }, - navArgument("reachableOn") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("port") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("frequency") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("bandwidth") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("sf") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("cr") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("modulation") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("status") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("lastHeard") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("hops") { - type = NavType.IntType - defaultValue = -1 - }, - ), - ) { backStackEntry -> - val lat = backStackEntry.arguments?.getFloat("lat")?.toDouble() - val lon = backStackEntry.arguments?.getFloat("lon")?.toDouble() - val label = backStackEntry.arguments?.getString("label") - val type = backStackEntry.arguments?.getString("type") - val height = backStackEntry.arguments?.getFloat("height")?.toDouble() - val reachableOn = backStackEntry.arguments?.getString("reachableOn") - val port = backStackEntry.arguments?.getInt("port") - val frequency = backStackEntry.arguments?.getLong("frequency") - val bandwidth = backStackEntry.arguments?.getInt("bandwidth") - val sf = backStackEntry.arguments?.getInt("sf") - val cr = backStackEntry.arguments?.getInt("cr") - val modulation = backStackEntry.arguments?.getString("modulation") - val status = backStackEntry.arguments?.getString("status") - val lastHeard = backStackEntry.arguments?.getLong("lastHeard") - val hops = backStackEntry.arguments?.getInt("hops") - - // Build FocusInterfaceDetails if we have valid lat/lon - val focusDetails = - buildFocusInterfaceDetails( - lat = lat, - lon = lon, - label = label, - type = type, - height = height, - reachableOn = reachableOn, - port = port, - frequency = frequency, - bandwidth = bandwidth, - sf = sf, - cr = cr, - modulation = modulation, - status = status, - lastHeard = lastHeard, - hops = hops, + MapScreen( + viewModel = mapViewModel, + onNavigateToConversation = { destinationHash -> + val encodedHash = Uri.encode(destinationHash) + navController.navigate("messaging/$encodedHash/Contact") + }, + onNavigateToOfflineMaps = { + navController.navigate("offline_maps") + }, + onNavigateToRNodeWizardWithParams = { frequency, bandwidth, sf, cr -> + navController.navigate( + "rnode_wizard?loraFrequency=${frequency ?: -1L}" + + "&loraBandwidth=${bandwidth ?: -1}" + + "&loraSf=${sf ?: -1}" + + "&loraCr=${cr ?: -1}", + ) + }, + focusLatitude = if (lat != 0.0) lat else null, + focusLongitude = if (lon != 0.0) lon else null, + focusLabel = label?.ifEmpty { null }, + focusInterfaceDetails = focusDetails, + sosTrailSenderHash = sosTrailHash, + permissionSheetDismissed = mapPermissionSheetDismissed, + onPermissionSheetDismissed = { mapPermissionSheetDismissed = true }, + permissionCardDismissed = mapPermissionCardDismissed, + onPermissionCardDismissed = { mapPermissionCardDismissed = true }, ) + } - MapScreen( - viewModel = mapViewModel, - onNavigateToConversation = { destinationHash -> - val encodedHash = Uri.encode(destinationHash) - navController.navigate("messaging/$encodedHash/Contact") - }, - onNavigateToOfflineMaps = { - navController.navigate("offline_maps") - }, - onNavigateToRNodeWizardWithParams = { frequency, bandwidth, sf, cr -> - navController.navigate( - "rnode_wizard?loraFrequency=${frequency ?: -1L}" + - "&loraBandwidth=${bandwidth ?: -1}" + - "&loraSf=${sf ?: -1}" + - "&loraCr=${cr ?: -1}", - ) - }, - focusLatitude = if (lat != 0.0) lat else null, - focusLongitude = if (lon != 0.0) lon else null, - focusLabel = label?.ifEmpty { null }, - focusInterfaceDetails = focusDetails, - permissionSheetDismissed = mapPermissionSheetDismissed, - onPermissionSheetDismissed = { mapPermissionSheetDismissed = true }, - permissionCardDismissed = mapPermissionCardDismissed, - onPermissionCardDismissed = { mapPermissionCardDismissed = true }, - ) - } + composable(Screen.Identity.route) { + IdentityScreen( + onBackClick = { navController.popBackStack() }, + settingsViewModel = settingsViewModel, + onNavigateToBleStatus = { + navController.navigate("ble_connection_status") + }, + onNavigateToInterfaceStats = { interfaceId -> + navController.navigate("interface_stats/$interfaceId") + }, + onNavigateToInterfaceManagement = { + navController.navigate("interface_management") + }, + ) + } - composable(Screen.Identity.route) { - IdentityScreen( - onBackClick = { navController.popBackStack() }, - settingsViewModel = settingsViewModel, - onNavigateToBleStatus = { - navController.navigate("ble_connection_status") - }, - onNavigateToInterfaceStats = { interfaceId -> - navController.navigate("interface_stats/$interfaceId") - }, - onNavigateToInterfaceManagement = { - navController.navigate("interface_management") - }, - ) - } + composable(Screen.Settings.route) { + DoubleBackToExitHandler(Screen.Settings.route) + SettingsScreen( + viewModel = settingsViewModel, + crashReportManager = crashReportManager, + onNavigateToInterfaces = { + navController.navigate("interface_management") + }, + onNavigateToIdentity = { + navController.navigate("my_identity") + }, + onNavigateToNetworkStatus = { + navController.navigate("network_status") + }, + onNavigateToIdentityManager = { + navController.navigate("identity_manager") + }, + onNavigateToNotifications = { + navController.navigate("notification_settings") + }, + onNavigateToCustomThemes = { + navController.navigate("theme_management") + }, + onNavigateToMigration = { + navController.navigate("migration") + }, + onNavigateToApkSharing = { + navController.navigate("apk_sharing") + }, + onNavigateToAnnounces = { filterType -> + selectedTab = 1 // Announces tab + val route = + if (filterType != null) { + "${Screen.Announces.route}?filterType=$filterType" + } else { + Screen.Announces.route + } + navController.navigate(route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = false // Don't restore state so filter applies + } + }, + onNavigateToFlasher = { + navController.navigate("rnode_flasher") + }, + ) + } - composable(Screen.Settings.route) { - DoubleBackToExitHandler(Screen.Settings.route) - SettingsScreen( - viewModel = settingsViewModel, - crashReportManager = crashReportManager, - onNavigateToInterfaces = { - navController.navigate("interface_management") - }, - onNavigateToIdentity = { - navController.navigate("my_identity") - }, - onNavigateToNetworkStatus = { - navController.navigate("network_status") - }, - onNavigateToIdentityManager = { - navController.navigate("identity_manager") - }, - onNavigateToNotifications = { - navController.navigate("notification_settings") - }, - onNavigateToCustomThemes = { - navController.navigate("theme_management") - }, - onNavigateToMigration = { - navController.navigate("migration") - }, - onNavigateToApkSharing = { - navController.navigate("apk_sharing") - }, - onNavigateToAnnounces = { filterType -> - selectedTab = 1 // Announces tab - val route = - if (filterType != null) { - "${Screen.Announces.route}?filterType=$filterType" - } else { - Screen.Announces.route + composable( + route = + "usb_device_action" + + "?usbDeviceId={usbDeviceId}" + + "&usbVendorId={usbVendorId}" + + "&usbProductId={usbProductId}" + + "&usbDeviceName={usbDeviceName}", + arguments = + listOf( + navArgument("usbDeviceId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbVendorId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbProductId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbDeviceName") { + type = NavType.StringType + defaultValue = "" + nullable = true + }, + ), + ) { backStackEntry -> + val usbDeviceId = backStackEntry.arguments?.getInt("usbDeviceId") ?: -1 + val usbVendorId = backStackEntry.arguments?.getInt("usbVendorId") ?: -1 + val usbProductId = backStackEntry.arguments?.getInt("usbProductId") ?: -1 + val usbDeviceName = backStackEntry.arguments?.getString("usbDeviceName") ?: "USB Device" + com.lxmf.messenger.ui.screens.UsbDeviceActionScreen( + deviceName = usbDeviceName, + onNavigateBack = { navController.popBackStack() }, + onFlashFirmware = { + val route = + "rnode_flasher" + + "?usbDeviceId=$usbDeviceId" + + "&usbVendorId=$usbVendorId" + + "&usbProductId=$usbProductId" + + "&usbDeviceName=${Uri.encode(usbDeviceName)}" + navController.navigate(route) { + popUpTo("usb_device_action") { inclusive = true } } - navController.navigate(route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + }, + onConfigureRNode = { + val route = + "rnode_wizard?connectionType=usb" + + "&usbDeviceId=$usbDeviceId" + + "&usbVendorId=$usbVendorId" + + "&usbProductId=$usbProductId" + + "&usbDeviceName=${Uri.encode(usbDeviceName)}" + navController.navigate(route) { + popUpTo("usb_device_action") { inclusive = true } } - launchSingleTop = true - restoreState = false // Don't restore state so filter applies - } - }, - onNavigateToFlasher = { - navController.navigate("rnode_flasher") - }, - onNavigateToBlockedUsers = { - navController.navigate("blocked_users") - }, - ) - } - - composable( - route = - "usb_device_action" + - "?usbDeviceId={usbDeviceId}" + - "&usbVendorId={usbVendorId}" + - "&usbProductId={usbProductId}" + - "&usbDeviceName={usbDeviceName}", - arguments = - listOf( - navArgument("usbDeviceId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbVendorId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbProductId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbDeviceName") { - type = NavType.StringType - defaultValue = "" - nullable = true - }, - ), - ) { backStackEntry -> - val usbDeviceId = backStackEntry.arguments?.getInt("usbDeviceId") ?: -1 - val usbVendorId = backStackEntry.arguments?.getInt("usbVendorId") ?: -1 - val usbProductId = backStackEntry.arguments?.getInt("usbProductId") ?: -1 - val usbDeviceName = backStackEntry.arguments?.getString("usbDeviceName") ?: "USB Device" - - // State for disable transport operation - val context = androidx.compose.ui.platform.LocalContext.current - val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() - var isDisablingTransport by androidx.compose.runtime.remember { - androidx.compose.runtime.mutableStateOf(false) - } - var disableTransportResult: Boolean? by androidx.compose.runtime.remember { - androidx.compose.runtime.mutableStateOf(null) + }, + onConfigureTransport = { + // TODO: navigate to transport configuration + }, + onDisableTransport = { + // TODO: handle disable transport + }, + ) } - com.lxmf.messenger.ui.screens.UsbDeviceActionScreen( - deviceName = usbDeviceName, - onNavigateBack = { navController.popBackStack() }, - onFlashFirmware = { - val route = - "rnode_flasher" + - "?usbDeviceId=$usbDeviceId" + - "&usbVendorId=$usbVendorId" + - "&usbProductId=$usbProductId" + - "&usbDeviceName=${Uri.encode(usbDeviceName)}" - navController.navigate(route) { - popUpTo("usb_device_action") { inclusive = true } - } - }, - onConfigureRNode = { - val route = - "rnode_wizard?connectionType=usb" + - "&usbDeviceId=$usbDeviceId" + - "&usbVendorId=$usbVendorId" + - "&usbProductId=$usbProductId" + - "&usbDeviceName=${Uri.encode(usbDeviceName)}" - navController.navigate(route) { - popUpTo("usb_device_action") { inclusive = true } - } - }, - onConfigureTransport = { - val route = - "rnode_wizard?connectionType=usb" + - "&transportMode=true" + - "&usbDeviceId=$usbDeviceId" + - "&usbVendorId=$usbVendorId" + - "&usbProductId=$usbProductId" + - "&usbDeviceName=${Uri.encode(usbDeviceName)}" - navController.navigate(route) { - popUpTo("usb_device_action") { inclusive = true } - } - }, - onDisableTransport = { - isDisablingTransport = true - coroutineScope.launch { - val flasher = - com.lxmf.messenger.reticulum.flasher - .RNodeFlasher(context) - val success = flasher.tncModeController.disableTncMode(usbDeviceId) - isDisablingTransport = false - disableTransportResult = success - } - }, - isDisablingTransport = isDisablingTransport, - disableTransportResult = disableTransportResult, - onDismissDisableResult = { disableTransportResult = null }, - ) - } - - composable( - route = - "rnode_flasher?skipDetection={skipDetection}&tncConfigOnly={tncConfigOnly}" + - "&usbDeviceId={usbDeviceId}" + - "&usbVendorId={usbVendorId}&usbProductId={usbProductId}&usbDeviceName={usbDeviceName}", - arguments = - listOf( - navArgument("skipDetection") { - type = NavType.BoolType - defaultValue = false - }, - navArgument("tncConfigOnly") { - type = NavType.BoolType - defaultValue = false - }, - navArgument("usbDeviceId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbVendorId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbProductId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbDeviceName") { - type = NavType.StringType - defaultValue = "" - nullable = true - }, - ), - ) { backStackEntry -> - val skipDetection = backStackEntry.arguments?.getBoolean("skipDetection") ?: false - val tncConfigOnly = backStackEntry.arguments?.getBoolean("tncConfigOnly") ?: false - val usbDeviceId = backStackEntry.arguments?.getInt("usbDeviceId") ?: -1 - RNodeFlasherScreen( - onNavigateBack = { navController.popBackStack() }, - onComplete = { navController.popBackStack() }, - onNavigateToRNodeWizard = { - navController.navigate("rnode_wizard") - }, - skipDetection = skipDetection, - tncConfigOnly = tncConfigOnly, - preselectedUsbDeviceId = if (usbDeviceId > 0) usbDeviceId else null, - ) - } - - composable("interface_management") { - InterfaceManagementScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToRNodeWizard = { interfaceId -> - if (interfaceId != null) { - navController.navigate("rnode_wizard?interfaceId=$interfaceId") - } else { + composable( + route = + "rnode_flasher?skipDetection={skipDetection}&usbDeviceId={usbDeviceId}" + + "&usbVendorId={usbVendorId}&usbProductId={usbProductId}&usbDeviceName={usbDeviceName}", + arguments = + listOf( + navArgument("skipDetection") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("usbDeviceId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbVendorId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbProductId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbDeviceName") { + type = NavType.StringType + defaultValue = "" + nullable = true + }, + ), + ) { backStackEntry -> + val skipDetection = backStackEntry.arguments?.getBoolean("skipDetection") ?: false + val usbDeviceId = backStackEntry.arguments?.getInt("usbDeviceId") ?: -1 + RNodeFlasherScreen( + onNavigateBack = { navController.popBackStack() }, + onComplete = { navController.popBackStack() }, + onNavigateToRNodeWizard = { navController.navigate("rnode_wizard") - } - }, - onNavigateToTcpClientWizard = { - navController.navigate("tcp_client_wizard") - }, - onNavigateToInterfaceStats = { interfaceId -> - navController.navigate("interface_stats/$interfaceId") - }, - onNavigateToDiscoveredInterfaces = { - navController.navigate("discovered_interfaces") - }, - ) - } + }, + skipDetection = skipDetection, + preselectedUsbDeviceId = if (usbDeviceId > 0) usbDeviceId else null, + ) + } - composable("discovered_interfaces") { - DiscoveredInterfacesScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToTcpClientWizard = { host, port, name -> - val encodedHost = Uri.encode(host) - val encodedName = Uri.encode(name) - navController.navigate("tcp_client_wizard?host=$encodedHost&port=$port&name=$encodedName") - }, - onNavigateToMapWithInterface = { details -> - val encodedLabel = Uri.encode(details.name) - val encodedType = Uri.encode(details.type) - val encodedReachableOn = Uri.encode(details.reachableOn ?: "") - val encodedModulation = Uri.encode(details.modulation ?: "") - val encodedStatus = Uri.encode(details.status ?: "") - navController.navigate( - "map_focus?lat=${details.latitude}&lon=${details.longitude}" + - "&label=$encodedLabel&type=$encodedType" + - "&height=${details.height ?: Float.NaN}" + - "&reachableOn=$encodedReachableOn&port=${details.port ?: -1}" + - "&frequency=${details.frequency ?: -1L}&bandwidth=${details.bandwidth ?: -1}" + - "&sf=${details.spreadingFactor ?: -1}&cr=${details.codingRate ?: -1}" + - "&modulation=$encodedModulation&status=$encodedStatus" + - "&lastHeard=${details.lastHeard ?: -1L}&hops=${details.hops ?: -1}", - ) - }, - onNavigateToRNodeWizardWithParams = { frequency, bandwidth, sf, cr -> - navController.navigate( - "rnode_wizard?loraFrequency=${frequency ?: -1L}" + - "&loraBandwidth=${bandwidth ?: -1}" + - "&loraSf=${sf ?: -1}" + - "&loraCr=${cr ?: -1}", - ) - }, - ) - } + composable("interface_management") { + InterfaceManagementScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToRNodeWizard = { interfaceId -> + if (interfaceId != null) { + navController.navigate("rnode_wizard?interfaceId=$interfaceId") + } else { + navController.navigate("rnode_wizard") + } + }, + onNavigateToTcpClientWizard = { + navController.navigate("tcp_client_wizard") + }, + onNavigateToInterfaceStats = { interfaceId -> + navController.navigate("interface_stats/$interfaceId") + }, + onNavigateToDiscoveredInterfaces = { + navController.navigate("discovered_interfaces") + }, + ) + } - composable( - route = "tcp_client_wizard?interfaceId={interfaceId}&host={host}&port={port}&name={name}", - arguments = - listOf( - navArgument("interfaceId") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("host") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("port") { - type = NavType.IntType - defaultValue = 0 - }, - navArgument("name") { - type = NavType.StringType - defaultValue = "" - }, - ), - ) { backStackEntry -> - val interfaceId = backStackEntry.arguments?.getLong("interfaceId") ?: -1L - val host = backStackEntry.arguments?.getString("host") ?: "" - val port = backStackEntry.arguments?.getInt("port") ?: 0 - val name = backStackEntry.arguments?.getString("name") ?: "" - TcpClientWizardScreen( - onNavigateBack = { navController.popBackStack() }, - onComplete = { - navController.navigate("interface_management") { - popUpTo("interface_management") { inclusive = true } - } - }, - interfaceId = if (interfaceId > 0) interfaceId else null, - initialHost = host.ifEmpty { null }, - initialPort = if (port > 0) port else null, - initialName = name.ifEmpty { null }, - ) - } + composable("discovered_interfaces") { + DiscoveredInterfacesScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToTcpClientWizard = { host, port, name -> + val encodedHost = Uri.encode(host) + val encodedName = Uri.encode(name) + navController.navigate("tcp_client_wizard?host=$encodedHost&port=$port&name=$encodedName") + }, + onNavigateToMapWithInterface = { details -> + val encodedLabel = Uri.encode(details.name) + val encodedType = Uri.encode(details.type) + val encodedReachableOn = Uri.encode(details.reachableOn ?: "") + val encodedModulation = Uri.encode(details.modulation ?: "") + val encodedStatus = Uri.encode(details.status ?: "") + navController.navigate( + "map_focus?lat=${details.latitude}&lon=${details.longitude}" + + "&label=$encodedLabel&type=$encodedType" + + "&height=${details.height ?: Float.NaN}" + + "&reachableOn=$encodedReachableOn&port=${details.port ?: -1}" + + "&frequency=${details.frequency ?: -1L}&bandwidth=${details.bandwidth ?: -1}" + + "&sf=${details.spreadingFactor ?: -1}&cr=${details.codingRate ?: -1}" + + "&modulation=$encodedModulation&status=$encodedStatus" + + "&lastHeard=${details.lastHeard ?: -1L}&hops=${details.hops ?: -1}", + ) + }, + onNavigateToRNodeWizardWithParams = { frequency, bandwidth, sf, cr -> + navController.navigate( + "rnode_wizard?loraFrequency=${frequency ?: -1L}" + + "&loraBandwidth=${bandwidth ?: -1}" + + "&loraSf=${sf ?: -1}" + + "&loraCr=${cr ?: -1}", + ) + }, + ) + } - composable( - route = - "rnode_wizard?interfaceId={interfaceId}" + - "&connectionType={connectionType}" + - "&transportMode={transportMode}" + - "&usbDeviceId={usbDeviceId}" + - "&usbVendorId={usbVendorId}" + - "&usbProductId={usbProductId}" + - "&usbDeviceName={usbDeviceName}" + - "&loraFrequency={loraFrequency}" + - "&loraBandwidth={loraBandwidth}" + - "&loraSf={loraSf}" + - "&loraCr={loraCr}", - arguments = - listOf( - navArgument("interfaceId") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("connectionType") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - navArgument("transportMode") { - type = NavType.BoolType - defaultValue = false - }, - navArgument("usbDeviceId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbVendorId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbProductId") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("usbDeviceName") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - navArgument("loraFrequency") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("loraBandwidth") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("loraSf") { - type = NavType.IntType - defaultValue = -1 - }, - navArgument("loraCr") { - type = NavType.IntType - defaultValue = -1 - }, - ), - ) { backStackEntry -> - val interfaceId = backStackEntry.arguments?.getLong("interfaceId") ?: -1L - val connectionType = backStackEntry.arguments?.getString("connectionType") - val transportMode = backStackEntry.arguments?.getBoolean("transportMode") ?: false - val usbDeviceId = backStackEntry.arguments?.getInt("usbDeviceId") ?: -1 - val usbVendorId = backStackEntry.arguments?.getInt("usbVendorId") ?: -1 - val usbProductId = backStackEntry.arguments?.getInt("usbProductId") ?: -1 - val usbDeviceName = backStackEntry.arguments?.getString("usbDeviceName") - val loraFrequency = backStackEntry.arguments?.getLong("loraFrequency") ?: -1L - val loraBandwidth = backStackEntry.arguments?.getInt("loraBandwidth") ?: -1 - val loraSf = backStackEntry.arguments?.getInt("loraSf") ?: -1 - val loraCr = backStackEntry.arguments?.getInt("loraCr") ?: -1 - com.lxmf.messenger.ui.screens.rnode.RNodeWizardScreen( - editingInterfaceId = if (interfaceId >= 0) interfaceId else null, - preselectedConnectionType = connectionType, - preselectedUsbDeviceId = if (usbDeviceId >= 0) usbDeviceId else null, - preselectedUsbVendorId = if (usbVendorId >= 0) usbVendorId else null, - preselectedUsbProductId = if (usbProductId >= 0) usbProductId else null, - preselectedUsbDeviceName = usbDeviceName, - preselectedLoraFrequency = if (loraFrequency > 0) loraFrequency else null, - preselectedLoraBandwidth = if (loraBandwidth > 0) loraBandwidth else null, - preselectedLoraSf = if (loraSf > 0) loraSf else null, - preselectedLoraCr = if (loraCr > 0) loraCr else null, - transportMode = transportMode, - onNavigateBack = { navController.popBackStack() }, - onComplete = { - if (transportMode) { - navController.popBackStack() - } else { + composable( + route = "tcp_client_wizard?interfaceId={interfaceId}&host={host}&port={port}&name={name}", + arguments = + listOf( + navArgument("interfaceId") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("host") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("port") { + type = NavType.IntType + defaultValue = 0 + }, + navArgument("name") { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { backStackEntry -> + val interfaceId = backStackEntry.arguments?.getLong("interfaceId") ?: -1L + val host = backStackEntry.arguments?.getString("host") ?: "" + val port = backStackEntry.arguments?.getInt("port") ?: 0 + val name = backStackEntry.arguments?.getString("name") ?: "" + TcpClientWizardScreen( + onNavigateBack = { navController.popBackStack() }, + onComplete = { navController.navigate("interface_management") { popUpTo("interface_management") { inclusive = true } } - } - }, - ) - } + }, + interfaceId = if (interfaceId > 0) interfaceId else null, + initialHost = host.ifEmpty { null }, + initialPort = if (port > 0) port else null, + initialName = name.ifEmpty { null }, + ) + } - composable( - route = "interface_stats/{interfaceId}", - arguments = - listOf( - navArgument("interfaceId") { - type = NavType.LongType - }, - ), - ) { backStackEntry -> - com.lxmf.messenger.ui.screens.InterfaceStatsScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToEdit = { interfaceId, interfaceType -> - // Route to appropriate wizard based on interface type - val route = - when (interfaceType) { - "TCPClient" -> "tcp_client_wizard?interfaceId=$interfaceId" - "RNode" -> "rnode_wizard?interfaceId=$interfaceId" - else -> "rnode_wizard?interfaceId=$interfaceId" + composable( + route = + "rnode_wizard?interfaceId={interfaceId}" + + "&connectionType={connectionType}" + + "&usbDeviceId={usbDeviceId}" + + "&usbVendorId={usbVendorId}" + + "&usbProductId={usbProductId}" + + "&usbDeviceName={usbDeviceName}" + + "&loraFrequency={loraFrequency}" + + "&loraBandwidth={loraBandwidth}" + + "&loraSf={loraSf}" + + "&loraCr={loraCr}", + arguments = + listOf( + navArgument("interfaceId") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("connectionType") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("usbDeviceId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbVendorId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbProductId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("usbDeviceName") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("loraFrequency") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("loraBandwidth") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("loraSf") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("loraCr") { + type = NavType.IntType + defaultValue = -1 + }, + ), + ) { backStackEntry -> + val interfaceId = backStackEntry.arguments?.getLong("interfaceId") ?: -1L + val connectionType = backStackEntry.arguments?.getString("connectionType") + val usbDeviceId = backStackEntry.arguments?.getInt("usbDeviceId") ?: -1 + val usbVendorId = backStackEntry.arguments?.getInt("usbVendorId") ?: -1 + val usbProductId = backStackEntry.arguments?.getInt("usbProductId") ?: -1 + val usbDeviceName = backStackEntry.arguments?.getString("usbDeviceName") + val loraFrequency = backStackEntry.arguments?.getLong("loraFrequency") ?: -1L + val loraBandwidth = backStackEntry.arguments?.getInt("loraBandwidth") ?: -1 + val loraSf = backStackEntry.arguments?.getInt("loraSf") ?: -1 + val loraCr = backStackEntry.arguments?.getInt("loraCr") ?: -1 + com.lxmf.messenger.ui.screens.rnode.RNodeWizardScreen( + editingInterfaceId = if (interfaceId >= 0) interfaceId else null, + preselectedConnectionType = connectionType, + preselectedUsbDeviceId = if (usbDeviceId >= 0) usbDeviceId else null, + preselectedUsbVendorId = if (usbVendorId >= 0) usbVendorId else null, + preselectedUsbProductId = if (usbProductId >= 0) usbProductId else null, + preselectedUsbDeviceName = usbDeviceName, + preselectedLoraFrequency = if (loraFrequency > 0) loraFrequency else null, + preselectedLoraBandwidth = if (loraBandwidth > 0) loraBandwidth else null, + preselectedLoraSf = if (loraSf > 0) loraSf else null, + preselectedLoraCr = if (loraCr > 0) loraCr else null, + onNavigateBack = { navController.popBackStack() }, + onComplete = { + navController.navigate("interface_management") { + popUpTo("interface_management") { inclusive = true } } - navController.navigate(route) - }, - ) - } + }, + ) + } - composable("notification_settings") { - NotificationSettingsScreen( - onNavigateBack = { navController.popBackStack() }, - ) - } + composable( + route = "interface_stats/{interfaceId}", + arguments = + listOf( + navArgument("interfaceId") { + type = NavType.LongType + }, + ), + ) { backStackEntry -> + com.lxmf.messenger.ui.screens.InterfaceStatsScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { interfaceId, interfaceType -> + // Route to appropriate wizard based on interface type + val route = + when (interfaceType) { + "TCPClient" -> "tcp_client_wizard?interfaceId=$interfaceId" + "RNode" -> "rnode_wizard?interfaceId=$interfaceId" + else -> "rnode_wizard?interfaceId=$interfaceId" + } + navController.navigate(route) + }, + ) + } - composable("blocked_users") { - BlockedUsersScreen( - onBackClick = { navController.popBackStack() }, - ) - } + composable("notification_settings") { + NotificationSettingsScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable("theme_management") { - ThemeManagementScreen( - onBackClick = { navController.popBackStack() }, - onCreateTheme = { - navController.navigate("theme_editor") - }, - onEditTheme = { themeId -> - navController.navigate("theme_editor/$themeId") - }, - onApplyTheme = { themeId -> - settingsViewModel.applyCustomTheme(themeId) - }, - ) - } + composable("theme_management") { + ThemeManagementScreen( + onBackClick = { navController.popBackStack() }, + onCreateTheme = { + navController.navigate("theme_editor") + }, + onEditTheme = { themeId -> + navController.navigate("theme_editor/$themeId") + }, + onApplyTheme = { themeId -> + settingsViewModel.applyCustomTheme(themeId) + }, + ) + } - composable("theme_editor") { - ThemeEditorScreen( - themeId = null, - onBackClick = { navController.popBackStack() }, - onSave = { navController.popBackStack() }, - ) - } + composable("theme_editor") { + ThemeEditorScreen( + themeId = null, + onBackClick = { navController.popBackStack() }, + onSave = { navController.popBackStack() }, + ) + } - composable( - route = "theme_editor/{themeId}", - arguments = - listOf( - navArgument("themeId") { type = NavType.LongType }, - ), - ) { backStackEntry -> - val themeId = backStackEntry.arguments?.getLong("themeId") - - ThemeEditorScreen( - themeId = themeId, - onBackClick = { navController.popBackStack() }, - onSave = { navController.popBackStack() }, - ) - } + composable( + route = "theme_editor/{themeId}", + arguments = + listOf( + navArgument("themeId") { type = NavType.LongType }, + ), + ) { backStackEntry -> + val themeId = backStackEntry.arguments?.getLong("themeId") + + ThemeEditorScreen( + themeId = themeId, + onBackClick = { navController.popBackStack() }, + onSave = { navController.popBackStack() }, + ) + } - composable("ble_connection_status") { - BleConnectionStatusScreen( - onBackClick = { navController.popBackStack() }, - ) - } + composable("ble_connection_status") { + BleConnectionStatusScreen( + onBackClick = { navController.popBackStack() }, + ) + } - composable( - "identity_manager?base32Key={base32Key}", - arguments = - listOf( - navArgument("base32Key") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ), - ) { backStackEntry -> - val base32Key = backStackEntry.arguments?.getString("base32Key") - IdentityManagerScreen( - onNavigateBack = { navController.popBackStack() }, - prefilledBase32Key = base32Key, - ) - } + composable( + "identity_manager?base32Key={base32Key}", + arguments = + listOf( + navArgument("base32Key") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { backStackEntry -> + val base32Key = backStackEntry.arguments?.getString("base32Key") + IdentityManagerScreen( + onNavigateBack = { navController.popBackStack() }, + prefilledBase32Key = base32Key, + ) + } - composable("migration") { - MigrationScreen( - onNavigateBack = { navController.popBackStack() }, - onImportComplete = { - // Service restart is handled by MigrationViewModel, - // just navigate to chats after import completes - navController.navigate("chats") { - popUpTo(0) { inclusive = true } - } - }, - ) - } + composable("migration") { + MigrationScreen( + onNavigateBack = { navController.popBackStack() }, + onImportComplete = { + // Service restart is handled by MigrationViewModel, + // just navigate to chats after import completes + navController.navigate("chats") { + popUpTo(0) { inclusive = true } + } + }, + ) + } - composable("apk_sharing") { - ApkSharingScreen( - onNavigateBack = { navController.popBackStack() }, - ) - } + composable("apk_sharing") { + ApkSharingScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable("my_identity") { - MyIdentityScreen( - onNavigateBack = { navController.popBackStack() }, - settingsViewModel = settingsViewModel, - onNavigateToIdentityManager = { - navController.navigate("identity_manager") - }, - ) - } + composable("my_identity") { + MyIdentityScreen( + onNavigateBack = { navController.popBackStack() }, + settingsViewModel = settingsViewModel, + onNavigateToIdentityManager = { + navController.navigate("identity_manager") + }, + ) + } - composable("network_status") { - IdentityScreen( - onBackClick = { navController.popBackStack() }, - settingsViewModel = settingsViewModel, - onNavigateToBleStatus = { - navController.navigate("ble_connection_status") - }, - onNavigateToInterfaceStats = { interfaceId -> - navController.navigate("interface_stats/$interfaceId") - }, - onNavigateToInterfaceManagement = { - navController.navigate("interface_management") - }, - ) - } + composable("network_status") { + IdentityScreen( + onBackClick = { navController.popBackStack() }, + settingsViewModel = settingsViewModel, + onNavigateToBleStatus = { + navController.navigate("ble_connection_status") + }, + onNavigateToInterfaceStats = { interfaceId -> + navController.navigate("interface_stats/$interfaceId") + }, + onNavigateToInterfaceManagement = { + navController.navigate("interface_management") + }, + ) + } - composable("qr_scanner") { - val contactsViewModel: ContactsViewModel = hiltViewModel() - QrScannerScreen( - onBackClick = { navController.popBackStack() }, - onQrScanned = { qrData -> - // Contact addition now handled by confirmation dialog - }, - onNavigateToConversation = { destinationHash -> - // Contact already exists - navigate to conversation - val contacts = contactsViewModel.contacts.value - val contact = contacts.find { it.destinationHash == destinationHash } - val peerName = contact?.displayName ?: destinationHash.take(16) - val encodedHash = Uri.encode(destinationHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - contactsViewModel = contactsViewModel, - ) - } + composable("qr_scanner") { + val contactsViewModel: ContactsViewModel = hiltViewModel() + QrScannerScreen( + onBackClick = { navController.popBackStack() }, + onQrScanned = { qrData -> + // Contact addition now handled by confirmation dialog + }, + onNavigateToConversation = { destinationHash -> + // Contact already exists - navigate to conversation + val contacts = contactsViewModel.contacts.value + val contact = contacts.find { it.destinationHash == destinationHash } + val peerName = contact?.displayName ?: destinationHash.take(16) + val encodedHash = Uri.encode(destinationHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + contactsViewModel = contactsViewModel, + ) + } - composable( - route = "messaging/{destinationHash}/{peerName}", - arguments = - listOf( - navArgument("destinationHash") { type = NavType.StringType }, - navArgument("peerName") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val destinationHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() - val peerName = backStackEntry.arguments?.getString("peerName").orEmpty() - - MessagingScreen( - destinationHash = destinationHash, - peerName = peerName, - onBackClick = { navController.popBackStack() }, - onPeerClick = { - val encodedHash = Uri.encode(destinationHash) - navController.navigate("announce_detail/$encodedHash") - }, - onViewMessageDetails = { messageId -> - val encodedId = Uri.encode(messageId) - navController.navigate("message_detail/$encodedId") - }, - onVoiceCall = { profileCode -> - val encodedHash = Uri.encode(destinationHash) - navController.navigate("voice_call/$encodedHash?profileCode=$profileCode") - }, - onLocateOnMap = { peerHash -> - mapViewModel.focusOnContact(peerHash) - navController.navigate(Screen.Map.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + composable( + route = "messaging/{destinationHash}/{peerName}", + arguments = + listOf( + navArgument("destinationHash") { type = NavType.StringType }, + navArgument("peerName") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val destinationHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() + val peerName = backStackEntry.arguments?.getString("peerName").orEmpty() + + MessagingScreen( + destinationHash = destinationHash, + peerName = peerName, + onBackClick = { navController.popBackStack() }, + onPeerClick = { + val encodedHash = Uri.encode(destinationHash) + navController.navigate("announce_detail/$encodedHash") + }, + onViewMessageDetails = { messageId -> + val encodedId = Uri.encode(messageId) + navController.navigate("message_detail/$encodedId") + }, + onVoiceCall = { profileCode -> + val encodedHash = Uri.encode(destinationHash) + navController.navigate("voice_call/$encodedHash?profileCode=$profileCode") + }, + onLocateOnMap = { peerHash -> + mapViewModel.focusOnContact(peerHash) + navController.navigate(Screen.Map.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - }, - ) - } + }, + ) + } - composable( - route = "message_detail/{messageId}", - arguments = - listOf( - navArgument("messageId") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val messageId = backStackEntry.arguments?.getString("messageId").orEmpty() - - MessageDetailScreen( - messageId = messageId, - onBackClick = { navController.popBackStack() }, - ) - } + composable( + route = "message_detail/{messageId}", + arguments = + listOf( + navArgument("messageId") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val messageId = backStackEntry.arguments?.getString("messageId").orEmpty() + + MessageDetailScreen( + messageId = messageId, + onBackClick = { navController.popBackStack() }, + ) + } - composable( - route = "announce_detail/{destinationHash}", - arguments = - listOf( - navArgument("destinationHash") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val destinationHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() - - AnnounceDetailScreen( - destinationHash = destinationHash, - onBackClick = { navController.popBackStack() }, - onViewAnnounce = { hash -> - navController.navigate("announce_detail/${Uri.encode(hash)}") - }, - onStartChat = { destHash, peerName -> - // Navigate back to chats tab - selectedTab = 0 - navController.navigate(Screen.Chats.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true + composable( + route = "announce_detail/{destinationHash}", + arguments = + listOf( + navArgument("destinationHash") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val destinationHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() + + AnnounceDetailScreen( + destinationHash = destinationHash, + onBackClick = { navController.popBackStack() }, + onStartChat = { destHash, peerName -> + // Navigate back to chats tab + selectedTab = 0 + navController.navigate(Screen.Chats.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - // Then navigate to the messaging screen - val encodedHash = Uri.encode(destHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - onBrowseNode = { destHash -> - navController.navigate("nomadnet_browser/$destHash") - }, - ) - } + // Then navigate to the messaging screen + val encodedHash = Uri.encode(destHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + onViewAnnounce = { destHash -> + navController.navigate("announce_detail/$destHash") + }, + onBrowseNode = { destHash -> + navController.navigate("nomadnet_browser/$destHash") + }, + ) + } - // NomadNet Browser screen - composable( - route = "nomadnet_browser/{destinationHash}?path={path}", - arguments = - listOf( - navArgument("destinationHash") { type = NavType.StringType }, - navArgument("path") { - type = NavType.StringType - defaultValue = "/page/index.mu" - }, - ), - ) { backStackEntry -> - val destHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() - val path = backStackEntry.arguments?.getString("path") ?: "/page/index.mu" - NomadNetBrowserScreen( - destinationHash = destHash, - initialPath = path, - onBackClick = { navController.popBackStack() }, - onOpenConversation = { conversationHash -> - val encodedHash = Uri.encode(conversationHash) - val encodedName = Uri.encode(conversationHash.take(12)) - navController.navigate("messaging/$encodedHash/$encodedName") - }, - ) - } + // Offline Maps management screen + composable("offline_maps") { + OfflineMapsScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToDownload = { navController.navigate("offline_map_download") }, + onNavigateToUpdate = { regionId -> + navController.navigate("offline_map_download?updateRegionId=$regionId") + }, + ) + } - // Offline Maps management screen - composable("offline_maps") { - OfflineMapsScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToDownload = { navController.navigate("offline_map_download") }, - onNavigateToUpdate = { regionId -> - navController.navigate("offline_map_download?updateRegionId=$regionId") - }, - ) - } + // Offline Map download wizard (with optional update parameter) + composable( + route = "offline_map_download?updateRegionId={updateRegionId}", + arguments = + listOf( + navArgument("updateRegionId") { + type = NavType.LongType + defaultValue = -1L + }, + ), + ) { backStackEntry -> + val updateRegionId = backStackEntry.arguments?.getLong("updateRegionId") ?: -1L + OfflineMapDownloadScreen( + onNavigateBack = { navController.popBackStack() }, + onDownloadComplete = { navController.popBackStack() }, + updateRegionId = if (updateRegionId > 0) updateRegionId else null, + ) + } - // Offline Map download wizard (with optional update parameter) - composable( - route = "offline_map_download?updateRegionId={updateRegionId}", - arguments = - listOf( - navArgument("updateRegionId") { - type = NavType.LongType - defaultValue = -1L - }, - ), - ) { backStackEntry -> - val updateRegionId = backStackEntry.arguments?.getLong("updateRegionId") ?: -1L - OfflineMapDownloadScreen( - onNavigateBack = { navController.popBackStack() }, - onDownloadComplete = { navController.popBackStack() }, - updateRegionId = if (updateRegionId > 0) updateRegionId else null, - ) - } + // Voice Call Screen (outgoing/active call) + composable( + route = "voice_call/{destinationHash}?autoAnswer={autoAnswer}&profileCode={profileCode}", + arguments = + listOf( + navArgument("destinationHash") { type = NavType.StringType }, + navArgument("autoAnswer") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("profileCode") { + type = NavType.IntType + defaultValue = -1 // -1 means use default + }, + ), + ) { backStackEntry -> + val destinationHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() + val autoAnswer = backStackEntry.arguments?.getBoolean("autoAnswer") ?: false + val profileCodeArg = backStackEntry.arguments?.getInt("profileCode") ?: -1 + val profileCode = if (profileCodeArg == -1) null else profileCodeArg + + VoiceCallScreen( + destinationHash = destinationHash, + onEndCall = exitCallFlow, + autoAnswer = autoAnswer, + profileCode = profileCode, + ) + } - // Voice Call Screen (outgoing/active call) - composable( - route = "voice_call/{destinationHash}?autoAnswer={autoAnswer}&profileCode={profileCode}", - arguments = - listOf( - navArgument("destinationHash") { type = NavType.StringType }, - navArgument("autoAnswer") { - type = NavType.BoolType - defaultValue = false - }, - navArgument("profileCode") { - type = NavType.IntType - defaultValue = -1 // -1 means use default - }, - ), - ) { backStackEntry -> - val destinationHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() - val autoAnswer = backStackEntry.arguments?.getBoolean("autoAnswer") ?: false - val profileCodeArg = backStackEntry.arguments?.getInt("profileCode") ?: -1 - val profileCode = if (profileCodeArg == -1) null else profileCodeArg - - VoiceCallScreen( - destinationHash = destinationHash, - onEndCall = exitCallFlow, - autoAnswer = autoAnswer, - profileCode = profileCode, - ) + // NomadNet Browser screen + composable( + route = "nomadnet_browser/{destinationHash}?path={path}", + arguments = + listOf( + navArgument("destinationHash") { type = NavType.StringType }, + navArgument("path") { + type = NavType.StringType + defaultValue = "/page/index.mu" + }, + ), + ) { backStackEntry -> + val destHash = backStackEntry.arguments?.getString("destinationHash").orEmpty() + val path = backStackEntry.arguments?.getString("path") ?: "/page/index.mu" + NomadNetBrowserScreen( + destinationHash = destHash, + initialPath = path, + onBackClick = { navController.popBackStack() }, + onOpenConversation = { conversationHash -> + val encodedHash = Uri.encode(conversationHash) + val encodedName = Uri.encode(conversationHash.take(12)) + navController.navigate("messaging/$encodedHash/$encodedName") + }, + ) + } + + // Incoming Call Screen + composable( + route = "incoming_call/{identityHash}", + arguments = + listOf( + navArgument("identityHash") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val identityHash = backStackEntry.arguments?.getString("identityHash").orEmpty() + + IncomingCallScreen( + identityHash = identityHash, + onCallAnswered = { + // Navigate to voice call screen when answered + val encodedHash = Uri.encode(identityHash) + navController.navigate("voice_call/$encodedHash") { + popUpTo("incoming_call/$identityHash") { inclusive = true } + } + }, + onCallDeclined = exitCallFlow, + ) + } } + } // end Column + + // SOS overlay — floating draggable pill above bottom nav + // SOS floating elements — FAB and Active pill share the same position + if (settingsState.sosEnabled) { + var sosOffsetX by remember { mutableFloatStateOf(settingsState.sosFabOffsetX) } + var sosOffsetY by remember { mutableFloatStateOf(settingsState.sosFabOffsetY) } + val dragModifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = if (shouldShowBottomNav) 88.dp else 16.dp, end = 16.dp) + .offset { IntOffset(sosOffsetX.roundToInt(), sosOffsetY.roundToInt()) } + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = { + settingsViewModel.setSosFabOffset(sosOffsetX, sosOffsetY) + }, + ) { change, dragAmount -> + change.consume() + sosOffsetX += dragAmount.x + sosOffsetY += dragAmount.y + } + } - // Incoming Call Screen - composable( - route = "incoming_call/{identityHash}", - arguments = - listOf( - navArgument("identityHash") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val identityHash = backStackEntry.arguments?.getString("identityHash").orEmpty() - - IncomingCallScreen( - identityHash = identityHash, - onCallAnswered = { - // Navigate to voice call screen when answered - val encodedHash = Uri.encode(identityHash) - navController.navigate("voice_call/$encodedHash") { - popUpTo("incoming_call/$identityHash") { inclusive = true } - } - }, - onCallDeclined = exitCallFlow, - ) + SosOverlay( + sosState = sosState, + sosDeactivationPin = settingsState.sosDeactivationPin, + onCancel = { sosViewModel.cancel() }, + onDeactivate = { pin -> sosViewModel.deactivate(pin) }, + modifier = dragModifier, + ) + + // Show FAB only when idle and floating button enabled + if (settingsState.sosShowFloatingButton && sosState is SosState.Idle) { + FloatingActionButton( + onClick = { sosViewModel.trigger() }, + containerColor = MaterialTheme.colorScheme.error, + modifier = dragModifier, + ) { + Icon( + Icons.Filled.Warning, + contentDescription = "Trigger SOS", + tint = MaterialTheme.colorScheme.onError, + ) + } } } - } + } // end Box // Bluetooth permission bottom sheet // Only show if activity is at least STARTED to prevent BadTokenException diff --git a/app/src/main/java/com/lxmf/messenger/MainActivityIntentHandler.kt b/app/src/main/java/com/lxmf/messenger/MainActivityIntentHandler.kt index bcf3cd098..8abf7cd55 100644 --- a/app/src/main/java/com/lxmf/messenger/MainActivityIntentHandler.kt +++ b/app/src/main/java/com/lxmf/messenger/MainActivityIntentHandler.kt @@ -22,6 +22,8 @@ class MainActivityIntentHandler( when (intent.action) { NotificationHelper.ACTION_OPEN_ANNOUNCE -> handleOpenAnnounce(intent) NotificationHelper.ACTION_OPEN_CONVERSATION -> handleOpenConversation(intent) + NotificationHelper.ACTION_SOS_CALL_BACK -> handleSosCallBack(intent) + NotificationHelper.ACTION_SOS_VIEW_MAP -> handleSosViewMap(intent) Intent.ACTION_VIEW -> handleActionView(intent) Intent.ACTION_SEND -> handleActionSend(intent) Intent.ACTION_SEND_MULTIPLE -> handleActionSendMultiple(intent) @@ -81,11 +83,23 @@ class MainActivityIntentHandler( } } + @Suppress("DEPRECATION") private fun handleActionSend(intent: Intent) { val mimeType = intent.type if (mimeType != null && mimeType.startsWith("image/")) { - handleActionSendImage(intent) + val uri: Uri? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + if (uri != null) { + Log.d(logTag, "Received shared image (ACTION_SEND): $uri") + triggerSharedImages(listOf(uri)) + } else { + Log.w(logTag, "ACTION_SEND received with image/* but no EXTRA_STREAM URI found") + } return } @@ -124,27 +138,22 @@ class MainActivityIntentHandler( } @Suppress("DEPRECATION") - private fun handleActionSendImage(intent: Intent) { - val uri: Uri? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) - } else { - intent.getParcelableExtra(Intent.EXTRA_STREAM) - } - - if (uri != null) { - Log.d(logTag, "Received shared image (ACTION_SEND): $uri") - triggerSharedImages(listOf(uri)) - } else { - Log.w(logTag, "ACTION_SEND received with image/* but no EXTRA_STREAM URI found") - } - } - private fun handleActionSendMultiple(intent: Intent) { val mimeType = intent.type if (mimeType != null && mimeType.startsWith("image/")) { - handleActionSendMultipleImages(intent) + val uris: List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) ?: emptyList() + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) ?: emptyList() + } + if (uris.isNotEmpty()) { + Log.d(logTag, "Received ${uris.size} shared images (ACTION_SEND_MULTIPLE)") + triggerSharedImages(uris) + } else { + Log.w(logTag, "ACTION_SEND_MULTIPLE received with image/* but no EXTRA_STREAM URIs found") + } return } @@ -184,23 +193,6 @@ class MainActivityIntentHandler( } } - @Suppress("DEPRECATION") - private fun handleActionSendMultipleImages(intent: Intent) { - val uris: List = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) ?: emptyList() - } else { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) ?: emptyList() - } - - if (uris.isNotEmpty()) { - Log.d(logTag, "Received ${uris.size} shared images (ACTION_SEND_MULTIPLE)") - triggerSharedImages(uris) - } else { - Log.w(logTag, "ACTION_SEND_MULTIPLE received with image/* but no EXTRA_STREAM URIs found") - } - } - private fun handleActionProcessText(intent: Intent) { val extrasKeys = intent.extras?.keySet()?.joinToString() ?: "" val sharedText = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString() @@ -259,6 +251,26 @@ class MainActivityIntentHandler( } } + private fun handleSosCallBack(intent: Intent) { + val destinationHash = intent.getStringExtra(NotificationHelper.EXTRA_DESTINATION_HASH) + val peerName = intent.getStringExtra(NotificationHelper.EXTRA_PEER_NAME) ?: "Contact" + if (destinationHash != null) { + Log.d(logTag, "SOS call-back: opening conversation with $peerName ($destinationHash)") + pendingNavigation.value = PendingNavigation.Conversation(destinationHash, peerName) + } + } + + private fun handleSosViewMap(intent: Intent) { + val lat = intent.getDoubleExtra("latitude", 0.0) + val lon = intent.getDoubleExtra("longitude", 0.0) + val label = intent.getStringExtra(NotificationHelper.EXTRA_PEER_NAME) ?: "SOS" + val senderHash = intent.getStringExtra(NotificationHelper.EXTRA_DESTINATION_HASH) + if (lat != 0.0 && lon != 0.0) { + Log.d(logTag, "SOS view map: focusing on $lat, $lon ($label) trail=${senderHash?.take(8)}") + pendingNavigation.value = PendingNavigation.SosMapFocus(lat, lon, "SOS: $label", senderHash) + } + } + private fun handleUsbDeviceAttachedIntent(intent: Intent) { Log.d(logTag, "🔌 USB_DEVICE_ATTACHED action matched!") @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/lxmf/messenger/migration/MigrationExporter.kt b/app/src/main/java/com/lxmf/messenger/migration/MigrationExporter.kt index 5362a6e69..3716539cd 100644 --- a/app/src/main/java/com/lxmf/messenger/migration/MigrationExporter.kt +++ b/app/src/main/java/com/lxmf/messenger/migration/MigrationExporter.kt @@ -282,9 +282,7 @@ class MigrationExporter * Handles both encrypted (new) and unencrypted (legacy) storage. */ @Suppress("DEPRECATION") - private suspend fun getDecryptedKeyData( - identity: com.lxmf.messenger.data.db.entity.LocalIdentityEntity, - ): ByteArray? { + private suspend fun getDecryptedKeyData(identity: com.lxmf.messenger.data.db.entity.LocalIdentityEntity): ByteArray? { // Try to get from encrypted storage first if (identity.keyEncryptionVersion > 0 && identity.encryptedKeyData != null) { return try { diff --git a/app/src/main/java/com/lxmf/messenger/notifications/NotificationHelper.kt b/app/src/main/java/com/lxmf/messenger/notifications/NotificationHelper.kt index e4f42b177..ba7eaac20 100644 --- a/app/src/main/java/com/lxmf/messenger/notifications/NotificationHelper.kt +++ b/app/src/main/java/com/lxmf/messenger/notifications/NotificationHelper.kt @@ -40,15 +40,19 @@ class NotificationHelper private const val CHANNEL_ID_MESSAGES = "messages" private const val CHANNEL_ID_ANNOUNCES = "announces" private const val CHANNEL_ID_BLE_EVENTS = "ble_events" + private const val CHANNEL_ID_SOS = "sos_emergency" // Notification IDs private const val NOTIFICATION_ID_MESSAGE = 1000 private const val NOTIFICATION_ID_ANNOUNCE = 2000 private const val NOTIFICATION_ID_BLE = 3000 + internal const val NOTIFICATION_ID_SOS = 5000 // Intent actions const val ACTION_OPEN_ANNOUNCE = "com.lxmf.messenger.ACTION_OPEN_ANNOUNCE" const val ACTION_OPEN_CONVERSATION = "com.lxmf.messenger.ACTION_OPEN_CONVERSATION" + const val ACTION_SOS_CALL_BACK = "com.lxmf.messenger.ACTION_SOS_CALL_BACK" + const val ACTION_SOS_VIEW_MAP = "com.lxmf.messenger.ACTION_SOS_VIEW_MAP" private const val ACTION_REPLY = "com.lxmf.messenger.ACTION_REPLY" private const val ACTION_MARK_READ = "com.lxmf.messenger.ACTION_MARK_READ" @@ -106,10 +110,23 @@ class NotificationHelper enableVibration(false) } + val sosChannel = + NotificationChannel( + CHANNEL_ID_SOS, + "SOS Emergency", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Emergency SOS alerts from contacts" + enableVibration(true) + vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500) + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + } + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.createNotificationChannel(messagesChannel) manager.createNotificationChannel(announcesChannel) manager.createNotificationChannel(bleEventsChannel) + manager.createNotificationChannel(sosChannel) } } @@ -402,18 +419,280 @@ class NotificationHelper } /** - * Cancel a specific notification. + * Cancel one or all notifications. * - * @param notificationId The ID of the notification to cancel + * @param notificationId The ID of the notification to cancel, or null to cancel all. */ - fun cancelNotification(notificationId: Int) { - notificationManager.cancel(notificationId) + fun cancelNotification(notificationId: Int? = null) { + if (notificationId != null) { + notificationManager.cancel(notificationId) + } else { + notificationManager.cancelAll() + } } /** - * Cancel all notifications. + * Post an urgent notification when an SOS message is received. */ - fun cancelAllNotifications() { - notificationManager.cancelAll() + fun notifySosReceived( + destinationHash: String, + peerName: String, + messageContent: String, + latitude: Double? = null, + longitude: Double? = null, + isUpdate: Boolean = false, + ) { + if (!hasNotificationPermission()) return + + val openPendingIntent = + createSosPendingIntent( + action = ACTION_OPEN_CONVERSATION, + requestCode = "sos_open_$destinationHash", + destinationHash = destinationHash, + peerName = peerName, + ) + + val callBackPendingIntent = + createSosPendingIntent( + action = ACTION_SOS_CALL_BACK, + requestCode = "sos_call_$destinationHash", + destinationHash = destinationHash, + peerName = peerName, + ) + + val contentText = + if (latitude != null && longitude != null) { + "GPS: ${"%.5f".format(latitude)}, ${"%.5f".format(longitude)}" + } else { + messageContent.take(200) + } + + val bigText = + buildString { + append(messageContent.take(500)) + if (latitude != null && longitude != null) { + append("\n\nLocation: ${"%.5f".format(latitude)}, ${"%.5f".format(longitude)}") + } + } + + val builder = + NotificationCompat + .Builder(context, CHANNEL_ID_SOS) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("SOS from $peerName") + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setAutoCancel(false) + .setOngoing(true) + .setContentIntent(openPendingIntent) + .addAction(R.mipmap.ic_launcher, "Open Chat", callBackPendingIntent) + .apply { + if (isUpdate) { + setOnlyAlertOnce(true) + } else { + setVibrate(longArrayOf(0, 500, 200, 500, 200, 500)) + } + } + + if (latitude != null && longitude != null) { + val mapPendingIntent = + createSosPendingIntent( + action = ACTION_SOS_VIEW_MAP, + requestCode = "sos_map_$destinationHash", + destinationHash = destinationHash, + peerName = peerName, + latitude = latitude, + longitude = longitude, + ) + builder.addAction(R.mipmap.ic_launcher, "View on Map", mapPendingIntent) + } + + val notification = builder.build() + val notificationId = NOTIFICATION_ID_SOS xor (destinationHash.hashCode() and 0x7FFFFFFF) + try { + notificationManager.notify(notificationId, notification) + } catch (e: SecurityException) { + // Permission was revoked + } + } + + private fun createSosPendingIntent( + action: String, + requestCode: String, + destinationHash: String? = null, + peerName: String? = null, + latitude: Double? = null, + longitude: Double? = null, + ): PendingIntent { + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + this.action = action + destinationHash?.let { putExtra(EXTRA_DESTINATION_HASH, it) } + peerName?.let { putExtra(EXTRA_PEER_NAME, it) } + latitude?.let { putExtra("latitude", it) } + longitude?.let { putExtra("longitude", it) } + } + return PendingIntent.getActivity( + context, + requestCode.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } + + /** + * Show a persistent notification indicating SOS mode is active (sender side). + */ + fun showSosActiveNotification( + contactsNotified: Int, + failedCount: Int, + ) { + if (!hasNotificationPermission()) return + + val openIntent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val openPendingIntent = + PendingIntent.getActivity( + context, + "sos_active".hashCode(), + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val text = + buildString { + append("$contactsNotified contact(s) notified") + if (failedCount > 0) append(" ($failedCount failed)") + } + + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID_SOS) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("SOS Active") + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(false) + .setOngoing(true) + .setContentIntent(openPendingIntent) + .build() + + try { + notificationManager.notify(NOTIFICATION_ID_SOS, notification) + } catch (e: SecurityException) { + // Permission was revoked + } + } + + /** + * Check if a message content is an SOS emergency message. + */ + fun isSosMessage(content: String): Boolean { + val upper = content.uppercase().trimStart() + return ( + upper.startsWith("SOS") || + upper.startsWith("URGENCE") || + upper.startsWith("EMERGENCY") + ) && + !isSosCancelledMessage(content) + } + + } + +/** + * Parse GPS coordinates from an SOS message. + * Checks FIELD_TELEMETRY first (Sideband-compatible), then falls back to text regex. + */ +fun parseSosLocation(content: String, fieldsJson: String? = null): Pair? { + // Primary: extract from FIELD_TELEMETRY in fieldsJson + if (fieldsJson != null) { + try { + val fields = org.json.JSONObject(fieldsJson) + val telemetry = fields.optJSONObject("2") + if (telemetry != null) { + val lat = telemetry.optDouble("lat", Double.NaN) + val lng = telemetry.optDouble("lng", Double.NaN) + val validCoords = !lat.isNaN() && !lng.isNaN() + if (validCoords && lat in -90.0..90.0 && lng in -180.0..180.0) { + return Pair(lat, lng) + } + } + } catch (_: Exception) { + // Fall through to text parsing + } + } + // Fallback: parse from message text (locale-independent regex) + val regex = Regex("""GPS:\s*(-?[\d.,]+),\s*(-?[\d.,]+)""") + val match = regex.find(content) ?: return null + return try { + val lat = match.groupValues[1].replace(',', '.').toDouble() + val lng = match.groupValues[2].replace(',', '.').toDouble() + if (lat in -90.0..90.0 && lng in -180.0..180.0) Pair(lat, lng) else null + } catch (e: NumberFormatException) { + null + } +} + +/** + * Check if a message is an SOS cancellation. Top-level to avoid TooManyFunctions in NotificationHelper. + */ + +/** + * Check if a message is an SOS periodic update. Top-level to avoid TooManyFunctions in NotificationHelper. + */ +fun isSosUpdate(content: String): Boolean = content.uppercase().trimStart().startsWith("SOS UPDATE") + +fun isSosCancelledMessage(content: String): Boolean { + val upper = content.uppercase().trimStart() + return upper.startsWith("SOS CANCELLED") || upper.startsWith("SOS CANCELED") +} + +/** Extract SOS state from fieldsJson FIELD_COMMANDS. Returns "active", "cancelled", "update", or null. */ +fun extractSosState(fieldsJson: String?): String? { + if (fieldsJson == null) return null + return try { + val fields = org.json.JSONObject(fieldsJson) + fields.optString("sos_state").ifEmpty { null } + } catch (_: Exception) { + null } +} + +/** Check if a message is SOS using FIELD_COMMANDS (primary) or text content (fallback). */ +fun isSosMessageByField( + content: String, + fieldsJson: String?, +): Boolean { + val sosState = extractSosState(fieldsJson) + if (sosState != null) return sosState == "active" || sosState == "update" + // Text-based fallback for Sideband/legacy compatibility + val upper = content.uppercase().trimStart() + return (upper.startsWith("SOS") || upper.startsWith("URGENCE") || upper.startsWith("EMERGENCY")) && + !isSosCancelledMessage(content) +} + +/** Check if a message is SOS cancellation using FIELD_COMMANDS (primary) or text content (fallback). */ +fun isSosCancelledByField( + content: String, + fieldsJson: String?, +): Boolean { + val sosState = extractSosState(fieldsJson) + if (sosState != null) return sosState == "cancelled" + return isSosCancelledMessage(content) +} + +/** Check if a message is SOS update using FIELD_COMMANDS (primary) or text content (fallback). */ +fun isSosUpdateByField( + content: String, + fieldsJson: String?, +): Boolean { + val sosState = extractSosState(fieldsJson) + if (sosState != null) return sosState == "update" + return isSosUpdate(content) +} diff --git a/app/src/main/java/com/lxmf/messenger/receiver/BootReceiver.kt b/app/src/main/java/com/lxmf/messenger/receiver/BootReceiver.kt new file mode 100644 index 000000000..4553188a3 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/receiver/BootReceiver.kt @@ -0,0 +1,56 @@ +package com.lxmf.messenger.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat +import com.lxmf.messenger.service.ReticulumService +import com.lxmf.messenger.service.SosTriggerService + +/** + * Starts Columba services after device boot. + * + * Launches: + * 1. [ReticulumService] — mesh network (runs in :reticulum process) + * 2. [SosTriggerService] — keeps the main process alive so that + * [com.lxmf.messenger.service.SosTriggerDetector.startObserving] (started in + * [com.lxmf.messenger.ColumbaApplication.onCreate]) can read SOS settings and + * decide whether to keep the service running. If SOS is disabled or mode is + * MANUAL, the observer stops the service automatically. + */ +class BootReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "BootReceiver" + } + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + + Log.d(TAG, "Boot completed — starting Columba services") + + try { + val serviceIntent = + Intent(context, ReticulumService::class.java).apply { + action = ReticulumService.ACTION_START + } + ContextCompat.startForegroundService(context, serviceIntent) + Log.d(TAG, "ReticulumService start requested") + } catch (e: Exception) { + Log.e(TAG, "Failed to start ReticulumService on boot: ${e.message}", e) + } + + // Start SosTriggerService to keep the main process alive while + // startObserving() reads DataStore. If SOS is not active, the observer + // will stop this service within seconds. + try { + SosTriggerService.start(context) + Log.d(TAG, "SosTriggerService start requested") + } catch (e: Exception) { + Log.e(TAG, "Failed to start SosTriggerService on boot: ${e.message}", e) + } + } +} diff --git a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt index cdfc01528..249600396 100644 --- a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt +++ b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt @@ -151,6 +151,40 @@ class SettingsRepository // Message sort order: false = received time (default), true = sent time val SORT_MESSAGES_BY_SENT_TIME = booleanPreferencesKey("sort_messages_by_sent_time") + + // SOS Emergency preferences + val SOS_ENABLED = booleanPreferencesKey("sos_enabled") + val SOS_MESSAGE_TEMPLATE = stringPreferencesKey("sos_message_template") + val SOS_COUNTDOWN_SECONDS = intPreferencesKey("sos_countdown_seconds") + val SOS_INCLUDE_LOCATION = booleanPreferencesKey("sos_include_location") + val SOS_SILENT_AUTO_ANSWER = booleanPreferencesKey("sos_silent_auto_answer") + val SOS_SHOW_FLOATING_BUTTON = booleanPreferencesKey("sos_show_floating_button") + val SOS_DEACTIVATION_PIN = stringPreferencesKey("sos_deactivation_pin") + val SOS_PERIODIC_UPDATES = booleanPreferencesKey("sos_periodic_updates") + val SOS_UPDATE_INTERVAL_SECONDS = intPreferencesKey("sos_update_interval_seconds") + + // SOS floating element positions (persisted across recompositions) + val SOS_FAB_OFFSET_X = floatPreferencesKey("sos_fab_offset_x") + val SOS_FAB_OFFSET_Y = floatPreferencesKey("sos_fab_offset_y") + val SOS_PILL_OFFSET_X = floatPreferencesKey("sos_pill_offset_x") + val SOS_PILL_OFFSET_Y = floatPreferencesKey("sos_pill_offset_y") + + // SOS state persistence (survives app/phone restart) + val SOS_ACTIVE = booleanPreferencesKey("sos_active") + val SOS_ACTIVE_SENT_COUNT = intPreferencesKey("sos_active_sent_count") + val SOS_ACTIVE_FAILED_COUNT = intPreferencesKey("sos_active_failed_count") + + // SOS trigger modes (multi-select set, migrated from legacy single-mode string) + val SOS_TRIGGER_MODES = stringSetPreferencesKey("sos_trigger_modes") + + @Deprecated("Legacy single mode key, kept for migration") + val SOS_TRIGGER_MODE_LEGACY = stringPreferencesKey("sos_trigger_mode") + val SOS_SHAKE_SENSITIVITY = floatPreferencesKey("sos_shake_sensitivity") + val SOS_TAP_COUNT = intPreferencesKey("sos_tap_count") + + // SOS audio recording + val SOS_AUDIO_ENABLED = booleanPreferencesKey("sos_audio_enabled") + val SOS_AUDIO_DURATION_SECONDS = intPreferencesKey("sos_audio_duration_seconds") } // Cross-process SharedPreferences for service communication @@ -1920,4 +1954,258 @@ class SettingsRepository preferences[PreferencesKeys.SORT_MESSAGES_BY_SENT_TIME] = enabled } } + + // ========== SOS Emergency Settings ========== + + val sosEnabled: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_ENABLED] ?: false + }.distinctUntilChanged() + + suspend fun setSosEnabled(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_ENABLED] = enabled + } + } + + val sosMessageTemplate: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_MESSAGE_TEMPLATE] + ?: "SOS! I need help. This is an emergency." + }.distinctUntilChanged() + + suspend fun setSosMessageTemplate(template: String) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_MESSAGE_TEMPLATE] = template + } + } + + val sosCountdownSeconds: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_COUNTDOWN_SECONDS] ?: 5 + }.distinctUntilChanged() + + suspend fun setSosCountdownSeconds(seconds: Int) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_COUNTDOWN_SECONDS] = seconds.coerceIn(0, 30) + } + } + + val sosIncludeLocation: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_INCLUDE_LOCATION] ?: true + }.distinctUntilChanged() + + suspend fun setSosIncludeLocation(include: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_INCLUDE_LOCATION] = include + } + } + + val sosSilentAutoAnswer: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_SILENT_AUTO_ANSWER] ?: false + }.distinctUntilChanged() + + suspend fun setSosSilentAutoAnswer(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_SILENT_AUTO_ANSWER] = enabled + } + } + + val sosShowFloatingButton: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_SHOW_FLOATING_BUTTON] ?: false + }.distinctUntilChanged() + + suspend fun setSosShowFloatingButton(show: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_SHOW_FLOATING_BUTTON] = show + } + } + + val sosDeactivationPin: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_DEACTIVATION_PIN] + }.distinctUntilChanged() + + suspend fun setSosDeactivationPin(pin: String?) { + context.dataStore.edit { preferences -> + if (pin.isNullOrBlank()) { + preferences.remove(PreferencesKeys.SOS_DEACTIVATION_PIN) + } else { + preferences[PreferencesKeys.SOS_DEACTIVATION_PIN] = pin + } + } + } + + val sosPeriodicUpdates: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_PERIODIC_UPDATES] ?: false + }.distinctUntilChanged() + + suspend fun setSosPeriodicUpdates(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_PERIODIC_UPDATES] = enabled + } + } + + val sosUpdateIntervalSeconds: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_UPDATE_INTERVAL_SECONDS] ?: 120 + }.distinctUntilChanged() + + suspend fun setSosUpdateIntervalSeconds(seconds: Int) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_UPDATE_INTERVAL_SECONDS] = seconds.coerceIn(30, 600) + } + } + + // ========== SOS State Persistence ========== + + val sosActive: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_ACTIVE] ?: false + }.distinctUntilChanged() + + val sosActiveSentCount: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_ACTIVE_SENT_COUNT] ?: 0 + }.distinctUntilChanged() + + val sosActiveFailedCount: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_ACTIVE_FAILED_COUNT] ?: 0 + }.distinctUntilChanged() + + suspend fun persistSosActiveState( + sentCount: Int, + failedCount: Int, + ) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_ACTIVE] = true + preferences[PreferencesKeys.SOS_ACTIVE_SENT_COUNT] = sentCount + preferences[PreferencesKeys.SOS_ACTIVE_FAILED_COUNT] = failedCount + } + } + + suspend fun clearSosActiveState() { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_ACTIVE] = false + preferences.remove(PreferencesKeys.SOS_ACTIVE_SENT_COUNT) + preferences.remove(PreferencesKeys.SOS_ACTIVE_FAILED_COUNT) + } + } + + // ========== SOS Trigger Mode Settings ========== + + @Suppress("DEPRECATION") + val sosTriggerModes: Flow> = + context.dataStore.data + .map { preferences -> + // Migrate from legacy single-mode string if needed + val newModes = preferences[PreferencesKeys.SOS_TRIGGER_MODES] + if (newModes != null) { + newModes + } else { + val legacy = preferences[PreferencesKeys.SOS_TRIGGER_MODE_LEGACY] + if (legacy != null && legacy != "manual") setOf(legacy) else emptySet() + } + }.distinctUntilChanged() + + @Suppress("DEPRECATION") + suspend fun setSosTriggerModes(modes: Set) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_TRIGGER_MODES] = modes + preferences.remove(PreferencesKeys.SOS_TRIGGER_MODE_LEGACY) + } + } + + val sosShakeSensitivity: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_SHAKE_SENSITIVITY] ?: 2.5f + }.distinctUntilChanged() + + suspend fun setSosShakeSensitivity(sensitivity: Float) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_SHAKE_SENSITIVITY] = sensitivity.coerceIn(1.0f, 5.0f) + } + } + + val sosTapCount: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_TAP_COUNT] ?: 3 + }.distinctUntilChanged() + + suspend fun setSosTapCount(count: Int) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_TAP_COUNT] = count.coerceIn(3, 5) + } + } + + // ========== SOS Audio Recording Settings ========== + + val sosAudioEnabled: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_AUDIO_ENABLED] ?: false + }.distinctUntilChanged() + + suspend fun setSosAudioEnabled(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_AUDIO_ENABLED] = enabled + } + } + + val sosAudioDurationSeconds: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.SOS_AUDIO_DURATION_SECONDS] ?: 30 + }.distinctUntilChanged() + + suspend fun setSosAudioDurationSeconds(seconds: Int) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_AUDIO_DURATION_SECONDS] = seconds.coerceIn(15, 60) + } + } + + val sosFabOffsetX: Flow = + context.dataStore.data.map { it[PreferencesKeys.SOS_FAB_OFFSET_X] ?: 0f }.distinctUntilChanged() + + val sosFabOffsetY: Flow = + context.dataStore.data.map { it[PreferencesKeys.SOS_FAB_OFFSET_Y] ?: 0f }.distinctUntilChanged() + + val sosPillOffsetX: Flow = + context.dataStore.data.map { it[PreferencesKeys.SOS_PILL_OFFSET_X] ?: 0f }.distinctUntilChanged() + + val sosPillOffsetY: Flow = + context.dataStore.data.map { it[PreferencesKeys.SOS_PILL_OFFSET_Y] ?: 0f }.distinctUntilChanged() + + suspend fun setSosFabOffset(x: Float, y: Float) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_FAB_OFFSET_X] = x + preferences[PreferencesKeys.SOS_FAB_OFFSET_Y] = y + } + } + + suspend fun setSosPillOffset(x: Float, y: Float) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.SOS_PILL_OFFSET_X] = x + preferences[PreferencesKeys.SOS_PILL_OFFSET_Y] = y + } + } } diff --git a/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt b/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt index 5f20c4802..8514b2e18 100644 --- a/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt +++ b/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt @@ -2232,6 +2232,9 @@ class ServiceReticulumProtocol( fileAttachments: List>?, replyToMessageId: String?, iconAppearance: IconAppearance?, + telemetryJson: String?, + audioData: ByteArray?, + sosState: String?, ): Result = withContext(Dispatchers.IO) { runCatching { @@ -2282,6 +2285,22 @@ class ServiceReticulumProtocol( } } + // Handle audio data by writing to temp file if needed + var smallAudioData: ByteArray? = null + var audioDataPath: String? = null + if (audioData != null) { + if (audioData.size <= FileUtils.FILE_TRANSFER_THRESHOLD) { + smallAudioData = audioData + } else { + val tempFile = + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + FileUtils.writeTempAttachment(context, "sos_audio.m4a", audioData) + } + audioDataPath = tempFile.absolutePath + Log.d(TAG, "Large audio (${audioData.size} bytes) written to temp file") + } + } + val resultJson = service.sendLxmfMessageWithMethod( destinationHash, @@ -2298,6 +2317,10 @@ class ServiceReticulumProtocol( iconAppearance?.iconName, iconAppearance?.foregroundColor, iconAppearance?.backgroundColor, + telemetryJson, + smallAudioData, + audioDataPath, + sosState, ) val result = JSONObject(resultJson) diff --git a/app/src/main/java/com/lxmf/messenger/service/LocalHotspotManager.kt b/app/src/main/java/com/lxmf/messenger/service/LocalHotspotManager.kt index 4ca09cfb1..58297d5fc 100644 --- a/app/src/main/java/com/lxmf/messenger/service/LocalHotspotManager.kt +++ b/app/src/main/java/com/lxmf/messenger/service/LocalHotspotManager.kt @@ -63,9 +63,13 @@ class LocalHotspotManager(private val context: Context) { callback: (Result) -> Unit, ) { if (!isSupported()) { - callback(Result.failure(UnsupportedOperationException( - "Local-only hotspot requires Android 8.0 or higher" - ))) + callback( + Result.failure( + UnsupportedOperationException( + "Local-only hotspot requires Android 8.0 or higher", + ), + ), + ) return } @@ -79,8 +83,9 @@ class LocalHotspotManager(private val context: Context) { onSystemStoppedListener = onSystemStopped - val wifiManager = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager + val wifiManager = + context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager try { wifiManager.startLocalOnlyHotspot( @@ -101,18 +106,19 @@ class LocalHotspotManager(private val context: Context) { override fun onFailed(reason: Int) { Log.e(TAG, "Local hotspot failed with reason: $reason") reservation = null - val message = when (reason) { - ERROR_TETHERING_DISALLOWED -> - "Hotspot is not allowed by device policy" - ERROR_INCOMPATIBLE_MODE -> - "WiFi is in a mode that prevents hotspot creation" - ERROR_NO_CHANNEL -> - "No WiFi channel available for hotspot" - ERROR_GENERIC -> - "Could not start hotspot" - else -> - "Hotspot failed (error $reason)" - } + val message = + when (reason) { + ERROR_TETHERING_DISALLOWED -> + "Hotspot is not allowed by device policy" + ERROR_INCOMPATIBLE_MODE -> + "WiFi is in a mode that prevents hotspot creation" + ERROR_NO_CHANNEL -> + "No WiFi channel available for hotspot" + ERROR_GENERIC -> + "Could not start hotspot" + else -> + "Hotspot failed (error $reason)" + } callback(Result.failure(HotspotException(message, reason))) } }, @@ -143,9 +149,7 @@ class LocalHotspotManager(private val context: Context) { } @Suppress("DEPRECATION") - private fun extractHotspotInfo( - reservation: WifiManager.LocalOnlyHotspotReservation, - ): HotspotInfo { + private fun extractHotspotInfo(reservation: WifiManager.LocalOnlyHotspotReservation): HotspotInfo { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // API 33+: Use SoftApConfiguration with getWifiSsid() val config = reservation.softApConfiguration diff --git a/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt b/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt index 51571cffc..04396f419 100644 --- a/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt +++ b/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt @@ -11,7 +11,6 @@ import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority -import com.lxmf.messenger.util.LocationCompat import com.lxmf.messenger.data.db.dao.ReceivedLocationDao import com.lxmf.messenger.data.db.entity.ReceivedLocationEntity import com.lxmf.messenger.data.model.LocationTelemetry @@ -20,6 +19,7 @@ import com.lxmf.messenger.repository.SettingsRepository import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol import com.lxmf.messenger.reticulum.protocol.ServiceReticulumProtocol import com.lxmf.messenger.ui.model.SharingDuration +import com.lxmf.messenger.util.LocationCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/app/src/main/java/com/lxmf/messenger/service/MessageCollector.kt b/app/src/main/java/com/lxmf/messenger/service/MessageCollector.kt index 75a0bb886..0da3f1693 100644 --- a/app/src/main/java/com/lxmf/messenger/service/MessageCollector.kt +++ b/app/src/main/java/com/lxmf/messenger/service/MessageCollector.kt @@ -2,14 +2,20 @@ package com.lxmf.messenger.service import android.util.Log import com.lxmf.messenger.data.db.dao.PeerIconDao +import com.lxmf.messenger.data.db.dao.ReceivedLocationDao import com.lxmf.messenger.data.db.entity.ContactStatus import com.lxmf.messenger.data.db.entity.PeerIconEntity +import com.lxmf.messenger.data.db.entity.ReceivedLocationEntity import com.lxmf.messenger.data.model.InterfaceType import com.lxmf.messenger.data.repository.AnnounceRepository import com.lxmf.messenger.data.repository.ContactRepository import com.lxmf.messenger.data.repository.ConversationRepository import com.lxmf.messenger.data.repository.IdentityRepository import com.lxmf.messenger.notifications.NotificationHelper +import com.lxmf.messenger.notifications.isSosCancelledByField +import com.lxmf.messenger.notifications.isSosMessageByField +import com.lxmf.messenger.notifications.isSosUpdateByField +import com.lxmf.messenger.notifications.parseSosLocation import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol import com.lxmf.messenger.service.util.PeerNameResolver import kotlinx.coroutines.CoroutineScope @@ -48,6 +54,7 @@ class MessageCollector private val identityRepository: IdentityRepository, private val notificationHelper: NotificationHelper, private val peerIconDao: PeerIconDao, + private val receivedLocationDao: ReceivedLocationDao, private val conversationLinkManager: ConversationLinkManager, ) { companion object { @@ -82,6 +89,21 @@ class MessageCollector isStarted = true Log.i(TAG, "Starting message collection service") + // Restore SOS active senders from DB (needed after stop/restart within same session) + scope.launch { + try { + val recentSenders = receivedLocationDao.getRecentSosTrailSenders( + sinceTimestamp = System.currentTimeMillis() - 24 * 3600_000L, + ) + if (recentSenders.isNotEmpty()) { + SosActiveTracker.restoreFromSenders(recentSenders.toSet()) + Log.d(TAG, "Restored ${recentSenders.size} SOS active senders") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to restore SOS active senders", e) + } + } + // Collect messages from the Reticulum protocol scope.launch { // Pre-seed processedMessageIds with recent received messages from the DB. @@ -150,17 +172,74 @@ class MessageCollector false } - // Only notify if the message hasn't been read yet + // Track SOS active state (works for both new and already-persisted messages) + // Primary: check FIELD_COMMANDS sos_state in fieldsJson; Fallback: text-based + val fieldsJson = receivedMessage.fieldsJson + if (isSosCancelledByField(receivedMessage.content, fieldsJson)) { + SosActiveTracker.removeSender(sourceHash) + receivedLocationDao.deleteSosTrailForSender(sourceHash) + notificationHelper.cancelNotification( + NotificationHelper.NOTIFICATION_ID_SOS xor (sourceHash.hashCode() and 0x7FFFFFFF), + ) + notificationHelper.notifyMessageReceived( + destinationHash = sourceHash, + peerName = peerName, + messagePreview = receivedMessage.content.take(100), + isFavorite = isFavorite, + ) + Log.d(TAG, "Cleared SOS active and notification for ${sourceHash.take(16)} (already-persisted)") + } else if (isSosMessageByField(receivedMessage.content, fieldsJson)) { + SosActiveTracker.addSender(sourceHash) + Log.d(TAG, "Set SOS active for ${sourceHash.take(16)} (already-persisted)") + } + + // Only notify and store trail for unread messages + // This prevents duplicate trail rows on service restart // This prevents duplicate notifications after service restart // for messages the user has already seen if (!existingMessage.isRead) { + // Store SOS trail location (only for unread = first processing) + if (isSosMessageByField(receivedMessage.content, fieldsJson)) { + val trailLocation = parseSosLocation(receivedMessage.content, fieldsJson) + if (trailLocation != null) { + try { + receivedLocationDao.insert( + ReceivedLocationEntity( + id = java.util.UUID.randomUUID().toString(), + senderHash = sourceHash, + latitude = trailLocation.first, + longitude = trailLocation.second, + accuracy = 0f, + timestamp = receivedMessage.timestamp, + expiresAt = null, + source = ReceivedLocationEntity.SOURCE_SOS_TRAIL, + receivedAt = System.currentTimeMillis(), + ), + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to store SOS trail location", e) + } + } + } try { - notificationHelper.notifyMessageReceived( - destinationHash = sourceHash, - peerName = peerName, - messagePreview = receivedMessage.content.take(100), - isFavorite = isFavorite, - ) + if (isSosMessageByField(receivedMessage.content, fieldsJson)) { + val location = parseSosLocation(receivedMessage.content, fieldsJson) + notificationHelper.notifySosReceived( + destinationHash = sourceHash, + peerName = peerName, + messageContent = receivedMessage.content, + latitude = location?.first, + longitude = location?.second, + isUpdate = isSosUpdateByField(receivedMessage.content, fieldsJson), + ) + } else { + notificationHelper.notifyMessageReceived( + destinationHash = sourceHash, + peerName = peerName, + messagePreview = receivedMessage.content.take(100), + isFavorite = isFavorite, + ) + } Log.d(TAG, "Posted notification for already-persisted unread message") } catch (e: Exception) { Log.e(TAG, "Failed to post notification for already-persisted message", e) @@ -280,16 +359,63 @@ class MessageCollector false } - // Show notification for received message + // Show notification - SOS messages get urgent treatment try { - notificationHelper.notifyMessageReceived( - destinationHash = sourceHash, - peerName = peerName, - // Truncate preview - messagePreview = receivedMessage.content.take(100), - isFavorite = isFavorite, - ) - Log.d(TAG, "Posted notification for message (favorite: $isFavorite)") + val newFieldsJson = receivedMessage.fieldsJson + if (isSosCancelledByField(receivedMessage.content, newFieldsJson)) { + SosActiveTracker.removeSender(sourceHash) + receivedLocationDao.deleteSosTrailForSender(sourceHash) + notificationHelper.cancelNotification( + NotificationHelper.NOTIFICATION_ID_SOS xor (sourceHash.hashCode() and 0x7FFFFFFF), + ) + notificationHelper.notifyMessageReceived( + destinationHash = sourceHash, + peerName = peerName, + messagePreview = receivedMessage.content.take(100), + isFavorite = isFavorite, + ) + Log.d(TAG, "Cleared SOS active and notification for ${sourceHash.take(16)}") + } else if (isSosMessageByField(receivedMessage.content, newFieldsJson)) { + SosActiveTracker.addSender(sourceHash) + val location = parseSosLocation(receivedMessage.content, newFieldsJson) + notificationHelper.notifySosReceived( + destinationHash = sourceHash, + peerName = peerName, + messageContent = receivedMessage.content, + latitude = location?.first, + longitude = location?.second, + isUpdate = isSosUpdateByField(receivedMessage.content, newFieldsJson), + ) + // Store SOS location for breadcrumb trail + if (location != null) { + try { + receivedLocationDao.insert( + ReceivedLocationEntity( + id = java.util.UUID.randomUUID().toString(), + senderHash = sourceHash, + latitude = location.first, + longitude = location.second, + accuracy = 0f, + timestamp = receivedMessage.timestamp, + expiresAt = null, + source = ReceivedLocationEntity.SOURCE_SOS_TRAIL, + receivedAt = System.currentTimeMillis(), + ), + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to store SOS location for trail", e) + } + } + Log.d(TAG, "Posted SOS notification for message from ${sourceHash.take(16)}") + } else { + notificationHelper.notifyMessageReceived( + destinationHash = sourceHash, + peerName = peerName, + messagePreview = receivedMessage.content.take(100), + isFavorite = isFavorite, + ) + Log.d(TAG, "Posted notification for message (favorite: $isFavorite)") + } } catch (e: Exception) { Log.e(TAG, "Failed to post message notification", e) } @@ -514,6 +640,7 @@ class MessageCollector // This ensures fresh data is fetched from database or announces when restarted peerNames.clear() processedMessageIds.clear() + SosActiveTracker.clear() Log.i(TAG, "Stopped message collection service (caches cleared)") } diff --git a/app/src/main/java/com/lxmf/messenger/service/SosActiveTracker.kt b/app/src/main/java/com/lxmf/messenger/service/SosActiveTracker.kt new file mode 100644 index 000000000..e69070a56 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/service/SosActiveTracker.kt @@ -0,0 +1,50 @@ +package com.lxmf.messenger.service + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * Tracks which contacts currently have an active SOS (receiver side). + * Shared between MessageCollector and UI ViewModels. + */ +object SosActiveTracker { + private val _activeSenders = MutableStateFlow>(emptySet()) + val activeSenders: StateFlow> = _activeSenders.asStateFlow() + + // Senders explicitly removed since last clear — prevents stale restore after app restart + private val explicitlyRemoved = mutableSetOf() + + fun addSender(hash: String) { + synchronized(explicitlyRemoved) { + explicitlyRemoved.remove(hash) + _activeSenders.update { it + hash } + } + } + + fun removeSender(hash: String) { + synchronized(explicitlyRemoved) { + explicitlyRemoved.add(hash) + _activeSenders.update { it - hash } + } + } + + fun isActive(hash: String): Flow = _activeSenders.map { it.contains(hash) } + + fun restoreFromSenders(senders: Set) { + synchronized(explicitlyRemoved) { + val safeSet = senders - explicitlyRemoved + _activeSenders.update { it + safeSet } + } + } + + fun clear() { + synchronized(explicitlyRemoved) { + explicitlyRemoved.clear() + _activeSenders.value = emptySet() + } + } +} diff --git a/app/src/main/java/com/lxmf/messenger/service/SosAudioRecorder.kt b/app/src/main/java/com/lxmf/messenger/service/SosAudioRecorder.kt new file mode 100644 index 000000000..d2a6f5b87 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/service/SosAudioRecorder.kt @@ -0,0 +1,165 @@ +package com.lxmf.messenger.service + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Records audio for SOS emergency messages using AAC codec in M4A container. + * + * Produces compact audio files suitable for LXMF FIELD_AUDIO: + * ~24 kbps AAC = ~90 KB for 30 seconds, well within the 1 MB message limit. + */ +@Singleton +class SosAudioRecorder + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + companion object { + private const val TAG = "SosAudioRecorder" + private const val SAMPLE_RATE = 16000 + private const val BIT_RATE = 24000 + } + + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + + private val lock = Any() + + val isRecording: Boolean get() = synchronized(lock) { recorder != null } + + fun hasPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + + /** + * Start recording audio to a temporary file. + * + * @return true if recording started, false if permission missing or error. + */ + fun start(): Boolean { + if (!hasPermission()) { + Log.w(TAG, "RECORD_AUDIO permission not granted") + return false + } + + synchronized(lock) { + if (recorder != null) { + Log.w(TAG, "Already recording, ignoring start") + return true + } + + val file = File(context.cacheDir, "sos_audio.m4a") + outputFile = file + + @Suppress("DEPRECATION") + val mr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + return try { + mr.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(SAMPLE_RATE) + setAudioEncodingBitRate(BIT_RATE) + setAudioChannels(1) + setOutputFile(file.absolutePath) + prepare() + start() + } + // Assign only after start() succeeds — a concurrent cancel()/stopRecorder() + // seeing recorder != null must find a MediaRecorder in the Started state. + recorder = mr + Log.d(TAG, "Audio recording started: ${file.absolutePath}") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to start audio recording", e) + try { mr.release() } catch (_: Exception) {} + outputFile?.delete() + outputFile = null + false + } + } + } + + /** + * Stop recording and return the audio bytes. + * + * @return audio data as ByteArray, or null on error. + */ + /** Stop the recorder (must be called on main thread for MediaRecorder). */ + fun stopRecorder() { + synchronized(lock) { + try { + recorder?.stop() + } catch (e: Exception) { + Log.e(TAG, "Failed to stop recorder", e) + } + try { + recorder?.release() + } catch (e: Exception) { + Log.e(TAG, "Failed to release recorder", e) + } + recorder = null + } + } + + /** Read and delete the output file (safe to call from IO thread). */ + fun readAndDeleteOutputFile(): ByteArray? { + val file = synchronized(lock) { + val f = outputFile + outputFile = null + f + } ?: return null + return try { + val bytes = file.readBytes() + file.delete() + Log.d(TAG, "Audio recording stopped: ${bytes.size} bytes") + bytes + } catch (e: Exception) { + Log.e(TAG, "Failed to read audio file", e) + file.delete() + null + } + } + + /** + * Cancel recording without returning data. + */ + fun cancel() { + synchronized(lock) { cleanup() } + Log.d(TAG, "Audio recording cancelled") + } + + private fun cleanup() { + try { + recorder?.stop() + } catch (_: Exception) { + // Ignore — may not have started + } + try { + recorder?.release() + } catch (_: Exception) { + // Ignore + } + recorder = null + outputFile?.delete() + outputFile = null + } + } diff --git a/app/src/main/java/com/lxmf/messenger/service/SosManager.kt b/app/src/main/java/com/lxmf/messenger/service/SosManager.kt new file mode 100644 index 000000000..250a1cce4 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/service/SosManager.kt @@ -0,0 +1,618 @@ +package com.lxmf.messenger.service + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.os.BatteryManager +import android.util.Log +import com.lxmf.messenger.data.repository.ContactRepository +import com.lxmf.messenger.notifications.NotificationHelper +import com.lxmf.messenger.repository.SettingsRepository +import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol +import com.lxmf.messenger.reticulum.protocol.ServiceReticulumProtocol +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Represents the current state of the SOS system. + */ +sealed class SosState { + data object Idle : SosState() + + data class Countdown( + val remainingSeconds: Int, + val totalSeconds: Int, + ) : SosState() + + data object Sending : SosState() + + data class Active( + val sentCount: Int, + val failedCount: Int, + ) : SosState() +} + +/** + * Manages the SOS emergency messaging state machine. + * + * State flow: Idle -> Countdown -> Sending -> Active -> Idle + * + * When triggered, the manager reads SOS settings, optionally counts down, + * sends emergency messages to all configured SOS contacts, and enters + * an active state with optional periodic location updates. + */ +@Suppress("TooManyFunctions") +@Singleton +class SosManager + @Inject + constructor( + @ApplicationContext private val context: Context, + private val contactRepository: ContactRepository, + private val settingsRepository: SettingsRepository, + private val reticulumProtocol: ReticulumProtocol, + private val notificationHelper: NotificationHelper, + private val audioRecorder: SosAudioRecorder, + ) { + companion object { + private const val TAG = "SosManager" + } + + /** Overridable for testing — provides current location. */ + internal var locationProvider: (suspend () -> Location?)? = null + + /** Override in tests to use a test dispatcher. */ + internal var dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default + + private val scope = + CoroutineScope(SupervisorJob() + dispatcher).also { s -> + s.launch { + launch { settingsRepository.sosDeactivationPin.collect { cachedDeactivationPin = it } } + launch { settingsRepository.sosSilentAutoAnswer.collect { cachedSilentAutoAnswer = it } } + } + } + + private val _state = MutableStateFlow(SosState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val isTriggerRunning = java.util.concurrent.atomic.AtomicBoolean(false) + private val triggerGeneration = java.util.concurrent.atomic.AtomicLong(0) + @Volatile private var triggerJob: Job? = null + @Volatile private var countdownJob: Job? = null + @Volatile private var periodicUpdateJob: Job? = null + @Volatile private var audioRecordingJob: Job? = null + + @Volatile private var cachedDeactivationPin: String? = null + + @Volatile private var cachedSilentAutoAnswer: Boolean = false + + /** + * Restore persisted SOS active state after app/phone restart. + * Should be called once at app startup (e.g., from Application.onCreate or service init). + */ + fun restoreIfActive() { + scope.launch { + try { + val wasActive = settingsRepository.sosActive.first() + if (!wasActive) return@launch + // Atomically claim the trigger slot — prevents concurrent trigger() from racing + if (!isTriggerRunning.compareAndSet(false, true)) return@launch + try { + if (_state.value !is SosState.Idle) return@launch + + val sentCount = settingsRepository.sosActiveSentCount.first() + val failedCount = settingsRepository.sosActiveFailedCount.first() + _state.value = SosState.Active(sentCount, failedCount) + notificationHelper.showSosActiveNotification(sentCount, failedCount) + Log.d(TAG, "Restored SOS active state: sent=$sentCount, failed=$failedCount") + + val periodicUpdates = settingsRepository.sosPeriodicUpdates.first() + if (periodicUpdates) { + startPeriodicUpdates() + } + } finally { + isTriggerRunning.set(false) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error restoring SOS state", e) + } + } + } + + /** + * Trigger the SOS sequence. Reads settings to determine countdown duration, + * then proceeds to send emergency messages to all SOS contacts. + */ + fun trigger() { + if (!isTriggerRunning.compareAndSet(false, true)) return + val myGeneration = triggerGeneration.incrementAndGet() + triggerJob = + scope.launch { + try { + if (_state.value !is SosState.Idle) { + Log.d(TAG, "SOS already in progress (${_state.value}), ignoring trigger") + return@launch + } + + val enabled = settingsRepository.sosEnabled.first() + if (!enabled) { + Log.d(TAG, "SOS not enabled, ignoring trigger") + return@launch + } + + val countdownSeconds = settingsRepository.sosCountdownSeconds.first() + if (countdownSeconds <= 0) { + sendSosMessages() + } else { + startCountdown(countdownSeconds) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error triggering SOS", e) + _state.value = SosState.Idle + } finally { + // Only reset if no newer trigger has started + if (triggerGeneration.get() == myGeneration) { + isTriggerRunning.set(false) + } + } + } + } + + /** + * Cancel the SOS during the countdown phase. Returns to Idle. + */ + fun cancel() { + if (_state.value is SosState.Countdown) { + countdownJob?.cancel() + countdownJob = null + triggerJob?.cancel() + triggerJob = null + triggerGeneration.incrementAndGet() + isTriggerRunning.set(false) + _state.value = SosState.Idle + Log.d(TAG, "SOS countdown cancelled") + } + } + + /** + * Deactivate SOS from the Active state. + * + * @param pin Optional PIN for deactivation. If a deactivation PIN is configured + * in settings, the provided pin must match. + * @return true if successfully deactivated, false if PIN mismatch or not in Active state. + */ + fun deactivate(pin: String? = null): Boolean { + if (_state.value !is SosState.Active) return false + + val requiredPin = cachedDeactivationPin + if (!requiredPin.isNullOrBlank() && requiredPin != pin) { + Log.d(TAG, "SOS deactivation PIN mismatch") + return false + } + + triggerJob?.cancel() + triggerJob = null + triggerGeneration.incrementAndGet() + isTriggerRunning.set(false) + periodicUpdateJob?.cancel() + periodicUpdateJob = null + audioRecordingJob?.cancel() + audioRecordingJob = null + audioRecorder.cancel() + notificationHelper.cancelNotification(NotificationHelper.NOTIFICATION_ID_SOS) + _state.value = SosState.Idle + scope.launch { + settingsRepository.clearSosActiveState() + sendCancellationMessage() + } + Log.d(TAG, "SOS deactivated") + return true + } + + /** + * Force deactivate SOS bypassing PIN check. + * Used when the feature toggle is disabled at the app level. + */ + fun forceDeactivate() { + if (_state.value is SosState.Idle) return + triggerJob?.cancel() + triggerJob = null + triggerGeneration.incrementAndGet() + isTriggerRunning.set(false) + countdownJob?.cancel() + countdownJob = null + periodicUpdateJob?.cancel() + periodicUpdateJob = null + audioRecordingJob?.cancel() + audioRecordingJob = null + audioRecorder.cancel() + notificationHelper.cancelNotification(NotificationHelper.NOTIFICATION_ID_SOS) + val shouldSendCancellation = _state.value is SosState.Active || _state.value is SosState.Sending + _state.value = SosState.Idle + scope.launch { + settingsRepository.clearSosActiveState() + if (shouldSendCancellation) sendCancellationMessage() + } + Log.d(TAG, "SOS force-deactivated (feature toggle disabled)") + } + + /** + * Check if incoming calls should be auto-answered due to active SOS. + * + * @return true if SOS is active and silent auto-answer is enabled in settings. + */ + fun shouldAutoAnswer(): Boolean { + if (_state.value !is SosState.Active) return false + return cachedSilentAutoAnswer + } + + private suspend fun startCountdown(totalSeconds: Int) { + // Use coroutineScope so countdown is a child of the caller (triggerJob). + // Cancelling triggerJob in forceDeactivate() will also cancel the countdown. + coroutineScope { + countdownJob = + launch { + try { + for (remaining in totalSeconds downTo 1) { + _state.value = SosState.Countdown(remaining, totalSeconds) + delay(1_000L) + } + sendSosMessages() + } catch (e: CancellationException) { + Log.d(TAG, "Countdown coroutine cancelled") + throw e + } catch (e: Exception) { + Log.e(TAG, "Error during countdown/send", e) + _state.value = SosState.Idle + } + } + } + } + + @SuppressLint("MissingPermission") + private suspend fun sendSosMessages() { + kotlin.coroutines.coroutineContext.ensureActive() // Honour pending cancellation before touching state + _state.value = SosState.Sending + + val template = settingsRepository.sosMessageTemplate.first() + val includeLocation = settingsRepository.sosIncludeLocation.first() + + val location = if (includeLocation) getLastKnownLocation() else null + val batteryLevel = getBatteryLevel() + + val messageContent = + buildString { + append(template) + location?.let { + append("\nGPS: ${String.format(java.util.Locale.US, "%.6f", it.latitude)}, ${String.format(java.util.Locale.US, "%.6f", it.longitude)}") + append(" (accuracy: ${it.accuracy.toInt()}m)") + } + batteryLevel?.let { level -> + append("\nBattery: $level%") + } + } + + // Build FIELD_TELEMETRY JSON for Sideband-compatible telemetry + val telemetryJson = buildTelemetryJson(location, batteryLevel) + + val contacts = contactRepository.getSosContacts() + if (contacts.isEmpty()) { + Log.w(TAG, "No SOS contacts configured") + _state.value = SosState.Active(sentCount = 0, failedCount = 0) + settingsRepository.persistSosActiveState(0, 0) + notificationHelper.showSosActiveNotification(0, 0) + return + } + + val identity = loadIdentity() + if (identity == null) { + Log.e(TAG, "Failed to load identity, cannot send SOS messages") + _state.value = SosState.Active(sentCount = 0, failedCount = contacts.size) + settingsRepository.persistSosActiveState(0, contacts.size) + notificationHelper.showSosActiveNotification(0, contacts.size) + return + } + + var sentCount = 0 + var failedCount = 0 + + for (contact in contacts) { + try { + val destHashBytes = contact.destinationHash.hexToByteArray() + val result = + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = destHashBytes, + content = messageContent, + sourceIdentity = identity, + telemetryJson = telemetryJson, + sosState = "active", + ) + if (result.isSuccess) { + sentCount++ + Log.d(TAG, "SOS message sent to ${contact.destinationHash.take(8)}...") + } else { + failedCount++ + Log.e(TAG, "SOS message failed for ${contact.destinationHash.take(8)}...: ${result.exceptionOrNull()?.message}") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + failedCount++ + Log.e(TAG, "Error sending SOS message to ${contact.destinationHash.take(8)}...", e) + } + } + + kotlin.coroutines.coroutineContext.ensureActive() // honour pending cancellation before touching state + _state.value = SosState.Active(sentCount, failedCount) + settingsRepository.persistSosActiveState(sentCount, failedCount) + notificationHelper.showSosActiveNotification(sentCount, failedCount) + Log.d(TAG, "SOS messages sent: $sentCount success, $failedCount failed") + + // Check cancellation before launching jobs on scope (not children of triggerJob). + // Without this, deactivate() can cancel triggerJob but these jobs still launch. + kotlin.coroutines.coroutineContext.ensureActive() + + val periodicUpdates = settingsRepository.sosPeriodicUpdates.first() + if (periodicUpdates && _state.value is SosState.Active) { + startPeriodicUpdates() + } + + kotlin.coroutines.coroutineContext.ensureActive() + + val audioEnabled = settingsRepository.sosAudioEnabled.first() + if (audioEnabled && _state.value is SosState.Active) { + startAudioRecording(identity, contacts.map { it.destinationHash }) + } + } + + @SuppressLint("MissingPermission") + private fun startPeriodicUpdates() { + if (_state.value !is SosState.Active) return + periodicUpdateJob?.cancel() + periodicUpdateJob = + scope.launch { + val intervalSeconds = settingsRepository.sosUpdateIntervalSeconds.first() + + // Wait for the Reticulum service to be bound and ready before + // loading identity. At boot, the mesh network may not be up yet. + if (reticulumProtocol is ServiceReticulumProtocol) { + try { + reticulumProtocol.bindService() + reticulumProtocol.waitForReady(timeoutMs = 30_000) + } catch (e: Exception) { + ensureActive() + Log.w(TAG, "Reticulum not ready yet, periodic updates will retry", e) + } + } + + var identity = loadIdentity() + + while (true) { + delay(intervalSeconds * 1_000L) + + // Retry identity load if it failed on first attempt (service wasn't ready) + if (identity == null) { + identity = loadIdentity() + if (identity == null) continue + } + + val updateLocation = getLastKnownLocation() + val updateBattery = getBatteryLevel() + + val updateMessage = + buildString { + append("SOS Update") + updateLocation?.let { loc -> + append(" - GPS: ${String.format(java.util.Locale.US, "%.6f", loc.latitude)}, ${String.format(java.util.Locale.US, "%.6f", loc.longitude)}") + append(" (accuracy: ${loc.accuracy.toInt()}m)") + } + updateBattery?.let { level -> + append(" - Battery: $level%") + } + } + + val updateTelemetry = buildTelemetryJson(updateLocation, updateBattery) + + try { + val contacts = contactRepository.getSosContacts() + for (contact in contacts) { + try { + val destHashBytes = contact.destinationHash.hexToByteArray() + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = destHashBytes, + content = updateMessage, + sourceIdentity = identity, + telemetryJson = updateTelemetry, + sosState = "update", + ) + } catch (e: Exception) { + ensureActive() + Log.e(TAG, "Error sending SOS update to ${contact.destinationHash.take(8)}...", e) + } + } + Log.d(TAG, "Periodic SOS update sent to ${contacts.size} contacts") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error fetching SOS contacts for periodic update", e) + } + } + } + } + + private fun startAudioRecording( + identity: com.lxmf.messenger.reticulum.model.Identity, + contactHashes: List, + ) { + audioRecordingJob = + scope.launch { + val durationSeconds = settingsRepository.sosAudioDurationSeconds.first() + + val started = withContext(Dispatchers.IO) { audioRecorder.start() } + if (!started) { + Log.w(TAG, "Audio recording failed to start") + return@launch + } + + Log.d(TAG, "SOS audio recording for ${durationSeconds}s") + delay(durationSeconds * 1_000L) + + if (_state.value !is SosState.Active) { + Log.d(TAG, "SOS deactivated during audio recording, discarding") + withContext(Dispatchers.IO) { audioRecorder.cancel() } + return@launch + } + + withContext(Dispatchers.IO) { audioRecorder.stopRecorder() } + val audioBytes = withContext(Dispatchers.IO) { audioRecorder.readAndDeleteOutputFile() } + if (audioBytes == null) { + Log.w(TAG, "Audio recording returned no data") + return@launch + } + + Log.d(TAG, "Sending SOS audio (${audioBytes.size} bytes) to ${contactHashes.size} contacts") + for (hash in contactHashes) { + try { + val destHashBytes = hash.hexToByteArray() + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = destHashBytes, + content = "Audio from SOS alert", + sourceIdentity = identity, + audioData = audioBytes, + sosState = "active", + ) + } catch (e: Exception) { + ensureActive() + Log.e(TAG, "Error sending SOS audio to ${hash.take(8)}...", e) + } + } + Log.d(TAG, "SOS audio sent to ${contactHashes.size} contacts") + } + } + + @SuppressLint("MissingPermission") + private suspend fun getLastKnownLocation(): Location? = + try { + locationProvider?.invoke() + ?: kotlinx.coroutines.suspendCancellableCoroutine { cont -> + com.lxmf.messenger.util.LocationCompat.getCurrentLocation(context) { location -> + cont.resume(location, null) + } + } + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error getting location", e) + null + } + + private fun buildTelemetryJson( + location: Location?, + batteryLevel: Int?, + ): String? { + if (location == null) return null // Don't emit telemetry without a real position (avoids Null Island 0,0) + return JSONObject().apply { + put("lat", location.latitude) + put("lng", location.longitude) + put("acc", location.accuracy.toDouble()) + put("ts", System.currentTimeMillis()) + if (location.hasAltitude()) put("altitude", location.altitude) + if (location.hasSpeed()) put("speed", location.speed.toDouble()) + if (location.hasBearing()) put("bearing", location.bearing.toDouble()) + if (batteryLevel != null) { + put("battery_percent", batteryLevel) + put("battery_charging", isBatteryCharging()) + } + }.toString() + } + + private fun isBatteryCharging(): Boolean = + try { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + batteryManager.isCharging + } catch (e: Exception) { + Log.w(TAG, "Error checking battery charging state", e) + false + } + + private fun getBatteryLevel(): Int? = + try { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + if (level in 0..100) level else null + } catch (e: Exception) { + Log.e(TAG, "Error getting battery level", e) + null + } + + private suspend fun loadIdentity(): com.lxmf.messenger.reticulum.model.Identity? = + try { + if (reticulumProtocol is ServiceReticulumProtocol) { + reticulumProtocol.getLxmfIdentity().getOrNull() + } else { + reticulumProtocol.loadIdentity("default_identity").getOrNull() + ?: reticulumProtocol.createIdentity().getOrThrow().also { + reticulumProtocol.saveIdentity(it, "default_identity") + } + } + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error loading identity", e) + null + } + + private suspend fun sendCancellationMessage() { + try { + val contacts = contactRepository.getSosContacts() + if (contacts.isEmpty()) return + val identity = loadIdentity() ?: return + for (contact in contacts) { + try { + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = contact.destinationHash.hexToByteArray(), + content = "SOS Cancelled — I am safe.", + sourceIdentity = identity, + sosState = "cancelled", + ) + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to send cancellation to ${contact.destinationHash.take(8)}", e) + } + } + Log.d(TAG, "SOS cancellation sent to ${contacts.size} contacts") + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error sending cancellation messages", e) + } + } + + /** + * Convert a hex string to a ByteArray. + */ + private fun String.hexToByteArray(): ByteArray { + check(length % 2 == 0) { "Hex string must have even length" } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + } diff --git a/app/src/main/java/com/lxmf/messenger/service/SosTriggerDetector.kt b/app/src/main/java/com/lxmf/messenger/service/SosTriggerDetector.kt new file mode 100644 index 000000000..21d1b2f20 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/service/SosTriggerDetector.kt @@ -0,0 +1,486 @@ +package com.lxmf.messenger.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.util.Log +import com.lxmf.messenger.repository.SettingsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.sqrt + +/** + * Trigger mode for SOS activation. + */ +enum class SosTriggerMode(val key: String) { + SHAKE("shake"), + TAP_PATTERN("tap_pattern"), + POWER_BUTTON("power_button"), + ; + + companion object { + fun fromKey(key: String): SosTriggerMode? = entries.find { it.key == key } + + fun fromKeys(keys: Set): Set = keys.mapNotNull { fromKey(it) }.toSet() + } +} + +/** + * Detects SOS trigger gestures via the device accelerometer. + * + * Supports two detection modes: + * - **Shake**: Sustained high acceleration (magnitude minus gravity exceeds threshold). + * Requires the threshold to be exceeded for [SHAKE_DURATION_MS] within a + * [SHAKE_WINDOW_MS] window to avoid false positives from single bumps. + * - **Tap pattern**: A sequence of sharp acceleration spikes (taps) within a time window. + * The required number of taps is configurable (3-5). + * + * The detector registers/unregisters itself based on [start]/[stop] calls. + * It should be started when SOS is enabled with a non-MANUAL trigger mode, + * and stopped otherwise. + */ +/** Snapshot of SOS settings for combine/distinctUntilChanged. */ +private data class SosSettingsSnapshot( + val enabled: Boolean, + val modes: Set, + val sosActive: Boolean, +) + +@Singleton +class SosTriggerDetector + @Inject + constructor( + @ApplicationContext private val context: Context, + private val settingsRepository: SettingsRepository, + private val sosManager: SosManager, + ) : SensorEventListener { + companion object { + private const val TAG = "SosTriggerDetector" + + // Shake detection constants + private const val SHAKE_WINDOW_MS = 1_000L + private const val SHAKE_DURATION_MS = 500L + private const val SHAKE_COOLDOWN_MS = 5_000L + + // Max gap between above-threshold samples allowed before resetting accumulation. + // Shorter than SHAKE_DURATION_MS so bursty motion (e.g. walking/running bumps + // spaced ~150ms apart) cannot masquerade as sustained shaking. + private const val SHAKE_GAP_RESET_MS = 200L + + // Tap detection constants + // Spike-based detection: a tap is counted only when netAcceleration crosses above + // TAP_THRESHOLD and returns below it within MAX_TAP_SPIKE_MS. + // Walking/running steps last >100ms at the sensor → rejected by duration filter. + // This lets us use a lower threshold without false positives from sustained motion. + private const val TAP_THRESHOLD = 4.0f // m/s² to enter a spike + private const val MAX_TAP_SPIKE_MS = 100L // valid tap spike must end within this + private const val TAP_WINDOW_MS = 2_500L // sliding window to accumulate taps + private const val TAP_MIN_INTERVAL_MS = 150L // min time between two registered taps + private const val TAP_COOLDOWN_MS = 5_000L + + // Power button detection constants + private const val POWER_PRESS_COUNT = 3 + private const val POWER_PRESS_WINDOW_MS = 2_000L + private const val POWER_COOLDOWN_MS = 5_000L + } + + private val sensorManager by lazy { + context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Volatile private var isSensorListening = false + @Volatile private var isPowerButtonListening = false + + /** Active trigger modes. Override in tests. */ + internal var activeModes: Set = emptySet() + + /** Override in tests to control shake sensitivity. */ + internal var shakeSensitivity = 2.5f + + /** Override in tests to control required tap count. */ + internal var requiredTapCount = 3 + private var settingsJob: Job? = null + + // Shake state + @Volatile private var shakeStartTime = 0L + + @Volatile private var shakeAccumulatedMs = 0L + + // Timestamp of the previous sample IF it was above threshold, else 0L. + // Used to credit dt into the accumulator only for continuous above-threshold runs. + @Volatile private var lastShakeEventTime = 0L + + // Timestamp of the most recent above-threshold sample, persistent across dips. + // Used to detect long gaps (> SHAKE_GAP_RESET_MS) and reset the accumulator. + @Volatile private var lastAboveTime = 0L + + @Volatile private var lastShakeTriggerTime = 0L + + // Tap state + private val tapTimestamps = java.util.Collections.synchronizedList(mutableListOf()) + + @Volatile private var lastTapTriggerTime = 0L + + @Volatile private var lastTapRegisteredTime = 0L + + @Volatile private var inTapSpike = false + + @Volatile private var tapSpikeStartTime = 0L + + // Power button state + private val powerPressTimestamps = java.util.Collections.synchronizedList(mutableListOf()) + + @Volatile private var lastPowerTriggerTime = 0L + + private val powerButtonReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action == Intent.ACTION_SCREEN_OFF || + intent.action == Intent.ACTION_SCREEN_ON + ) { + handlePowerPress(System.currentTimeMillis()) + } + } + } + + /** + * Start listening for trigger gestures. Reads current settings and + * registers appropriate listeners based on active modes. + */ + suspend fun start() { + activeModes = SosTriggerMode.fromKeys(settingsRepository.sosTriggerModes.first()) + shakeSensitivity = settingsRepository.sosShakeSensitivity.first() + requiredTapCount = settingsRepository.sosTapCount.first() + + if (activeModes.isEmpty()) { + Log.d(TAG, "No trigger modes active, not registering listeners") + return + } + + // Register accelerometer if shake or tap is active + val needsSensor = + activeModes.any { + it == SosTriggerMode.SHAKE || it == SosTriggerMode.TAP_PATTERN + } + if (needsSensor && !isSensorListening) { + val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (accelerometer == null) { + Log.w(TAG, "No accelerometer available on this device") + } else { + sensorManager.registerListener( + this, + accelerometer, + SensorManager.SENSOR_DELAY_GAME, + ) + isSensorListening = true + } + } + + // Register power button receiver + if (SosTriggerMode.POWER_BUTTON in activeModes && !isPowerButtonListening) { + val filter = + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_SCREEN_ON) + } + context.registerReceiver(powerButtonReceiver, filter) + isPowerButtonListening = true + } + + Log.d(TAG, "Started listening for trigger modes: ${activeModes.map { it.key }}") + } + + /** + * Stop listening for trigger gestures. Unregisters all listeners. + */ + fun stop() { + if (isSensorListening) { + sensorManager.unregisterListener(this) + isSensorListening = false + } + if (isPowerButtonListening) { + try { + context.unregisterReceiver(powerButtonReceiver) + } catch (_: IllegalArgumentException) { + // already unregistered + } + isPowerButtonListening = false + } + resetShakeState() + resetTapState() + powerPressTimestamps.clear() + Log.d(TAG, "Stopped listening") + } + + /** + * Reload settings (e.g., when user changes trigger modes or sensitivity). + * Restarts listeners if needed. + */ + suspend fun reloadSettings() { + stop() + start() + } + + /** + * Start observing SOS settings and [SosManager] state to manage: + * 1. The accelerometer sensor listener (shake/tap detection) + * 2. The [SosTriggerService] foreground service lifecycle + * + * The foreground service runs whenever: + * - Trigger detection is active (SOS enabled + non-MANUAL mode), OR + * - SOS is in an active state (Countdown / Sending / Active) — keeps the + * process alive for periodic location updates and message sending, + * including after a device reboot. + * + * Should be called once at app startup (main process only). + */ + fun startObserving() { + settingsJob?.cancel() + settingsJob = + scope.launch { + combine( + settingsRepository.sosEnabled, + settingsRepository.sosTriggerModes, + settingsRepository.sosShakeSensitivity, + settingsRepository.sosTapCount, + sosManager.state.map { it !is SosState.Idle }.distinctUntilChanged(), + ) { enabled, modes, _, _, sosActive -> + // sensitivity and tapCount are change-triggers only (re-read in start()) + SosSettingsSnapshot(enabled, modes, sosActive) + } + .distinctUntilChanged() + .collect { (enabled, modes, sosActive) -> + try { + val triggerNeeded = enabled && modes.isNotEmpty() + + if (!enabled && sosActive) { + sosManager.forceDeactivate() + } + + val effectiveSosActive = sosManager.state.value !is SosState.Idle + + if (triggerNeeded) { + reloadSettings() + } else { + stop() + } + + if (triggerNeeded || effectiveSosActive) { + SosTriggerService.start(context) + } else { + SosTriggerService.stop(context) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error applying SOS settings", e) + } + } + } + } + + override fun onSensorChanged(event: SensorEvent) { + if (!isSensorListening) return + if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) return + + val x = event.values[0] + val y = event.values[1] + val z = event.values[2] + + // Acceleration magnitude minus gravity (~9.81 m/s²) + val magnitude = sqrt((x * x + y * y + z * z).toDouble()).toFloat() + val netAcceleration = Math.abs(magnitude - SensorManager.GRAVITY_EARTH) + + val now = System.currentTimeMillis() + + if (SosTriggerMode.SHAKE in activeModes) handleShake(netAcceleration, now) + if (SosTriggerMode.TAP_PATTERN in activeModes) handleTap(netAcceleration, now) + } + + override fun onAccuracyChanged( + sensor: Sensor?, + accuracy: Int, + ) { + // Not needed + } + + /** + * Shake detection: the net acceleration must exceed a threshold derived from + * [shakeSensitivity] for a cumulative [SHAKE_DURATION_MS] within a + * [SHAKE_WINDOW_MS] sliding window. + * + * Threshold mapping (net acceleration, gravity-subtracted): + * sensitivity 1.0x → ~1.0g (easy, catches vigorous shaking) + * sensitivity 2.5x → ~1.75g (moderate, default range) + * sensitivity 5.0x → ~3.0g (hard, needs deliberate strong shake) + * + * The previous linear mapping (`sensitivity * GRAVITY`) put 4.0x at ~4g net, + * which is essentially unreachable for a sustained 500ms human shake — + * making the upper half of the slider unusable. + * + * The accumulator only advances while consecutive samples stay above threshold. + * A dip below threshold breaks the continuous chain immediately (so the duration + * of the dip is never credited), and a gap longer than [SHAKE_GAP_RESET_MS] + * between above-threshold samples discards the accumulated time entirely. This + * prevents bursty motion (e.g. running / bumps in a bag) from inflating the + * accumulator by crediting below-threshold time as "shaking". + */ + internal fun handleShake( + netAcceleration: Float, + now: Long, + ) { + if (now - lastShakeTriggerTime < SHAKE_COOLDOWN_MS) return + + // Map 1.0x..5.0x slider onto 1.0g..3.0g net threshold (see KDoc). + val threshold = (0.5f + shakeSensitivity * 0.5f) * SensorManager.GRAVITY_EARTH + + if (netAcceleration > threshold) { + // Gap since last above-threshold sample is too long → accumulated run is stale. + if (lastAboveTime > 0L && now - lastAboveTime > SHAKE_GAP_RESET_MS) { + resetShakeState() + } + + if (shakeStartTime == 0L) { + shakeStartTime = now + } + + if (now - shakeStartTime > SHAKE_WINDOW_MS) { + // Window expired — start a fresh window at this sample. + shakeStartTime = now + shakeAccumulatedMs = 0L + lastShakeEventTime = 0L + } + + // Credit dt only when the previous sample was ALSO above threshold + // (continuous run). Otherwise this is the first sample of a new run. + if (lastShakeEventTime > 0L) { + shakeAccumulatedMs += now - lastShakeEventTime + } + lastShakeEventTime = now + lastAboveTime = now + + if (shakeAccumulatedMs >= SHAKE_DURATION_MS) { + Log.d(TAG, "Shake detected! Triggering SOS") + lastShakeTriggerTime = now + resetShakeState() + sosManager.trigger() + } + } else { + // Below threshold: break the continuous-run chain immediately so a + // subsequent above-threshold sample cannot credit the dip duration. + lastShakeEventTime = 0L + } + } + + /** + * Tap detection using spike-based state machine. + * + * A tap is counted only when [netAcceleration] crosses above [TAP_THRESHOLD] and then + * returns below it within [MAX_TAP_SPIKE_MS]. This rejects walking steps and sustained + * vibrations (which create spikes lasting >100ms) while reliably catching brief finger + * taps (typically 20–80ms). The lower threshold is safe because the duration filter + * prevents false positives from continuous motion. + */ + internal fun handleTap( + netAcceleration: Float, + now: Long, + ) { + if (now - lastTapTriggerTime < TAP_COOLDOWN_MS) return + + if (!inTapSpike) { + if (netAcceleration > TAP_THRESHOLD) { + // Rising edge: spike starts + inTapSpike = true + tapSpikeStartTime = now + } + } else { + if (netAcceleration > TAP_THRESHOLD) { + // Still in spike — abort if it lasts too long (walking, shake, sustained bump) + if (now - tapSpikeStartTime > MAX_TAP_SPIKE_MS) { + inTapSpike = false + } + } else { + // Falling edge: spike ended + val spikeDuration = now - tapSpikeStartTime + inTapSpike = false + + if (spikeDuration <= MAX_TAP_SPIKE_MS && + now - lastTapRegisteredTime >= TAP_MIN_INTERVAL_MS + ) { + // Valid short spike with sufficient gap from previous tap + lastTapRegisteredTime = now + val triggered = synchronized(tapTimestamps) { + tapTimestamps.add(now) + tapTimestamps.removeAll { now - it > TAP_WINDOW_MS } + Log.d(TAG, "Tap registered (spike ${spikeDuration}ms), count=${tapTimestamps.size}/$requiredTapCount") + tapTimestamps.size >= requiredTapCount + } + if (triggered) { + Log.d(TAG, "Tap pattern detected! Triggering SOS") + lastTapTriggerTime = now + resetTapState() + sosManager.trigger() + } + } + } + } + } + + /** + * Power button detection: counts SCREEN_OFF events (power button presses). + * Triggers SOS when [POWER_PRESS_COUNT] presses are detected within [POWER_PRESS_WINDOW_MS]. + */ + internal fun handlePowerPress(now: Long) { + if (now - lastPowerTriggerTime < POWER_COOLDOWN_MS) return + + val triggered = synchronized(powerPressTimestamps) { + powerPressTimestamps.add(now) + powerPressTimestamps.removeAll { now - it > POWER_PRESS_WINDOW_MS } + Log.d(TAG, "Power press registered, count=${powerPressTimestamps.size}/$POWER_PRESS_COUNT") + powerPressTimestamps.size >= POWER_PRESS_COUNT + } + if (triggered) { + Log.d(TAG, "Power button pattern detected! Triggering SOS") + lastPowerTriggerTime = now + synchronized(powerPressTimestamps) { powerPressTimestamps.clear() } + sosManager.trigger() + } + } + + internal fun resetShakeState() { + shakeStartTime = 0L + shakeAccumulatedMs = 0L + lastShakeEventTime = 0L + lastAboveTime = 0L + } + + internal fun resetTapState() { + synchronized(tapTimestamps) { + tapTimestamps.clear() + inTapSpike = false + tapSpikeStartTime = 0L + lastTapRegisteredTime = 0L + } + } + } diff --git a/app/src/main/java/com/lxmf/messenger/service/SosTriggerService.kt b/app/src/main/java/com/lxmf/messenger/service/SosTriggerService.kt new file mode 100644 index 000000000..6967e7685 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/service/SosTriggerService.kt @@ -0,0 +1,120 @@ +package com.lxmf.messenger.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.lxmf.messenger.MainActivity +import com.lxmf.messenger.R + +/** + * Lightweight foreground service that keeps the main process alive while SOS + * trigger detection (shake/tap via accelerometer) is active. + * + * The actual sensor logic lives in [SosTriggerDetector]; this service only + * provides a persistent notification so Android won't kill the process when + * the app is in the background. + * + * Started/stopped by [SosTriggerDetector.startObserving] when SOS settings change. + */ +class SosTriggerService : Service() { + companion object { + private const val TAG = "SosTriggerService" + private const val NOTIFICATION_ID = 1003 + private const val CHANNEL_ID = "sos_trigger_monitoring" + + fun start(context: Context) { + try { + ContextCompat.startForegroundService( + context, + Intent(context, SosTriggerService::class.java), + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to start SosTriggerService: ${e.message}", e) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, SosTriggerService::class.java)) + } + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + createNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIFICATION_ID, createNotification()) + } + Log.d(TAG, "Foreground service started") + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground: ${e.message}", e) + stopSelf() + } + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int = START_STICKY + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + Log.d(TAG, "Service destroyed") + super.onDestroy() + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + "SOS Trigger Monitoring", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Keeps SOS gesture detection active in background" + setShowBadge(false) + setSound(null, null) + enableVibration(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun createNotification(): Notification { + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("SOS Monitoring Active") + .setContentText("Monitoring for emergency trigger gestures") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } +} diff --git a/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt b/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt index 9e8ae837f..ce9c0f34e 100644 --- a/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt +++ b/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt @@ -36,9 +36,13 @@ internal class TelemetryLocationTracker( } @Volatile private var locationTrackingActive = false + @Volatile private var gmsLocationTrackingCallback: LocationCallback? = null + @Volatile private var platformLocationTrackingListener: LocationListener? = null + @Volatile private var latestTrackedLocation: Location? = null + @Volatile private var latestTrackedLocationRecordedAtMs: Long? = null val isTracking: Boolean get() = locationTrackingActive @@ -175,8 +179,7 @@ internal class TelemetryLocationTracker( } } - private fun isLocationRecent(location: Location): Boolean = - getTrackedLocationAgeMs(location) <= MAX_TRACKED_LOCATION_AGE_MS + private fun isLocationRecent(location: Location): Boolean = getTrackedLocationAgeMs(location) <= MAX_TRACKED_LOCATION_AGE_MS /** * Get the current device location via a one-shot request. diff --git a/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt b/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt index e92605ac2..f5cacc0e2 100644 --- a/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt +++ b/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt @@ -835,6 +835,10 @@ class ReticulumServiceBinder( iconName: String?, iconFgColor: String?, iconBgColor: String?, + telemetryJson: String?, + audioData: ByteArray?, + audioDataPath: String?, + sosState: String?, ): String = try { wrapperManager.withWrapper { wrapper -> @@ -868,6 +872,10 @@ class ReticulumServiceBinder( iconName, iconFgColor, iconBgColor, + telemetryJson, + audioData, + audioDataPath, + sosState, ) // Use PythonResultConverter to properly convert Python dict to JSON // (bytes values like message_hash need Base64 encoding) diff --git a/app/src/main/java/com/lxmf/messenger/ui/components/AudioMessagePlayer.kt b/app/src/main/java/com/lxmf/messenger/ui/components/AudioMessagePlayer.kt new file mode 100644 index 000000000..1cb790455 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/components/AudioMessagePlayer.kt @@ -0,0 +1,191 @@ +package com.lxmf.messenger.ui.components + +import android.content.Intent +import android.media.MediaPlayer +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Audio player composable for playing audio messages (LXMF FIELD_AUDIO). + * + * @param audioBytes Raw audio data bytes (M4A/AAC format) + * @param modifier Optional modifier + */ +@Composable +fun AudioMessagePlayer( + audioBytes: ByteArray, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isPlaying by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var durationMs by remember { mutableStateOf(0) } + var player by remember { mutableStateOf(null) } + var tempFile by remember { mutableStateOf(null) } + + LaunchedEffect(audioBytes) { + // Release any previous player and clean up stale temp file + withContext(Dispatchers.Main) { + player?.release() + player = null + } + withContext(Dispatchers.IO) { + tempFile?.delete() + tempFile = null + val file = File(context.cacheDir, "audio_playback_${audioBytes.contentHashCode()}.m4a") + file.writeBytes(audioBytes) + tempFile = file + val mp = MediaPlayer() + var assigned = false + try { + mp.setDataSource(file.absolutePath) + mp.prepare() + withContext(Dispatchers.Main) { + durationMs = mp.duration + mp.setOnCompletionListener { + isPlaying = false + progress = 0f + } + player = mp + assigned = true + } + } catch (_: kotlinx.coroutines.CancellationException) { + // Coroutine cancelled — release handled in finally + } catch (e: Exception) { + Log.e("AudioMessagePlayer", "Failed to prepare player", e) + } finally { + if (!assigned) mp.release() + } + } + } + + DisposableEffect(Unit) { + onDispose { + try { + player?.release() + } catch (_: Exception) { + } + tempFile?.delete() + } + } + + // Progress tracking + LaunchedEffect(isPlaying) { + while (isPlaying) { + val mp = player ?: break + if (durationMs > 0) { + progress = mp.currentPosition.toFloat() / durationMs + } + delay(200) + } + } + + val durationSec = durationMs / 1000 + val formattedDuration = "%d:%02d".format(durationSec / 60, durationSec % 60) + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton( + onClick = { + val mp = player ?: return@IconButton + if (isPlaying) { + mp.pause() + isPlaying = false + } else { + mp.start() + isPlaying = true + } + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.primary, + ) + } + + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.weight(1f), + ) + + Text( + text = formattedDuration, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + IconButton( + onClick = { + val src = tempFile ?: return@IconButton + scope.launch(Dispatchers.IO) { + val shareFile = File(context.cacheDir, "sos_audio_share_${src.name}") + try { + src.copyTo(shareFile, overwrite = true) + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", shareFile, + ) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "audio/mp4" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + withContext(Dispatchers.Main) { + context.startActivity(Intent.createChooser(shareIntent, "Share Audio")) + } + } finally { + shareFile.delete() + } + } + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/java/com/lxmf/messenger/ui/components/SosOverlay.kt b/app/src/main/java/com/lxmf/messenger/ui/components/SosOverlay.kt new file mode 100644 index 000000000..10594804e --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/components/SosOverlay.kt @@ -0,0 +1,218 @@ +package com.lxmf.messenger.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.lxmf.messenger.service.SosState + +/** + * Overlay composable that renders SOS-related UI on top of the main navigation. + * + * - [SosState.Countdown]: Shows a dismissible countdown AlertDialog with a Cancel button. + * - [SosState.Sending]: Shows a non-dismissible progress AlertDialog. + * - [SosState.Active]: Shows a persistent red banner (positioned via [modifier]) and a + * deactivation dialog (with optional PIN validation) when the user taps Deactivate. + * - [SosState.Idle]: Renders nothing. + * + * @param sosState Current SOS state. + * @param sosDeactivationPin Configured deactivation PIN (null/blank = no PIN required). + * @param onCancel Called when the user cancels during the countdown phase. + * @param onDeactivate Called with the entered PIN (or null) when deactivating. Returns true on success. + * @param modifier Applied to the active-state banner. + */ +@Composable +fun SosOverlay( + sosState: SosState, + sosDeactivationPin: String?, + onCancel: () -> Unit, + onDeactivate: (String?) -> Boolean, + modifier: Modifier = Modifier, +) { + when (val state = sosState) { + is SosState.Idle -> {} + + is SosState.Countdown -> { + AlertDialog( + onDismissRequest = {}, + icon = { + Icon( + Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { + Text( + "SOS Activating", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "${state.remainingSeconds}", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("Sending SOS in ${state.remainingSeconds} second(s).") + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Tap CANCEL to abort.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onCancel) { + Text("CANCEL", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold) + } + }, + ) + } + + is SosState.Sending -> { + AlertDialog( + onDismissRequest = {}, + icon = { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Sending SOS\u2026") }, + text = { Text("Sending emergency messages to your contacts.") }, + confirmButton = {}, + ) + } + + is SosState.Active -> { + var showDeactivateDialog by remember { mutableStateOf(false) } + + // Floating pill overlay — position controlled by parent modifier + Surface( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.error, + shadowElevation = 6.dp, + onClick = { showDeactivateDialog = true }, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "SOS ACTIVE", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onError, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "DEACTIVATE", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onError.copy(alpha = 0.8f), + ) + } + } + + if (showDeactivateDialog) { + val requiresPin = !sosDeactivationPin.isNullOrBlank() + var enteredPin by remember { mutableStateOf("") } + var pinError by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { showDeactivateDialog = false }, + title = { Text("Deactivate SOS") }, + text = { + Column { + Text("Are you sure you want to deactivate the SOS emergency mode?") + if (requiresPin) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = enteredPin, + onValueChange = { + enteredPin = it.filter { c -> c.isDigit() }.take(6) + pinError = false + }, + label = { Text("Deactivation PIN") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + visualTransformation = PasswordVisualTransformation(), + isError = pinError, + supportingText = + if (pinError) { + { Text("Incorrect PIN", color = MaterialTheme.colorScheme.error) } + } else { + null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val pin = if (requiresPin) enteredPin else null + val success = onDeactivate(pin) + if (success) { + showDeactivateDialog = false + } else { + pinError = true + } + }, + ) { + Text("Deactivate", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeactivateDialog = false }) { + Text("Cancel") + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/lxmf/messenger/ui/model/MessageMapper.kt b/app/src/main/java/com/lxmf/messenger/ui/model/MessageMapper.kt index 7ff828502..528b7c970 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/model/MessageMapper.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/model/MessageMapper.kt @@ -43,6 +43,14 @@ fun Message.toMessageUi(): MessageUi { Log.d(TAG, "Message ${id.take(16)}... has field 5, hasFiles=$hasFiles, json=${fieldsJson?.take(200)}") } val fileAttachmentsList = if (hasFiles) parseFileAttachments(fieldsJson) else emptyList() + val hasAudio = + fieldsJson?.let { + try { + org.json.JSONObject(it).has("7") + } catch (_: Exception) { + false + } + } == true // Get reply-to message ID: prefer DB column, fallback to parsing field 16 val replyId = replyToMessageId ?: parseReplyToFromField16(fieldsJson) @@ -53,7 +61,7 @@ fun Message.toMessageUi(): MessageUi { // Determine if we need to preserve fieldsJson for UI components // (uncached image, file attachments, or pending file notification) val hasUncachedImage = hasImage && cachedImage == null - val needsFieldsJson = hasUncachedImage || hasFiles || hasPendingFileNotification(fieldsJson) + val needsFieldsJson = hasUncachedImage || hasFiles || hasAudio || hasPendingFileNotification(fieldsJson) return MessageUi( id = id, @@ -66,6 +74,7 @@ fun Message.toMessageUi(): MessageUi { hasImageAttachment = hasImage, fileAttachments = fileAttachmentsList, hasFileAttachments = hasFiles, + hasAudioAttachment = hasAudio, fieldsJson = if (needsFieldsJson) fieldsJson else null, deliveryMethod = deliveryMethod, errorMessage = errorMessage, @@ -353,6 +362,48 @@ private fun extractImageBytes(fieldsJson: String?): ByteArray? { } } +/** + * Extract audio bytes from LXMF field 7. + * + * Supports formats: + * 1. Array format: "7": ["format", "hex_data"] + * 2. Array with staging: "7": ["format", null, "staging_path"] + * + * @param fieldsJson The message's fields JSON + * @return Raw audio bytes, or null if not found + */ +@Suppress("ReturnCount") +fun extractAudioBytes(fieldsJson: String?): ByteArray? { + if (fieldsJson == null) return null + + return try { + val fields = JSONObject(fieldsJson) + val field7 = fields.opt("7") ?: return null + + // Array format: ["format", null, "staging_path"] + if (field7 is JSONArray && field7.length() >= 3 && field7.isNull(1)) { + val stagingPath = field7.optString(2, "") + if (stagingPath.isNotEmpty()) return loadBinaryFromDisk(stagingPath) + } + + // Array format: ["format", "hex_data"] + if (field7 is JSONArray && field7.length() >= 2) { + val hexData = field7.optString(1, "") + if (hexData.isNotEmpty()) return hexStringToByteArray(hexData) + } + + // Raw hex string + if (field7 is String && field7.isNotEmpty()) { + return hexStringToByteArray(field7) + } + + null + } catch (e: Exception) { + Log.e(TAG, "Failed to extract audio bytes", e) + null + } +} + /** * Decodes LXMF image field (type 6) from hex string to ImageBitmap. * diff --git a/app/src/main/java/com/lxmf/messenger/ui/model/MessageUi.kt b/app/src/main/java/com/lxmf/messenger/ui/model/MessageUi.kt index 5c71683b2..500c4cd2b 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/model/MessageUi.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/model/MessageUi.kt @@ -76,6 +76,11 @@ data class MessageUi( * Used to quickly determine if file attachment UI should be rendered. */ val hasFileAttachments: Boolean = false, + /** + * Indicates whether this message has an audio attachment (LXMF field 7). + * When true, fieldsJson contains audio data for playback. + */ + val hasAudioAttachment: Boolean = false, /** * ID of the message this is replying to, if any. * Extracted from LXMF field 16 {"reply_to": "message_id"}. diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt index 640a958c8..c394703c7 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.ButtonDefaults @@ -239,6 +240,8 @@ fun ChatsScreen( val hapticFeedback = LocalHapticFeedback.current var showMenu by remember { mutableStateOf(false) } val isSaved by viewModel.isContactSaved(conversation.peerHash).collectAsState() + val isSos by viewModel.isSosContact(conversation.peerHash).collectAsState() + val hasSosActive by viewModel.hasSosActive(conversation.peerHash).collectAsState() var contactLocation by remember { mutableStateOf?>(null) } // Fetch contact location when menu opens; clear on close @@ -257,6 +260,8 @@ fun ChatsScreen( ConversationCard( conversation = conversation, isSaved = isSaved, + isSos = isSos, + hasSosActive = hasSosActive, draftText = draftText, onClick = { if (pendingSharedText != null) { @@ -289,6 +294,7 @@ fun ChatsScreen( expanded = showMenu, onDismiss = { showMenu = false }, isSaved = isSaved, + isSos = isSos, onSaveToContacts = { viewModel.saveToContacts(conversation) showMenu = false @@ -318,6 +324,10 @@ fun ChatsScreen( showMenu = false onLocateOnMap(conversation.peerHash) }, + onToggleSos = { + viewModel.toggleSosTag(conversation.peerHash) + showMenu = false + }, onBlockUser = { showMenu = false selectedConversation = conversation @@ -430,6 +440,8 @@ fun ChatsScreen( fun ConversationCard( conversation: Conversation, isSaved: Boolean = false, + isSos: Boolean = false, + hasSosActive: Boolean = false, draftText: String? = null, onClick: () -> Unit = {}, onLongPress: () -> Unit = {}, @@ -444,10 +456,21 @@ fun ConversationCard( onLongClick = onLongPress, ), shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + elevation = CardDefaults.cardElevation(defaultElevation = if (hasSosActive) 0.dp else 2.dp), + border = + if (hasSosActive) { + androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.error) + } else { + null + }, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = + if (hasSosActive) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, ), ) { Box { @@ -506,6 +529,25 @@ fun ConversationCard( ) } } + // SOS badge (top-start) + if (isSos) { + Box( + modifier = + Modifier + .size(20.dp) + .align(Alignment.TopStart) + .offset(x = (-4).dp, y = (-4).dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = "SOS Contact", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onError, + ) + } + } } // Conversation info @@ -596,6 +638,7 @@ fun ConversationContextMenu( expanded: Boolean, onDismiss: () -> Unit, isSaved: Boolean, + isSos: Boolean = false, onSaveToContacts: () -> Unit, onRemoveFromContacts: () -> Unit, onMarkAsUnread: () -> Unit, @@ -603,6 +646,7 @@ fun ConversationContextMenu( onViewDetails: () -> Unit, hasLocation: Boolean = false, onLocateOnMap: () -> Unit = {}, + onToggleSos: () -> Unit = {}, onBlockUser: () -> Unit, ) { DropdownMenu( @@ -680,6 +724,23 @@ fun ConversationContextMenu( ) } + // SOS toggle (only shown if contact is saved) + if (isSaved) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = if (isSos) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Text(if (isSos) "Unmark as SOS Contact" else "Mark as SOS Contact") + }, + onClick = onToggleSos, + ) + } + HorizontalDivider() // Delete conversation diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt index 98bf518bd..251bd1afd 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Info @@ -96,6 +97,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -103,6 +105,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel +import com.lxmf.messenger.R import com.lxmf.messenger.data.db.entity.ContactStatus import com.lxmf.messenger.data.model.EnrichedContact import com.lxmf.messenger.ui.components.AddContactConfirmationDialog @@ -113,8 +116,6 @@ import com.lxmf.messenger.util.formatRelativeTime import com.lxmf.messenger.util.validation.InputValidator import com.lxmf.messenger.util.validation.ValidationConstants import com.lxmf.messenger.util.validation.ValidationResult -import androidx.compose.ui.res.stringResource -import com.lxmf.messenger.R import com.lxmf.messenger.viewmodel.AddContactResult import com.lxmf.messenger.viewmodel.AnnounceStreamViewModel import com.lxmf.messenger.viewmodel.ContactsViewModel @@ -147,6 +148,7 @@ fun ContactsScreen( val hasPendingSharedImages = sharedImagesFromViewModel != null val contactsState by viewModel.contactsState.collectAsState() + val sosActiveSenders by viewModel.sosActiveSenders.collectAsState() val contactCount by viewModel.contactCount.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() val currentRelayInfo by viewModel.currentRelayInfo.collectAsState() @@ -499,6 +501,7 @@ fun ContactsScreen( item(key = "relay_${relay.destinationHash}") { ContactListItemWithMenu( contact = relay, + hasSosActive = sosActiveSenders.contains(relay.destinationHash), onClick = { if (relay.status == ContactStatus.PENDING_IDENTITY || relay.status == ContactStatus.UNRESOLVED @@ -522,6 +525,7 @@ fun ContactsScreen( }, onLocateOnMap = { onLocateOnMap(relay.destinationHash) }, getContactLocation = { viewModel.getContactLocation(it) }, + onToggleSos = { viewModel.toggleSosTag(relay.destinationHash) }, ) } } @@ -542,6 +546,7 @@ fun ContactsScreen( ) { contact -> ContactListItemWithMenu( contact = contact, + hasSosActive = sosActiveSenders.contains(contact.destinationHash), onClick = { if (contact.status == ContactStatus.PENDING_IDENTITY || contact.status == ContactStatus.UNRESOLVED @@ -572,6 +577,7 @@ fun ContactsScreen( onRemove = { viewModel.deleteContact(contact.destinationHash) }, onLocateOnMap = { onLocateOnMap(contact.destinationHash) }, getContactLocation = { viewModel.getContactLocation(it) }, + onToggleSos = { viewModel.toggleSosTag(contact.destinationHash) }, ) } } @@ -594,6 +600,7 @@ fun ContactsScreen( ) { contact -> ContactListItemWithMenu( contact = contact, + hasSosActive = sosActiveSenders.contains(contact.destinationHash), onClick = { if (contact.status == ContactStatus.PENDING_IDENTITY || contact.status == ContactStatus.UNRESOLVED @@ -624,6 +631,7 @@ fun ContactsScreen( onRemove = { viewModel.deleteContact(contact.destinationHash) }, onLocateOnMap = { onLocateOnMap(contact.destinationHash) }, getContactLocation = { viewModel.getContactLocation(it) }, + onToggleSos = { viewModel.toggleSosTag(contact.destinationHash) }, ) } } @@ -856,7 +864,7 @@ fun ContactsScreen( editNicknameCurrentValue = null }, onConfirm = { newNickname -> - viewModel.updateNickname(nicknameContactHash, newNickname) + viewModel.updateContact(nicknameContactHash, nickname = newNickname) showEditNicknameDialog = false editNicknameContactHash = null editNicknameCurrentValue = null @@ -919,6 +927,7 @@ fun ContactsScreen( @Composable private fun ContactListItemWithMenu( contact: EnrichedContact, + hasSosActive: Boolean = false, onClick: () -> Unit, onPinToggle: () -> Unit, onEditNickname: () -> Unit, @@ -926,6 +935,7 @@ private fun ContactListItemWithMenu( onRemove: () -> Unit, onLocateOnMap: () -> Unit = {}, getContactLocation: suspend (String) -> Pair? = { null }, + onToggleSos: () -> Unit = {}, ) { val hapticFeedback = LocalHapticFeedback.current var showMenu by remember { mutableStateOf(false) } @@ -943,6 +953,7 @@ private fun ContactListItemWithMenu( Box(modifier = Modifier.fillMaxWidth()) { ContactListItem( contact = contact, + hasSosActive = hasSosActive, onClick = onClick, onPinClick = onPinToggle, onLongPress = { @@ -978,6 +989,11 @@ private fun ContactListItemWithMenu( onLocateOnMap() showMenu = false }, + isSos = contact.isSosContact, + onToggleSos = { + onToggleSos() + showMenu = false + }, ) } } @@ -986,6 +1002,7 @@ private fun ContactListItemWithMenu( @Composable fun ContactListItem( contact: EnrichedContact, + hasSosActive: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit, onPinClick: () -> Unit, @@ -1008,9 +1025,20 @@ fun ContactListItem( onLongClick = onLongPress, ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = + if (hasSosActive) { + androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.error) + } else { + null + }, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = + if (hasSosActive) { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, ), ) { Row( @@ -1101,6 +1129,26 @@ fun ContactListItem( ) } } + + // SOS badge overlay (Warning icon in top-start corner) + if (contact.isSosContact) { + Box( + modifier = + Modifier + .size(20.dp) + .align(Alignment.TopStart) + .offset(x = (-4).dp, y = (-4).dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = "SOS Contact", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onError, + ) + } + } } // Contact info @@ -1289,6 +1337,8 @@ fun ContactContextMenu( onRemove: () -> Unit, hasLocation: Boolean = false, onLocateOnMap: () -> Unit = {}, + isSos: Boolean = false, + onToggleSos: () -> Unit = {}, ) { DropdownMenu( expanded = expanded, @@ -1312,6 +1362,21 @@ fun ContactContextMenu( onClick = onPin, ) + // SOS toggle + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = if (isSos) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Text(if (isSos) "Unmark as SOS Contact" else "Mark as SOS Contact") + }, + onClick = onToggleSos, + ) + HorizontalDivider() // View peer details diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/IncomingCallScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/IncomingCallScreen.kt index af9c0d321..79a3aa3c0 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/IncomingCallScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/IncomingCallScreen.kt @@ -49,8 +49,8 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import tech.torlando.lxst.core.CallState import com.lxmf.messenger.viewmodel.CallViewModel +import tech.torlando.lxst.core.CallState /** * Incoming call screen with answer/decline options. diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt index 64378e141..27c038f7b 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt @@ -196,6 +196,8 @@ fun MapScreen( focusLabel: String? = null, // Optional full interface details for bottom sheet focusInterfaceDetails: FocusInterfaceDetails? = null, + // Optional SOS sender hash for breadcrumb trail rendering + sosTrailSenderHash: String? = null, // Permission UI state - managed by parent to survive tab switches (issue #342) permissionSheetDismissed: Boolean = false, onPermissionSheetDismissed: () -> Unit = {}, @@ -870,6 +872,12 @@ fun MapScreen( } if (marker != null) { selectedMarker = marker + // Center the map on the tapped contact's location + map.animateCamera( + CameraUpdateFactory.newLatLng( + LatLng(marker.latitude, marker.longitude), + ), + ) Log.d("MapScreen", "Marker tapped: ${marker.destinationHash.take(16)}") } } @@ -1226,6 +1234,92 @@ fun MapScreen( Log.d("MapScreen", "Added focus marker at $focusLatitude, $focusLongitude for $focusLabel") } + // SOS breadcrumb trail - live updates + val sosTrailLocations = + sosTrailSenderHash?.let { + viewModel.observeSosTrailLocations(it).collectAsState(initial = emptyList()) + } + + LaunchedEffect(sosTrailSenderHash, mapStyleLoaded, sosTrailLocations?.value) { + if (!mapStyleLoaded || sosTrailSenderHash == null) return@LaunchedEffect + val map = mapLibreMap ?: return@LaunchedEffect + val screenDensity = context.resources.displayMetrics.density + val locations = sosTrailLocations?.value ?: return@LaunchedEffect + + val trailSourceId = "sos-trail-source" + val trailLayerId = "sos-trail-layer" + val dotsSourceId = "sos-trail-dots-source" + val dotsLayerId = "sos-trail-dots-layer" + + // If no locations (cleared), remove trail layers + if (locations.size < 2) { + val style = map.style ?: return@LaunchedEffect + try { style.removeLayer(trailLayerId) } catch (_: Exception) {} + try { style.removeLayer(dotsLayerId) } catch (_: Exception) {} + try { style.removeSource(trailSourceId) } catch (_: Exception) {} + try { style.removeSource(dotsSourceId) } catch (_: Exception) {} + return@LaunchedEffect + } + + try { + val style = map.style ?: return@LaunchedEffect + + val sortedLocations = locations.sortedBy { it.timestamp } + val points = sortedLocations.map { Point.fromLngLat(it.longitude, it.latitude) } + val lineFeature = Feature.fromGeometry(LineString.fromLngLats(points)) + val dotFeatures = + sortedLocations.map { loc -> + Feature.fromGeometry(Point.fromLngLat(loc.longitude, loc.latitude)) + } + + // Trail polyline + val existingTrailSource = style.getSourceAs(trailSourceId) + if (existingTrailSource != null) { + existingTrailSource.setGeoJson(FeatureCollection.fromFeatures(listOf(lineFeature))) + } else { + style.addSource(GeoJsonSource(trailSourceId, FeatureCollection.fromFeatures(listOf(lineFeature)))) + val trailLayer = + LineLayer(trailLayerId, trailSourceId) + .withProperties( + PropertyFactory.lineWidth(3f * screenDensity), + PropertyFactory.lineColor(Expression.color(android.graphics.Color.parseColor("#F44336"))), + PropertyFactory.lineOpacity(Expression.literal(0.8f)), + ) + val belowLayer = style.getLayer("focus-marker-layer") + if (belowLayer != null) { + style.addLayerBelow(trailLayer, "focus-marker-layer") + } else { + style.addLayer(trailLayer) + } + } + + // Trail dot markers + val existingDotsSource = style.getSourceAs(dotsSourceId) + if (existingDotsSource != null) { + existingDotsSource.setGeoJson(FeatureCollection.fromFeatures(dotFeatures)) + } else { + style.addSource(GeoJsonSource(dotsSourceId, FeatureCollection.fromFeatures(dotFeatures))) + val dotsLayer = + CircleLayer(dotsLayerId, dotsSourceId) + .withProperties( + PropertyFactory.circleRadius(4f * screenDensity), + PropertyFactory.circleColor(Expression.color(android.graphics.Color.parseColor("#F44336"))), + PropertyFactory.circleStrokeWidth(1f * screenDensity), + PropertyFactory.circleStrokeColor(Expression.color(android.graphics.Color.WHITE)), + ) + if (style.getLayer(trailLayerId) != null) { + style.addLayerAbove(dotsLayer, trailLayerId) + } else { + style.addLayer(dotsLayer) + } + } + + Log.d("MapScreen", "Updated SOS trail: ${locations.size} points for ${sosTrailSenderHash.take(8)}") + } catch (e: Exception) { + Log.e("MapScreen", "Failed to update SOS trail layers", e) + } + } + // Gradient scrim behind TopAppBar for readability Box( modifier = @@ -1335,6 +1429,17 @@ fun MapScreen( // Account for bottom navigation bar .padding(bottom = 80.dp), ) { + // Clear SOS Trail button (only when trail is visible) + if (sosTrailSenderHash != null && (sosTrailLocations?.value?.size ?: 0) >= 2) { + SmallFloatingActionButton( + onClick = { viewModel.clearSosTrail(sosTrailSenderHash) }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) { + Icon(Icons.Default.Close, contentDescription = "Clear SOS Trail") + } + } + // Offline Maps button SmallFloatingActionButton( onClick = onNavigateToOfflineMaps, diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt index 82b0848f8..ce45a4e39 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState @@ -81,6 +82,7 @@ import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -96,7 +98,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -157,10 +158,15 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import coil.compose.AsyncImage import coil.request.ImageRequest +import com.lxmf.messenger.MainActivity import com.lxmf.messenger.R +import com.lxmf.messenger.notifications.NotificationHelper +import com.lxmf.messenger.notifications.isSosMessageByField +import com.lxmf.messenger.notifications.parseSosLocation import com.lxmf.messenger.service.SyncProgress import com.lxmf.messenger.service.SyncResult import com.lxmf.messenger.ui.components.AttachmentPanel +import com.lxmf.messenger.ui.components.AudioMessagePlayer import com.lxmf.messenger.ui.components.CodecSelectionDialog import com.lxmf.messenger.ui.components.FileAttachmentCard import com.lxmf.messenger.ui.components.FileAttachmentOptionsSheet @@ -180,6 +186,8 @@ import com.lxmf.messenger.ui.components.SyncStatusBottomSheet import com.lxmf.messenger.ui.components.simpleVerticalScrollbar import com.lxmf.messenger.ui.model.CodecProfile import com.lxmf.messenger.ui.model.LocationSharingState +import com.lxmf.messenger.ui.model.extractAudioBytes +import com.lxmf.messenger.ui.screens.util.formatTimestamp import com.lxmf.messenger.ui.theme.MeshConnected import com.lxmf.messenger.ui.theme.MeshOffline import com.lxmf.messenger.util.AnimatedImageLoader @@ -198,8 +206,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale private const val URL_ANNOTATION_TAG = "url" @@ -1884,6 +1890,11 @@ fun MessageBubble( ) } } else { + // Detect SOS messages from others + val isSosMessage = remember(message.id, message.fieldsJson, message.content) { + !isFromMe && isSosMessageByField(message.content, message.fieldsJson) + } + // Regular message with bubble Box { Surface( @@ -1895,7 +1906,9 @@ fun MessageBubble( bottomEnd = if (isFromMe) 4.dp else 20.dp, ), color = - if (isFromMe) { + if (isSosMessage) { + MaterialTheme.colorScheme.errorContainer + } else if (isFromMe) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceContainerHigh @@ -2106,11 +2119,95 @@ fun MessageBubble( } } + // Display audio player if present (LXMF field 7 = AUDIO) + if (message.hasAudioAttachment) { + var audioBytes by remember(message.id) { mutableStateOf(null) } + LaunchedEffect(message.id) { + audioBytes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + extractAudioBytes(message.fieldsJson) + } + } + if (audioBytes != null) { + AudioMessagePlayer(audioBytes = audioBytes!!) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // SOS header badge for emergency messages + if (isSosMessage) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = "SOS Emergency", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "SOS EMERGENCY", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + } + } + LinkifiedMessageText( text = message.content, isFromMe = isFromMe, fontScale = fontScale, ) + + // "View on Map" button for SOS messages with GPS coordinates + if (isSosMessage) { + val sosLocation = remember(message.id, message.fieldsJson, message.content) { + parseSosLocation(message.content, message.fieldsJson) + } + if (sosLocation != null) { + Spacer(modifier = Modifier.height(6.dp)) + Surface( + onClick = { + val mapIntent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = NotificationHelper.ACTION_SOS_VIEW_MAP + putExtra(NotificationHelper.EXTRA_PEER_NAME, peerName) + putExtra(NotificationHelper.EXTRA_DESTINATION_HASH, message.destinationHash) + putExtra("latitude", sosLocation.first) + putExtra("longitude", sosLocation.second) + } + context.startActivity(mapIntent) + }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = "View on Map", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onError, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "View on Map", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onError, + ) + } + } + } + } + Spacer(modifier = Modifier.height(4.dp)) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -2120,7 +2217,9 @@ fun MessageBubble( text = formatTimestamp(message.receivedAt ?: message.timestamp), style = MaterialTheme.typography.labelSmall, color = - if (isFromMe) { + if (isSosMessage) { + MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f) + } else if (isFromMe) { MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -2529,24 +2628,6 @@ fun EmptyMessagesState() { private enum class InputPanelMode { NONE, KEYBOARD, PANEL } -private fun formatTimestamp(timestamp: Long): String { - val now = System.currentTimeMillis() - val diff = now - timestamp - - return when { - diff < 60_000 -> "Just now" - diff < 3600_000 -> { - val minutes = (diff / 60_000).toInt() - "$minutes min ago" - } - diff < 86400_000 -> { - SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) - } - else -> { - SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()).format(Date(timestamp)) - } - } -} @Composable private fun FullscreenImageDialog( @@ -2857,80 +2938,4 @@ internal fun getMessageStatusIcon(status: String): String = else -> "" } -@Composable -private fun TextSizeDialog( - currentScale: Float, - onScaleChange: (Float) -> Unit, - onDismiss: () -> Unit, -) { - var sliderValue by remember(currentScale) { mutableStateOf(currentScale) } - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = Icons.Default.FormatSize, - contentDescription = null, - ) - }, - title = { Text("Text size") }, - text = { - Column { - // Preview text - Text( - text = "Preview message text", - style = - MaterialTheme.typography.bodyLarge.copy( - fontSize = MaterialTheme.typography.bodyLarge.fontSize * sliderValue, - ), - modifier = Modifier.padding(bottom = 16.dp), - ) - - // Scale label - Text( - text = "${(sliderValue * 100).toInt()}%", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Slider - Slider( - value = sliderValue, - onValueChange = { sliderValue = it }, - valueRange = 0.7f..2.0f, - steps = 12, - ) - - // Min/Max labels - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "A", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "A", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - confirmButton = { - TextButton(onClick = { - onScaleChange(sliderValue) - onDismiss() - }) { - Text("OK") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} +// TextSizeDialog has been moved to TextSizeDialog.kt diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MigrationScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MigrationScreen.kt index 0d8b32cbf..2c2ea8b88 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/MigrationScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MigrationScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -56,7 +57,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -239,8 +239,9 @@ fun MigrationScreen( if (showExportPasswordDialog) { PasswordDialog( title = "Encrypt Export", - description = "Choose a password to protect your export file. " + - "You will need this password to import the data on another device.", + description = + "Choose a password to protect your export file. " + + "You will need this password to import the data on another device.", isConfirmMode = true, isWrongPassword = false, onConfirm = { password -> @@ -256,16 +257,18 @@ fun MigrationScreen( // Import Password Dialog (encrypted file detected) val currentState = uiState if (currentState is MigrationUiState.PasswordRequired || currentState is MigrationUiState.WrongPassword) { - val fileUri = when (currentState) { - is MigrationUiState.PasswordRequired -> currentState.fileUri - is MigrationUiState.WrongPassword -> currentState.fileUri - else -> null - } + val fileUri = + when (currentState) { + is MigrationUiState.PasswordRequired -> currentState.fileUri + is MigrationUiState.WrongPassword -> currentState.fileUri + else -> null + } if (fileUri != null) { PasswordDialog( title = "Encrypted Backup", - description = "This backup file is encrypted. " + - "Enter the password that was used during export.", + description = + "This backup file is encrypted. " + + "Enter the password that was used during export.", isConfirmMode = false, isWrongPassword = currentState is MigrationUiState.WrongPassword, onConfirm = { password -> @@ -860,8 +863,11 @@ internal fun PasswordDialog( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), visualTransformation = - if (passwordVisible) VisualTransformation.None - else PasswordVisualTransformation(), + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, trailingIcon = { TextButton(onClick = { passwordVisible = !passwordVisible }) { Text(if (passwordVisible) "Hide" else "Show") @@ -882,8 +888,11 @@ internal fun PasswordDialog( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), visualTransformation = - if (passwordVisible) VisualTransformation.None - else PasswordVisualTransformation(), + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, isError = errorMessage != null, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/NotificationSettingsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/NotificationSettingsScreen.kt index e2fec414f..0e12d8f8f 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/NotificationSettingsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/NotificationSettingsScreen.kt @@ -23,9 +23,9 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.BluetoothDisabled import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.NearMe import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.NotificationsOff -import androidx.compose.material.icons.filled.NearMe import androidx.compose.material.icons.filled.Sensors import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.WifiOff diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt index 854bacee2..85d6631d1 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt @@ -67,6 +67,7 @@ import com.lxmf.messenger.ui.screens.settings.cards.PrivacyCard import com.lxmf.messenger.ui.screens.settings.cards.RNodeFlasherCard import com.lxmf.messenger.ui.screens.settings.cards.ShareColumbaCard import com.lxmf.messenger.ui.screens.settings.cards.SharedInstanceBannerCard +import com.lxmf.messenger.ui.screens.settings.cards.SosEmergencyCard import com.lxmf.messenger.ui.screens.settings.cards.ThemeSelectionCard import com.lxmf.messenger.ui.screens.settings.cards.VoiceCallPermissionsCard import com.lxmf.messenger.ui.screens.settings.cards.shouldShowSharedInstanceBanner @@ -411,6 +412,40 @@ fun SettingsScreen( }, ) + SosEmergencyCard( + isExpanded = state.cardExpansionStates[SettingsCardId.SOS_EMERGENCY.name] ?: false, + onExpandedChange = { viewModel.toggleCardExpanded(SettingsCardId.SOS_EMERGENCY, it) }, + sosEnabled = state.sosEnabled, + onSosEnabledChange = { viewModel.setSosEnabled(it) }, + sosMessageTemplate = state.sosMessageTemplate, + onSosMessageTemplateChange = { viewModel.setSosMessageTemplate(it) }, + sosCountdownSeconds = state.sosCountdownSeconds, + onSosCountdownSecondsChange = { viewModel.setSosCountdownSeconds(it) }, + sosIncludeLocation = state.sosIncludeLocation, + onSosIncludeLocationChange = { viewModel.setSosIncludeLocation(it) }, + sosSilentAutoAnswer = state.sosSilentAutoAnswer, + onSosSilentAutoAnswerChange = { viewModel.setSosSilentAutoAnswer(it) }, + sosShowFloatingButton = state.sosShowFloatingButton, + onSosShowFloatingButtonChange = { viewModel.setSosShowFloatingButton(it) }, + sosDeactivationPin = state.sosDeactivationPin, + onSosDeactivationPinChange = { viewModel.setSosDeactivationPin(it) }, + sosPeriodicUpdates = state.sosPeriodicUpdates, + onSosPeriodicUpdatesChange = { viewModel.setSosPeriodicUpdates(it) }, + sosUpdateIntervalSeconds = state.sosUpdateIntervalSeconds, + onSosUpdateIntervalSecondsChange = { viewModel.setSosUpdateIntervalSeconds(it) }, + sosContactCount = state.sosContactCount, + sosTriggerModes = state.sosTriggerModes, + onSosTriggerModeToggle = { viewModel.toggleSosTriggerMode(it) }, + sosShakeSensitivity = state.sosShakeSensitivity, + onSosShakeSensitivityChange = { viewModel.setSosShakeSensitivity(it) }, + sosTapCount = state.sosTapCount, + onSosTapCountChange = { viewModel.setSosTapCount(it) }, + sosAudioEnabled = state.sosAudioEnabled, + onSosAudioEnabledChange = { viewModel.setSosAudioEnabled(it) }, + sosAudioDurationSeconds = state.sosAudioDurationSeconds, + onSosAudioDurationSecondsChange = { viewModel.setSosAudioDurationSeconds(it) }, + ) + MapSourcesCard( isExpanded = state.cardExpansionStates[SettingsCardId.MAP_SOURCES.name] ?: false, onExpandedChange = { viewModel.toggleCardExpanded(SettingsCardId.MAP_SOURCES, it) }, diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/TextSizeDialog.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/TextSizeDialog.kt new file mode 100644 index 000000000..916c170ba --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/TextSizeDialog.kt @@ -0,0 +1,100 @@ +package com.lxmf.messenger.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FormatSize +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal fun TextSizeDialog( + currentScale: Float, + onScaleChange: (Float) -> Unit, + onDismiss: () -> Unit, +) { + var sliderValue by remember(currentScale) { mutableStateOf(currentScale) } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.FormatSize, + contentDescription = null, + ) + }, + title = { Text("Text size") }, + text = { + Column { + // Preview text + Text( + text = "Preview message text", + style = + MaterialTheme.typography.bodyLarge.copy( + fontSize = MaterialTheme.typography.bodyLarge.fontSize * sliderValue, + ), + modifier = Modifier.padding(bottom = 16.dp), + ) + + // Scale label + Text( + text = "${(sliderValue * 100).toInt()}%", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Slider + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + valueRange = 0.7f..2.0f, + steps = 12, + ) + + // Min/Max labels + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "A", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "A", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + onScaleChange(sliderValue) + onDismiss() + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/ShareColumbaCard.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/ShareColumbaCard.kt index 6da97bdd6..68628aa75 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/ShareColumbaCard.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/ShareColumbaCard.kt @@ -28,8 +28,9 @@ fun ShareColumbaCard( onExpandedChange = onExpandedChange, ) { Text( - text = "Share the Columba app with someone nearby. " + - "The other person scans a QR code to download and install the app directly from your phone.", + text = + "Share the Columba app with someone nearby. " + + "The other person scans a QR code to download and install the app directly from your phone.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/SosEmergencyCard.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/SosEmergencyCard.kt new file mode 100644 index 000000000..c426fa9a1 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/SosEmergencyCard.kt @@ -0,0 +1,386 @@ +package com.lxmf.messenger.ui.screens.settings.cards + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.lxmf.messenger.service.SosTriggerMode +import com.lxmf.messenger.ui.components.CollapsibleSettingsCard + +@Suppress("LongParameterList") +@Composable +fun SosEmergencyCard( + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + sosEnabled: Boolean, + onSosEnabledChange: (Boolean) -> Unit, + sosMessageTemplate: String, + onSosMessageTemplateChange: (String) -> Unit, + sosCountdownSeconds: Int, + onSosCountdownSecondsChange: (Int) -> Unit, + sosIncludeLocation: Boolean, + onSosIncludeLocationChange: (Boolean) -> Unit, + sosSilentAutoAnswer: Boolean, + onSosSilentAutoAnswerChange: (Boolean) -> Unit, + sosShowFloatingButton: Boolean, + onSosShowFloatingButtonChange: (Boolean) -> Unit, + sosDeactivationPin: String?, + onSosDeactivationPinChange: (String?) -> Unit, + sosPeriodicUpdates: Boolean, + onSosPeriodicUpdatesChange: (Boolean) -> Unit, + sosUpdateIntervalSeconds: Int, + onSosUpdateIntervalSecondsChange: (Int) -> Unit, + sosContactCount: Int, + sosTriggerModes: Set, + onSosTriggerModeToggle: (String) -> Unit, + sosShakeSensitivity: Float, + onSosShakeSensitivityChange: (Float) -> Unit, + sosTapCount: Int, + onSosTapCountChange: (Int) -> Unit, + sosAudioEnabled: Boolean, + onSosAudioEnabledChange: (Boolean) -> Unit, + sosAudioDurationSeconds: Int, + onSosAudioDurationSecondsChange: (Int) -> Unit, +) { + val context = LocalContext.current + CollapsibleSettingsCard( + title = "SOS Emergency", + icon = Icons.Filled.Warning, + isExpanded = isExpanded, + onExpandedChange = onExpandedChange, + headerAction = { + Switch(checked = sosEnabled, onCheckedChange = onSosEnabledChange) + }, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + // ── Contacts ── + Text( + "$sosContactCount SOS contact(s) configured", + style = MaterialTheme.typography.bodySmall, + color = + if (sosContactCount == 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // ── Triggers ── + Text("Triggers", style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(8.dp)) + + // Floating button + SosToggleRow( + title = "Floating SOS Button", + subtitle = "Show a floating SOS trigger button", + checked = sosShowFloatingButton, + onCheckedChange = onSosShowFloatingButtonChange, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Trigger modes (multi-select) + Text( + "Additional trigger modes", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + + val activeModes = SosTriggerMode.fromKeys(sosTriggerModes) + SosTriggerMode.entries.forEach { mode -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = mode in activeModes, + onCheckedChange = { onSosTriggerModeToggle(mode.key) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + when (mode) { + SosTriggerMode.SHAKE -> "Shake phone" + SosTriggerMode.TAP_PATTERN -> "Tap pattern" + SosTriggerMode.POWER_BUTTON -> "Power button" + }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + when (mode) { + SosTriggerMode.SHAKE -> "Shake the device vigorously to trigger" + SosTriggerMode.TAP_PATTERN -> "Tap the back of the phone rapidly" + SosTriggerMode.POWER_BUTTON -> "Press power button 3 times rapidly" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (SosTriggerMode.SHAKE in activeModes) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Shake sensitivity: ${"%.1f".format(sosShakeSensitivity)}x", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "Lower = more sensitive, Higher = harder shake required", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = sosShakeSensitivity, + onValueChange = onSosShakeSensitivityChange, + valueRange = 1.0f..5.0f, + steps = 7, + ) + } + + if (SosTriggerMode.TAP_PATTERN in activeModes) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Required taps: $sosTapCount", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "Number of rapid taps needed to trigger SOS", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = sosTapCount.toFloat(), + onValueChange = { onSosTapCountChange(it.toInt()) }, + valueRange = 3f..5f, + steps = 1, + ) + } + + // Countdown + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Countdown: ${sosCountdownSeconds}s", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + if (sosCountdownSeconds == 0) "SOS will send instantly" else "Delay before sending", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = sosCountdownSeconds.toFloat(), + onValueChange = { onSosCountdownSecondsChange(it.toInt()) }, + valueRange = 0f..30f, + steps = 29, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // ── Signals ── + Text("Signals", style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(8.dp)) + + // Message template (local state to avoid cursor reset on async DataStore roundtrip) + var templateText by remember { mutableStateOf(sosMessageTemplate) } + OutlinedTextField( + value = templateText, + onValueChange = { + templateText = it + onSosMessageTemplateChange(it) + }, + label = { Text("Message Template") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Audio recording (with permission check) + val audioPermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onSosAudioEnabledChange(true) + } + SosToggleRow( + title = "Audio Recording", + subtitle = "Record and send audio when SOS is triggered", + checked = sosAudioEnabled, + onCheckedChange = { enabled -> + if (enabled) { + val hasPermission = + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.RECORD_AUDIO, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + audioPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO) + return@SosToggleRow + } + } + onSosAudioEnabledChange(enabled) + }, + ) + + if (sosAudioEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Recording duration: ${sosAudioDurationSeconds}s", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "Sent as a separate message after the initial alert", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = sosAudioDurationSeconds.toFloat(), + onValueChange = { onSosAudioDurationSecondsChange(it.toInt()) }, + valueRange = 15f..60f, + steps = 8, + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // ── Location ── + Text("Location", style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(8.dp)) + + // Include location + SosToggleRow( + title = "Include GPS Location", + subtitle = "Append coordinates to SOS message", + checked = sosIncludeLocation, + onCheckedChange = onSosIncludeLocationChange, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Periodic updates + SosToggleRow( + title = "Periodic Location Updates", + subtitle = "Send location updates while SOS is active", + checked = sosPeriodicUpdates, + onCheckedChange = onSosPeriodicUpdatesChange, + ) + + if (sosPeriodicUpdates) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Update interval: ${sosUpdateIntervalSeconds}s", + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = sosUpdateIntervalSeconds.toFloat(), + onValueChange = { onSosUpdateIntervalSecondsChange(it.toInt()) }, + valueRange = 30f..600f, + steps = 56, + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // ── Security ── + Text("Security", style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(8.dp)) + + // Silent auto-answer + SosToggleRow( + title = "Silent Auto-Answer", + subtitle = "Auto-answer incoming calls during active SOS", + checked = sosSilentAutoAnswer, + onCheckedChange = onSosSilentAutoAnswerChange, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Deactivation PIN + var pinText by remember { mutableStateOf(sosDeactivationPin ?: "") } + OutlinedTextField( + value = pinText, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() }.take(6) + pinText = filtered + onSosDeactivationPinChange(if (filtered.length >= 4) filtered else null) + }, + label = { Text("Deactivation PIN (optional)") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + visualTransformation = PasswordVisualTransformation(), + isError = pinText.isNotEmpty() && pinText.length < 4, + supportingText = { + Text( + if (pinText.isNotEmpty() && pinText.length < 4) { + "PIN must be at least 4 digits" + } else { + "4-6 digit PIN required to deactivate SOS" + }, + ) + }, + singleLine = true, + ) + } + } +} + +@Composable +private fun SosToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyMedium) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/util/FormatTimestamp.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/util/FormatTimestamp.kt new file mode 100644 index 000000000..debee3b07 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/util/FormatTimestamp.kt @@ -0,0 +1,24 @@ +package com.lxmf.messenger.ui.screens.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +internal fun formatTimestamp(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + + return when { + diff < 60_000 -> "Just now" + diff < 3600_000 -> { + val minutes = (diff / 60_000).toInt() + "$minutes min ago" + } + diff < 86400_000 -> { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) + } + else -> { + SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()).format(Date(timestamp)) + } + } +} diff --git a/app/src/main/java/com/lxmf/messenger/ui/util/MarkerDeclutter.kt b/app/src/main/java/com/lxmf/messenger/ui/util/MarkerDeclutter.kt index e3cbb881e..757f5b374 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/util/MarkerDeclutter.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/util/MarkerDeclutter.kt @@ -28,7 +28,10 @@ data class ScreenMarker( * Allows testing without MapLibre dependency. */ fun interface ScreenToLatLng { - fun convert(screenX: Float, screenY: Float): Pair + fun convert( + screenX: Float, + screenY: Float, + ): Pair } // Declutter constants @@ -58,6 +61,7 @@ fun calculateDeclutteredPositions( // Union-Find grouping of overlapping markers val parent = IntArray(screenMarkers.size) { it } + fun find(i: Int): Int { var x = i while (parent[x] != x) { @@ -66,7 +70,11 @@ fun calculateDeclutteredPositions( } return x } - fun union(a: Int, b: Int) { + + fun union( + a: Int, + b: Int, + ) { val ra = find(a) val rb = find(b) if (ra != rb) parent[ra] = rb diff --git a/app/src/main/java/com/lxmf/messenger/util/LocationPermissionManager.kt b/app/src/main/java/com/lxmf/messenger/util/LocationPermissionManager.kt index 0c9945a13..40a8d02b9 100644 --- a/app/src/main/java/com/lxmf/messenger/util/LocationPermissionManager.kt +++ b/app/src/main/java/com/lxmf/messenger/util/LocationPermissionManager.kt @@ -85,8 +85,7 @@ object LocationPermissionManager { /** * Whether this Android version requires explicit background location permission. */ - fun requiresBackgroundLocationPermission(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + fun requiresBackgroundLocationPermission(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q /** * Check if explicit background location permission is granted. @@ -106,8 +105,7 @@ object LocationPermissionManager { /** * Check if permissions are sufficient for telemetry sending while app is inactive. */ - fun hasTelemetryBackgroundPermission(context: Context): Boolean = - hasPermission(context) && hasBackgroundLocationPermission(context) + fun hasTelemetryBackgroundPermission(context: Context): Boolean = hasPermission(context) && hasBackgroundLocationPermission(context) /** * Check permission status and return detailed information. diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt index 195bb5d0e..4cdecec19 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt @@ -77,6 +77,9 @@ class ChatsViewModel // Cache for contact saved state flows to prevent flickering on recomposition private val contactSavedCache = ConcurrentHashMap>() + // Cache for SOS contact state flows + private val sosCacheFlows = ConcurrentHashMap>() + // Draft texts keyed by peerHash - for showing "Draft:" in conversation list val draftsMap: StateFlow> = conversationRepository @@ -245,4 +248,47 @@ class ChatsViewModel * Returns a Pair(latitude, longitude) or null if no valid location is known. */ suspend fun getContactLocation(peerHash: String): Pair? = receivedLocationRepository.getContactLocation(peerHash) + + /** + * Check if a peer is tagged as an SOS contact. + */ + fun isSosContact(peerHash: String): StateFlow = + sosCacheFlows.getOrPut(peerHash) { + contactRepository + .isSosContactFlow(peerHash) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) + } + + private val sosActiveCacheFlows = mutableMapOf>() + + /** + * Check if a peer has an active SOS emergency (receiver side). + */ + fun hasSosActive(peerHash: String): StateFlow = + sosActiveCacheFlows.getOrPut(peerHash) { + com.lxmf.messenger.service.SosActiveTracker.isActive(peerHash) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) + } + + /** + * Toggle SOS tag for a contact. + */ + fun toggleSosTag(peerHash: String) { + viewModelScope.launch { + try { + contactRepository.toggleSosTag(peerHash) + Log.d(TAG, "Toggled SOS tag for $peerHash") + } catch (e: Exception) { + Log.e(TAG, "Failed to toggle SOS tag for $peerHash", e) + } + } + } } diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt index 66ce64320..c84321149 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt @@ -286,49 +286,59 @@ class ContactsViewModel } /** - * Update contact nickname + * Update contact nickname or notes. + * + * @param destinationHash The contact's destination hash + * @param nickname If non-null, updates the nickname (pass empty string to clear) + * @param notes If non-null, updates the notes (pass empty string to clear) */ - fun updateNickname( + fun updateContact( destinationHash: String, - nickname: String?, + nickname: String? = null, + notes: String? = null, ) { viewModelScope.launch { try { - contactRepository.updateNickname(destinationHash, nickname) - Log.d(TAG, "Updated nickname for $destinationHash") + if (nickname != null) { + contactRepository.updateNickname(destinationHash, nickname) + Log.d(TAG, "Updated nickname for $destinationHash") + } + if (notes != null) { + contactRepository.updateNotes(destinationHash, notes) + Log.d(TAG, "Updated notes for $destinationHash") + } } catch (e: Exception) { - Log.e(TAG, "Failed to update nickname for $destinationHash", e) + Log.e(TAG, "Failed to update contact $destinationHash", e) } } } /** - * Update contact notes + * Toggle pin status for a contact */ - fun updateNotes( - destinationHash: String, - notes: String?, - ) { + fun togglePin(destinationHash: String) { viewModelScope.launch { try { - contactRepository.updateNotes(destinationHash, notes) - Log.d(TAG, "Updated notes for $destinationHash") + contactRepository.togglePin(destinationHash) + Log.d(TAG, "Toggled pin for $destinationHash") } catch (e: Exception) { - Log.e(TAG, "Failed to update notes for $destinationHash", e) + Log.e(TAG, "Failed to toggle pin for $destinationHash", e) } } } + val sosActiveSenders: StateFlow> = com.lxmf.messenger.service.SosActiveTracker.activeSenders + /** - * Toggle pin status for a contact + * Toggle SOS tag for a contact */ - fun togglePin(destinationHash: String) { + fun toggleSosTag(destinationHash: String) { viewModelScope.launch { try { - contactRepository.togglePin(destinationHash) - Log.d(TAG, "Toggled pin for $destinationHash") + contactRepository.toggleSosTag(destinationHash) + Log.d(TAG, "Toggled SOS tag for $destinationHash") } catch (e: Exception) { - Log.e(TAG, "Failed to toggle pin for $destinationHash", e) + Log.e(TAG, "Failed to toggle SOS tag for $destinationHash", e) } } } diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/IdentityManagerViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/IdentityManagerViewModel.kt index d895381b8..97a05eba6 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/IdentityManagerViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/IdentityManagerViewModel.kt @@ -519,9 +519,10 @@ class IdentityManagerViewModel try { _uiState.value = IdentityManagerUiState.Loading("Exporting identity...") - val fileData = withContext(Dispatchers.IO) { - reticulumProtocol.exportIdentityFile(identityHash, filePath) - } + val fileData = + withContext(Dispatchers.IO) { + reticulumProtocol.exportIdentityFile(identityHash, filePath) + } if (fileData.isEmpty()) { _uiState.value = diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt index 5ae25032b..a21151e18 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -790,4 +791,17 @@ class MapViewModel receivedLocationDao.deleteLocationsForSender(destinationHash) } } + + /** + * Observe all stored locations for a sender (for live SOS breadcrumb trail). + */ + fun observeSosTrailLocations(senderHash: String): kotlinx.coroutines.flow.Flow> = + receivedLocationDao.getSosTrailForSender(senderHash) + + fun clearSosTrail(senderHash: String) { + viewModelScope.launch { + receivedLocationDao.deleteSosTrailForSender(senderHash) + Log.d("MapViewModel", "Cleared SOS trail for ${senderHash.take(8)}") + } + } } diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt index c0cfa1e2d..994d661a8 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt @@ -59,6 +59,7 @@ enum class SettingsCardId { SHARE_COLUMBA, ABOUT, SHARED_INSTANCE_BANNER, + SOS_EMERGENCY, } @androidx.compose.runtime.Immutable @@ -165,6 +166,24 @@ data class SettingsState( val includePrereleaseUpdates: Boolean = false, // Message sort order: false = received time (default), true = sent time val sortMessagesBySentTime: Boolean = false, + // SOS Emergency state + val sosEnabled: Boolean = false, + val sosMessageTemplate: String = "SOS! I need help. This is an emergency.", + val sosCountdownSeconds: Int = 5, + val sosIncludeLocation: Boolean = true, + val sosSilentAutoAnswer: Boolean = false, + val sosShowFloatingButton: Boolean = false, + val sosFabOffsetX: Float = 0f, + val sosFabOffsetY: Float = 0f, + val sosDeactivationPin: String? = null, + val sosPeriodicUpdates: Boolean = false, + val sosUpdateIntervalSeconds: Int = 120, + val sosContactCount: Int = 0, + val sosTriggerModes: Set = emptySet(), + val sosShakeSensitivity: Float = 2.5f, + val sosTapCount: Int = 3, + val sosAudioEnabled: Boolean = false, + val sosAudioDurationSeconds: Int = 30, ) @Suppress("TooManyFunctions", "LargeClass") // ViewModel with many user interaction methods is expected @@ -236,6 +255,8 @@ class SettingsViewModel loadTelemetryCollectorSettings() // Load contacts for allowed requesters picker loadContacts() + // Load SOS emergency settings + loadSosSettings() // Load update checker settings and maybe check on startup loadUpdateSettings() // Always start sync state monitoring (no infinite loops, needed for UI) @@ -454,6 +475,22 @@ class SettingsViewModel // Preserve update checker state from loadUpdateSettings() updateCheckResult = _state.value.updateCheckResult, includePrereleaseUpdates = _state.value.includePrereleaseUpdates, + // Preserve SOS state from loadSosSettings() + sosEnabled = _state.value.sosEnabled, + sosMessageTemplate = _state.value.sosMessageTemplate, + sosCountdownSeconds = _state.value.sosCountdownSeconds, + sosIncludeLocation = _state.value.sosIncludeLocation, + sosSilentAutoAnswer = _state.value.sosSilentAutoAnswer, + sosShowFloatingButton = _state.value.sosShowFloatingButton, + sosDeactivationPin = _state.value.sosDeactivationPin, + sosPeriodicUpdates = _state.value.sosPeriodicUpdates, + sosUpdateIntervalSeconds = _state.value.sosUpdateIntervalSeconds, + sosContactCount = _state.value.sosContactCount, + sosTriggerModes = _state.value.sosTriggerModes, + sosShakeSensitivity = _state.value.sosShakeSensitivity, + sosTapCount = _state.value.sosTapCount, + sosAudioEnabled = _state.value.sosAudioEnabled, + sosAudioDurationSeconds = _state.value.sosAudioDurationSeconds, ) }.distinctUntilChanged().collect { newState -> applySettingsUpdate(newState) @@ -2022,6 +2059,152 @@ class SettingsViewModel } } + // ========== SOS Emergency ========== + + private fun loadSosSettings() { + viewModelScope.launch { + settingsRepository.sosEnabled.collect { enabled -> + _state.update { it.copy(sosEnabled = enabled) } + } + } + viewModelScope.launch { + settingsRepository.sosMessageTemplate.collect { template -> + _state.update { it.copy(sosMessageTemplate = template) } + } + } + viewModelScope.launch { + settingsRepository.sosCountdownSeconds.collect { seconds -> + _state.update { it.copy(sosCountdownSeconds = seconds) } + } + } + viewModelScope.launch { + settingsRepository.sosIncludeLocation.collect { include -> + _state.update { it.copy(sosIncludeLocation = include) } + } + } + viewModelScope.launch { + settingsRepository.sosSilentAutoAnswer.collect { enabled -> + _state.update { it.copy(sosSilentAutoAnswer = enabled) } + } + } + viewModelScope.launch { + settingsRepository.sosShowFloatingButton.collect { show -> + _state.update { it.copy(sosShowFloatingButton = show) } + } + } + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + settingsRepository.sosFabOffsetX, + settingsRepository.sosFabOffsetY, + ) { x, y -> Pair(x, y) }.collect { (x, y) -> + _state.update { it.copy(sosFabOffsetX = x, sosFabOffsetY = y) } + } + } + viewModelScope.launch { + settingsRepository.sosDeactivationPin.collect { pin -> + _state.update { it.copy(sosDeactivationPin = pin) } + } + } + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + settingsRepository.sosPeriodicUpdates, + settingsRepository.sosUpdateIntervalSeconds, + ) { enabled, seconds -> Pair(enabled, seconds) }.collect { (enabled, seconds) -> + _state.update { it.copy(sosPeriodicUpdates = enabled, sosUpdateIntervalSeconds = seconds) } + } + } + viewModelScope.launch { + contactRepository.getSosContactsFlow().collect { contacts -> + _state.update { it.copy(sosContactCount = contacts.size) } + } + } + viewModelScope.launch { + settingsRepository.sosTriggerModes.collect { modes -> + _state.update { it.copy(sosTriggerModes = modes) } + } + } + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + settingsRepository.sosShakeSensitivity, + settingsRepository.sosTapCount, + ) { sensitivity, count -> Pair(sensitivity, count) }.collect { (sensitivity, count) -> + _state.update { it.copy(sosShakeSensitivity = sensitivity, sosTapCount = count) } + } + } + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + settingsRepository.sosAudioEnabled, + settingsRepository.sosAudioDurationSeconds, + ) { enabled, seconds -> Pair(enabled, seconds) }.collect { (enabled, seconds) -> + _state.update { it.copy(sosAudioEnabled = enabled, sosAudioDurationSeconds = seconds) } + } + } + } + + fun setSosEnabled(enabled: Boolean) { + viewModelScope.launch { settingsRepository.setSosEnabled(enabled) } + } + + fun setSosMessageTemplate(template: String) { + viewModelScope.launch { settingsRepository.setSosMessageTemplate(template) } + } + + fun setSosCountdownSeconds(seconds: Int) { + viewModelScope.launch { settingsRepository.setSosCountdownSeconds(seconds) } + } + + fun setSosIncludeLocation(include: Boolean) { + viewModelScope.launch { settingsRepository.setSosIncludeLocation(include) } + } + + fun setSosSilentAutoAnswer(enabled: Boolean) { + viewModelScope.launch { settingsRepository.setSosSilentAutoAnswer(enabled) } + } + + fun setSosShowFloatingButton(show: Boolean) { + viewModelScope.launch { settingsRepository.setSosShowFloatingButton(show) } + } + + fun setSosFabOffset(x: Float, y: Float) { + viewModelScope.launch { settingsRepository.setSosFabOffset(x, y) } + } + + fun setSosDeactivationPin(pin: String?) { + viewModelScope.launch { settingsRepository.setSosDeactivationPin(pin) } + } + + fun setSosPeriodicUpdates(enabled: Boolean) { + viewModelScope.launch { settingsRepository.setSosPeriodicUpdates(enabled) } + } + + fun setSosUpdateIntervalSeconds(seconds: Int) { + viewModelScope.launch { settingsRepository.setSosUpdateIntervalSeconds(seconds) } + } + + fun toggleSosTriggerMode(mode: String) { + viewModelScope.launch { + val current = _state.value.sosTriggerModes + val updated = if (mode in current) current - mode else current + mode + settingsRepository.setSosTriggerModes(updated) + } + } + + fun setSosShakeSensitivity(sensitivity: Float) { + viewModelScope.launch { settingsRepository.setSosShakeSensitivity(sensitivity) } + } + + fun setSosTapCount(count: Int) { + viewModelScope.launch { settingsRepository.setSosTapCount(count) } + } + + fun setSosAudioEnabled(enabled: Boolean) { + viewModelScope.launch { settingsRepository.setSosAudioEnabled(enabled) } + } + + fun setSosAudioDurationSeconds(seconds: Int) { + viewModelScope.launch { settingsRepository.setSosAudioDurationSeconds(seconds) } + } + private fun loadUpdateSettings() { viewModelScope.launch { // Eagerly read the persisted value before the startup check fires, diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt index 2969897fd..c2c98bf78 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt @@ -5,38 +5,38 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class SharedTextViewModel : ViewModel() { - data class PendingSharedText( - val text: String, - val targetDestinationHash: String? = null, - ) + data class PendingSharedText( + val text: String, + val targetDestinationHash: String? = null, + ) - private val _sharedText = MutableStateFlow(null) - val sharedText: StateFlow = _sharedText + private val _sharedText = MutableStateFlow(null) + val sharedText: StateFlow = _sharedText - fun setText(text: String) { - _sharedText.value = PendingSharedText(text = text) - } - - fun assignToDestination(destinationHash: String) { - val current = _sharedText.value ?: return - _sharedText.value = current.copy(targetDestinationHash = destinationHash) - } + fun setText(text: String) { + _sharedText.value = PendingSharedText(text = text) + } - fun consumeForDestination(destinationHash: String): String? { - val current = _sharedText.value ?: return null - if (current.targetDestinationHash != destinationHash) return null - _sharedText.value = null - return current.text - } + fun assignToDestination(destinationHash: String) { + val current = _sharedText.value ?: return + _sharedText.value = current.copy(targetDestinationHash = destinationHash) + } - fun clearIfUnassigned() { - val current = _sharedText.value ?: return - if (current.targetDestinationHash == null) { - _sharedText.value = null - } - } + fun consumeForDestination(destinationHash: String): String? { + val current = _sharedText.value ?: return null + if (current.targetDestinationHash != destinationHash) return null + _sharedText.value = null + return current.text + } - fun clear() { + fun clearIfUnassigned() { + val current = _sharedText.value ?: return + if (current.targetDestinationHash == null) { _sharedText.value = null } } + + fun clear() { + _sharedText.value = null + } +} diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SosViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SosViewModel.kt new file mode 100644 index 000000000..97a1a3a36 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SosViewModel.kt @@ -0,0 +1,23 @@ +package com.lxmf.messenger.viewmodel + +import androidx.lifecycle.ViewModel +import com.lxmf.messenger.service.SosManager +import com.lxmf.messenger.service.SosState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class SosViewModel + @Inject + constructor( + private val sosManager: SosManager, + ) : ViewModel() { + val state: StateFlow = sosManager.state + + fun trigger() = sosManager.trigger() + + fun cancel() = sosManager.cancel() + + fun deactivate(pin: String? = null): Boolean = sosManager.deactivate(pin) + } diff --git a/app/src/test/java/com/lxmf/messenger/call/PttMediaSessionManagerTest.kt b/app/src/test/java/com/lxmf/messenger/call/PttMediaSessionManagerTest.kt index 358368804..2cfedbb43 100644 --- a/app/src/test/java/com/lxmf/messenger/call/PttMediaSessionManagerTest.kt +++ b/app/src/test/java/com/lxmf/messenger/call/PttMediaSessionManagerTest.kt @@ -25,12 +25,13 @@ class PttMediaSessionManagerTest { fun setup() { lastPttState = null pttStateChanges.clear() - manager = PttMediaSessionManager( - ApplicationProvider.getApplicationContext(), - ) { active -> - lastPttState = active - pttStateChanges.add(active) - } + manager = + PttMediaSessionManager( + ApplicationProvider.getApplicationContext(), + ) { active -> + lastPttState = active + pttStateChanges.add(active) + } } @After diff --git a/app/src/test/java/com/lxmf/messenger/map/TileDownloadManagerTest.kt b/app/src/test/java/com/lxmf/messenger/map/TileDownloadManagerTest.kt index b5694114a..bd9d5ba07 100644 --- a/app/src/test/java/com/lxmf/messenger/map/TileDownloadManagerTest.kt +++ b/app/src/test/java/com/lxmf/messenger/map/TileDownloadManagerTest.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.yield import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/lxmf/messenger/migration/MigrationCryptoTest.kt b/app/src/test/java/com/lxmf/messenger/migration/MigrationCryptoTest.kt index 503a6fbcf..c852e3d43 100644 --- a/app/src/test/java/com/lxmf/messenger/migration/MigrationCryptoTest.kt +++ b/app/src/test/java/com/lxmf/messenger/migration/MigrationCryptoTest.kt @@ -126,10 +126,11 @@ class MigrationCryptoTest { val plaintext = "stream test data".toByteArray() val encrypted = MigrationCrypto.encrypt(plaintext, testPassword) - val decryptedStream = MigrationCrypto.decryptStream( - encrypted.inputStream(), - testPassword, - ) + val decryptedStream = + MigrationCrypto.decryptStream( + encrypted.inputStream(), + testPassword, + ) val result = decryptedStream.readBytes() assertArrayEquals(plaintext, result) } diff --git a/app/src/test/java/com/lxmf/messenger/migration/MigrationImporterEncryptionTest.kt b/app/src/test/java/com/lxmf/messenger/migration/MigrationImporterEncryptionTest.kt index 79c202b1d..7206b3b25 100644 --- a/app/src/test/java/com/lxmf/messenger/migration/MigrationImporterEncryptionTest.kt +++ b/app/src/test/java/com/lxmf/messenger/migration/MigrationImporterEncryptionTest.kt @@ -3,7 +3,6 @@ package com.lxmf.messenger.migration import android.content.Context import android.net.Uri import androidx.test.core.app.ApplicationProvider -import com.lxmf.messenger.data.crypto.IdentityKeyEncryptor import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString diff --git a/app/src/test/java/com/lxmf/messenger/notifications/NotificationHelperTest.kt b/app/src/test/java/com/lxmf/messenger/notifications/NotificationHelperTest.kt index 50267abe3..515c1c633 100644 --- a/app/src/test/java/com/lxmf/messenger/notifications/NotificationHelperTest.kt +++ b/app/src/test/java/com/lxmf/messenger/notifications/NotificationHelperTest.kt @@ -414,7 +414,7 @@ class NotificationHelperTest { // ========== Cancel Notification Tests ========== @Test - fun `cancelAllNotifications clears all notifications`() = + fun `cancelNotification with no args clears all notifications`() = runBlocking { // Given: Post a notification notificationHelper.notifyMessageReceived( @@ -428,7 +428,7 @@ class NotificationHelperTest { assertTrue("Notification should exist", shadowNotificationManager.allNotifications.isNotEmpty()) // When - notificationHelper.cancelAllNotifications() + notificationHelper.cancelNotification() // Then assertTrue("All notifications should be cancelled", shadowNotificationManager.allNotifications.isEmpty()) diff --git a/app/src/test/java/com/lxmf/messenger/receiver/BootReceiverTest.kt b/app/src/test/java/com/lxmf/messenger/receiver/BootReceiverTest.kt new file mode 100644 index 000000000..5dc94f99b --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/receiver/BootReceiverTest.kt @@ -0,0 +1,94 @@ +package com.lxmf.messenger.receiver + +import android.app.Application +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.lxmf.messenger.service.ReticulumService +import com.lxmf.messenger.service.SosTriggerService +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** + * Unit tests for [BootReceiver]. + * + * Verifies that BOOT_COMPLETED starts the expected services and that + * other actions are ignored. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34]) +class BootReceiverTest { + private lateinit var context: Application + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + } + + private fun getAllStartedServices(): List { + val shadow = Shadows.shadowOf(context) + val services = mutableListOf() + var next = shadow.nextStartedService + while (next != null) { + services.add(next) + next = shadow.nextStartedService + } + return services + } + + @Test + fun `ignores non-boot-completed actions`() { + val receiver = BootReceiver() + receiver.onReceive(context, Intent("com.example.SOME_ACTION")) + + val shadow = Shadows.shadowOf(context) + assertNull("No service should be started for non-BOOT action", shadow.nextStartedService) + } + + @Test + fun `boot completed starts ReticulumService`() { + val receiver = BootReceiver() + receiver.onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) + + val services = getAllStartedServices() + assertTrue( + "Expected ReticulumService to be started, got: ${services.map { it.component?.className }}", + services.any { it.component?.className == ReticulumService::class.java.name }, + ) + } + + @Test + fun `boot completed starts SosTriggerService`() { + val receiver = BootReceiver() + receiver.onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) + + val services = getAllStartedServices() + assertTrue( + "Expected SosTriggerService to be started, got: ${services.map { it.component?.className }}", + services.any { it.component?.className == SosTriggerService::class.java.name }, + ) + } + + @Test + fun `boot completed sets ReticulumService ACTION_START`() { + val receiver = BootReceiver() + receiver.onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) + + val services = getAllStartedServices() + val reticulumIntent = + services.find { + it.component?.className == ReticulumService::class.java.name + } + assertNotNull("ReticulumService should be started", reticulumIntent) + assertTrue( + "ReticulumService intent should have ACTION_START", + reticulumIntent!!.action == ReticulumService.ACTION_START, + ) + } +} diff --git a/app/src/test/java/com/lxmf/messenger/reticulum/protocol/ContinuationRaceTest.kt b/app/src/test/java/com/lxmf/messenger/reticulum/protocol/ContinuationRaceTest.kt index 989564489..6dd39bf6b 100644 --- a/app/src/test/java/com/lxmf/messenger/reticulum/protocol/ContinuationRaceTest.kt +++ b/app/src/test/java/com/lxmf/messenger/reticulum/protocol/ContinuationRaceTest.kt @@ -1,4 +1,5 @@ @file:Suppress("InjectDispatcher") + package com.lxmf.messenger.reticulum.protocol import kotlinx.coroutines.Dispatchers diff --git a/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerAtomicityTest.kt b/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerAtomicityTest.kt index 3720ad0ed..b7ea77686 100644 --- a/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerAtomicityTest.kt +++ b/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerAtomicityTest.kt @@ -1,4 +1,5 @@ @file:Suppress("InjectDispatcher", "NoNameShadowing") + package com.lxmf.messenger.reticulum.util import kotlinx.coroutines.Dispatchers diff --git a/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerThreadSafetyTest.kt b/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerThreadSafetyTest.kt index e0bb34ec4..8f1d94436 100644 --- a/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerThreadSafetyTest.kt +++ b/app/src/test/java/com/lxmf/messenger/reticulum/util/SmartPollerThreadSafetyTest.kt @@ -1,4 +1,5 @@ @file:Suppress("InjectDispatcher") + package com.lxmf.messenger.reticulum.util import kotlinx.coroutines.Dispatchers diff --git a/app/src/test/java/com/lxmf/messenger/service/LocalHotspotManagerTest.kt b/app/src/test/java/com/lxmf/messenger/service/LocalHotspotManagerTest.kt index e25571509..df79ccf38 100644 --- a/app/src/test/java/com/lxmf/messenger/service/LocalHotspotManagerTest.kt +++ b/app/src/test/java/com/lxmf/messenger/service/LocalHotspotManagerTest.kt @@ -45,10 +45,11 @@ class LocalHotspotManagerTest { @Test fun `HotspotInfo data class holds ssid and password`() { - val info = LocalHotspotManager.HotspotInfo( - ssid = "TestNetwork", - password = "password123", - ) + val info = + LocalHotspotManager.HotspotInfo( + ssid = "TestNetwork", + password = "password123", + ) assertEquals("TestNetwork", info.ssid) assertEquals("password123", info.password) } diff --git a/app/src/test/java/com/lxmf/messenger/service/MessageCollectorTest.kt b/app/src/test/java/com/lxmf/messenger/service/MessageCollectorTest.kt index 22f1a2da0..e7d807b5e 100644 --- a/app/src/test/java/com/lxmf/messenger/service/MessageCollectorTest.kt +++ b/app/src/test/java/com/lxmf/messenger/service/MessageCollectorTest.kt @@ -40,6 +40,7 @@ class MessageCollectorTest { private lateinit var identityRepository: IdentityRepository private lateinit var notificationHelper: NotificationHelper private lateinit var peerIconDao: PeerIconDao + private lateinit var receivedLocationDao: com.lxmf.messenger.data.db.dao.ReceivedLocationDao private lateinit var conversationLinkManager: ConversationLinkManager private lateinit var messageCollector: MessageCollector @@ -59,6 +60,8 @@ class MessageCollectorTest { identityRepository = mockk() notificationHelper = mockk() peerIconDao = mockk() + receivedLocationDao = mockk() + coEvery { receivedLocationDao.insert(any()) } returns Unit conversationLinkManager = mockk() // Default behavior for conversationLinkManager @@ -67,6 +70,7 @@ class MessageCollectorTest { // Explicit stubs for notificationHelper (suspend function) coEvery { notificationHelper.notifyMessageReceived(any(), any(), any(), any()) } returns Unit + every { notificationHelper.isSosMessage(any()) } returns false // Explicit stubs for peerIconDao coEvery { peerIconDao.getIcon(any()) } returns null @@ -107,6 +111,7 @@ class MessageCollectorTest { identityRepository = identityRepository, notificationHelper = notificationHelper, peerIconDao = peerIconDao, + receivedLocationDao = receivedLocationDao, conversationLinkManager = conversationLinkManager, ) } diff --git a/app/src/test/java/com/lxmf/messenger/service/SosManagerTest.kt b/app/src/test/java/com/lxmf/messenger/service/SosManagerTest.kt new file mode 100644 index 000000000..4d1240315 --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/service/SosManagerTest.kt @@ -0,0 +1,792 @@ +package com.lxmf.messenger.service + +import android.content.Context +import android.location.Location +import android.os.BatteryManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.lxmf.messenger.data.model.EnrichedContact +import com.lxmf.messenger.data.repository.ContactRepository +import com.lxmf.messenger.notifications.NotificationHelper +import com.lxmf.messenger.repository.SettingsRepository +import com.lxmf.messenger.reticulum.model.Identity +import com.lxmf.messenger.reticulum.protocol.MessageReceipt +import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34]) +class SosManagerTest { + private val testDispatcher = StandardTestDispatcher() + + private lateinit var context: Context + private lateinit var contactRepository: ContactRepository + private lateinit var settingsRepository: SettingsRepository + private lateinit var reticulumProtocol: ReticulumProtocol + private lateinit var notificationHelper: NotificationHelper + private lateinit var audioRecorder: SosAudioRecorder + private lateinit var sosManager: SosManager + + private val mockIdentity = mockk() + private val mockReceipt = mockk() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + context = mockk(relaxed = true) + contactRepository = mockk() + settingsRepository = mockk() + reticulumProtocol = mockk() + notificationHelper = mockk() + audioRecorder = mockk() + every { audioRecorder.isRecording } returns false + every { audioRecorder.hasPermission() } returns false + every { audioRecorder.start() } returns false + every { audioRecorder.stopRecorder() } returns Unit + every { audioRecorder.readAndDeleteOutputFile() } returns null + every { audioRecorder.cancel() } returns Unit + + // Settings defaults + every { settingsRepository.sosEnabled } returns flowOf(true) + every { settingsRepository.sosCountdownSeconds } returns flowOf(5) + every { settingsRepository.sosMessageTemplate } returns flowOf("SOS! I need help.") + every { settingsRepository.sosIncludeLocation } returns flowOf(false) + every { settingsRepository.sosPeriodicUpdates } returns flowOf(false) + every { settingsRepository.sosUpdateIntervalSeconds } returns flowOf(120) + every { settingsRepository.sosDeactivationPin } returns flowOf(null) + every { settingsRepository.sosSilentAutoAnswer } returns flowOf(false) + every { settingsRepository.sosActive } returns flowOf(false) + every { settingsRepository.sosActiveSentCount } returns flowOf(0) + every { settingsRepository.sosActiveFailedCount } returns flowOf(0) + every { settingsRepository.sosAudioEnabled } returns flowOf(false) + every { settingsRepository.sosAudioDurationSeconds } returns flowOf(30) + coEvery { settingsRepository.persistSosActiveState(any(), any()) } just Runs + coEvery { settingsRepository.clearSosActiveState() } just Runs + + // Contact repository + coEvery { contactRepository.getSosContacts() } returns emptyList() + + // NotificationHelper + every { notificationHelper.showSosActiveNotification(any(), any()) } just Runs + every { notificationHelper.cancelNotification(any()) } just Runs + + // Identity loading + coEvery { reticulumProtocol.loadIdentity("default_identity") } returns Result.success(mockIdentity) + + // Default sendLxmfMessageWithMethod mock + coEvery { + reticulumProtocol.sendLxmfMessageWithMethod( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } returns Result.success(mockReceipt) + + sosManager = + SosManager( + context = context, + contactRepository = contactRepository, + settingsRepository = settingsRepository, + reticulumProtocol = reticulumProtocol, + notificationHelper = notificationHelper, + audioRecorder = audioRecorder, + ) + sosManager.dispatcher = testDispatcher + } + + @After + fun tearDown() { + Dispatchers.resetMain() + clearAllMocks() + } + + private fun makeContact(hexHash: String): EnrichedContact = + EnrichedContact( + destinationHash = hexHash, + publicKey = null, + displayName = "Contact-$hexHash", + customNickname = null, + announceName = null, + lastSeenTimestamp = null, + hops = null, + isOnline = false, + hasConversation = false, + unreadCount = 0, + lastMessageTimestamp = null, + notes = null, + tags = """["sos"]""", + addedTimestamp = System.currentTimeMillis(), + addedVia = "MANUAL", + isPinned = false, + ) + + // ========== State Tests ========== + + @Test + fun `initial state is Idle`() = + runTest { + assertEquals(SosState.Idle, sosManager.state.value) + } + + @Test + fun `trigger when not enabled does nothing`() = + runTest { + every { settingsRepository.sosEnabled } returns flowOf(false) + + sosManager.trigger() + advanceUntilIdle() + + assertEquals(SosState.Idle, sosManager.state.value) + } + + @Test + fun `trigger with countdown starts countdown`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(5) + + sosManager.trigger() + // Advance enough for the launch to start but not complete the countdown + advanceTimeBy(100) + + val state = sosManager.state.value + assertTrue("Expected Countdown state but got $state", state is SosState.Countdown) + } + + @Test + fun `trigger with countdown 0 goes directly to Sending`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue( + "Expected Active state (skipped countdown) but got $state", + state is SosState.Active, + ) + } + + @Test + fun `countdown ticks correctly`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(3) + + sosManager.trigger() + advanceTimeBy(100) // Let coroutine start + + val initialState = sosManager.state.value + assertTrue("Expected Countdown state but got $initialState", initialState is SosState.Countdown) + assertEquals(3, (initialState as SosState.Countdown).remainingSeconds) + + advanceTimeBy(1_000) // After 1 second tick + + val tickedState = sosManager.state.value + if (tickedState is SosState.Countdown) { + assertTrue( + "Expected remaining <= 2 but got ${tickedState.remainingSeconds}", + tickedState.remainingSeconds <= 2, + ) + } + } + + @Test + fun `countdown completion transitions to Sending then Active`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(1) + + sosManager.trigger() + // Advance past countdown (1s) + sending + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue( + "Expected Active after countdown completes but got $state", + state is SosState.Active, + ) + } + + @Test + fun `cancel during countdown returns to Idle`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(10) + + sosManager.trigger() + advanceTimeBy(100) // Let countdown start + + val countdownState = sosManager.state.value + assertTrue("Expected Countdown state but got $countdownState", countdownState is SosState.Countdown) + + sosManager.cancel() + + assertEquals(SosState.Idle, sosManager.state.value) + } + + @Test + fun `cancel when not in countdown does nothing`() = + runTest { + sosManager.cancel() + assertEquals(SosState.Idle, sosManager.state.value) + } + + // ========== Send Tests ========== + + @Test + fun `sendSosMessages with no contacts transitions to Active(0, 0)`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + coEvery { contactRepository.getSosContacts() } returns emptyList() + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(0, (state as SosState.Active).sentCount) + assertEquals(0, state.failedCount) + } + + @Test + fun `sendSosMessages sends to all contacts and counts successes`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + + val contact1 = makeContact("0a0b0c0d0e0f1011") + val contact2 = makeContact("1a1b1c1d1e1f2021") + coEvery { contactRepository.getSosContacts() } returns listOf(contact1, contact2) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(2, (state as SosState.Active).sentCount) + assertEquals(0, state.failedCount) + } + + @Test + fun `sendSosMessages counts failures`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + + val contact1 = makeContact("0a0b0c0d0e0f1011") + val contact2 = makeContact("1a1b1c1d1e1f2021") + coEvery { contactRepository.getSosContacts() } returns listOf(contact1, contact2) + + // Second contact fails + coEvery { + reticulumProtocol.sendLxmfMessageWithMethod( + match { + it.contentEquals( + contact2.destinationHash.chunked(2) + .map { b -> b.toInt(16).toByte() }.toByteArray(), + ) + }, + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } returns Result.failure(RuntimeException("Network error")) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + val activeState = state as SosState.Active + assertTrue( + "Expected at least one failure: sent=${activeState.sentCount}, failed=${activeState.failedCount}", + activeState.failedCount >= 1, + ) + } + + @Test + fun `sendSosMessages shows notification`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + verify { notificationHelper.showSosActiveNotification(any(), any()) } + } + + // ========== Deactivation Tests ========== + + private suspend fun triggerAndWaitForActive() { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + sosManager.trigger() + } + + @Test + fun `deactivate from Active without PIN succeeds`() = + runTest { + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + val result = sosManager.deactivate() + + assertTrue(result) + assertEquals(SosState.Idle, sosManager.state.value) + } + + @Test + fun `deactivate from Active with correct PIN succeeds`() = + runTest { + every { settingsRepository.sosDeactivationPin } returns flowOf("1234") + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + val result = sosManager.deactivate(pin = "1234") + + assertTrue(result) + assertEquals(SosState.Idle, sosManager.state.value) + } + + @Test + fun `deactivate from Active with wrong PIN fails`() = + runTest { + every { settingsRepository.sosDeactivationPin } returns flowOf("1234") + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + val result = sosManager.deactivate(pin = "9999") + + assertFalse(result) + assertTrue(sosManager.state.value is SosState.Active) + } + + @Test + fun `deactivate from non-Active state returns false`() = + runTest { + val result = sosManager.deactivate() + assertFalse(result) + } + + @Test + fun `deactivate cancels periodic updates`() = + runTest { + every { settingsRepository.sosPeriodicUpdates } returns flowOf(true) + every { settingsRepository.sosUpdateIntervalSeconds } returns flowOf(120) + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + val result = sosManager.deactivate() + + assertTrue(result) + assertEquals(SosState.Idle, sosManager.state.value) + } + + @Test + fun `deactivate clears notification`() = + runTest { + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + sosManager.deactivate() + + assertEquals(SosState.Idle, sosManager.state.value) + verify { notificationHelper.cancelNotification(NotificationHelper.NOTIFICATION_ID_SOS) } + } + + // ========== Auto-Answer Tests ========== + + @Test + fun `shouldAutoAnswer when Active and setting enabled returns true`() = + runTest { + every { settingsRepository.sosSilentAutoAnswer } returns flowOf(true) + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + assertTrue(sosManager.shouldAutoAnswer()) + } + + @Test + fun `shouldAutoAnswer when Active but setting disabled returns false`() = + runTest { + every { settingsRepository.sosSilentAutoAnswer } returns flowOf(false) + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + assertFalse(sosManager.shouldAutoAnswer()) + } + + @Test + fun `shouldAutoAnswer when not Active returns false`() = + runTest { + assertFalse(sosManager.shouldAutoAnswer()) + } + + // ========== Re-trigger Tests ========== + + @Test + fun `re-trigger after deactivation works`() = + runTest { + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + sosManager.deactivate() + assertEquals(SosState.Idle, sosManager.state.value) + + // Re-trigger + sosManager.trigger() + advanceUntilIdle() + + assertTrue( + "Expected Active state after re-trigger but got ${sosManager.state.value}", + sosManager.state.value is SosState.Active, + ) + } + + // ========== Location Tests ========== + + @Test + fun `message includes GPS when location available and setting enabled`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + every { settingsRepository.sosIncludeLocation } returns flowOf(true) + + val contact = makeContact("0a0b0c0d0e0f1011") + coEvery { contactRepository.getSosContacts() } returns listOf(contact) + + val mockLocation = mockk() + every { mockLocation.latitude } returns 48.8566 + every { mockLocation.longitude } returns 2.3522 + every { mockLocation.accuracy } returns 10f + every { mockLocation.hasAltitude() } returns false + every { mockLocation.hasSpeed() } returns false + every { mockLocation.hasBearing() } returns false + sosManager.locationProvider = { mockLocation } + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(1, (state as SosState.Active).sentCount) + coVerify { + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = any(), + content = match { it.contains("GPS: 48.856600, 2.352200") && it.contains("accuracy: 10m") }, + sourceIdentity = any(), + deliveryMethod = any(), + tryPropagationOnFail = any(), + imageData = any(), + imageFormat = any(), + fileAttachments = any(), + replyToMessageId = any(), + iconAppearance = any(), + telemetryJson = any(), + audioData = any(), + sosState = any(), + ) + } + } + + @Test + fun `message excludes GPS when setting disabled`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + every { settingsRepository.sosIncludeLocation } returns flowOf(false) + + val contact = makeContact("0a0b0c0d0e0f1011") + coEvery { contactRepository.getSosContacts() } returns listOf(contact) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(1, (state as SosState.Active).sentCount) + coVerify { + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = any(), + content = match { !it.contains("GPS:") }, + sourceIdentity = any(), + deliveryMethod = any(), + tryPropagationOnFail = any(), + imageData = any(), + imageFormat = any(), + fileAttachments = any(), + replyToMessageId = any(), + iconAppearance = any(), + telemetryJson = any(), + audioData = any(), + sosState = any(), + ) + } + } + + // ========== Restore State Tests ========== + + @Test + fun `restoreIfActive when not previously active does nothing`() = + runTest { + every { settingsRepository.sosActive } returns flowOf(false) + + sosManager.restoreIfActive() + advanceUntilIdle() + + val state = sosManager.state.value + assertEquals(SosState.Idle, state) + verify(exactly = 0) { notificationHelper.showSosActiveNotification(any(), any()) } + } + + @Test + fun `restoreIfActive when previously active restores Active state`() = + runTest { + every { settingsRepository.sosActive } returns flowOf(true) + every { settingsRepository.sosActiveSentCount } returns flowOf(3) + every { settingsRepository.sosActiveFailedCount } returns flowOf(1) + every { settingsRepository.sosPeriodicUpdates } returns flowOf(false) + + sosManager.restoreIfActive() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(3, (state as SosState.Active).sentCount) + assertEquals(1, state.failedCount) + } + + @Test + fun `restoreIfActive shows notification with persisted counts`() = + runTest { + every { settingsRepository.sosActive } returns flowOf(true) + every { settingsRepository.sosActiveSentCount } returns flowOf(2) + every { settingsRepository.sosActiveFailedCount } returns flowOf(1) + every { settingsRepository.sosPeriodicUpdates } returns flowOf(false) + + sosManager.restoreIfActive() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(2, (state as SosState.Active).sentCount) + assertEquals(1, state.failedCount) + verify { notificationHelper.showSosActiveNotification(2, 1) } + } + + @Test + fun `restoreIfActive starts periodic updates when enabled`() = + runTest { + every { settingsRepository.sosActive } returns flowOf(true) + every { settingsRepository.sosActiveSentCount } returns flowOf(1) + every { settingsRepository.sosActiveFailedCount } returns flowOf(0) + every { settingsRepository.sosPeriodicUpdates } returns flowOf(true) + every { settingsRepository.sosUpdateIntervalSeconds } returns flowOf(60) + + sosManager.restoreIfActive() + // Only advance enough to let restoreIfActive complete and start periodic updates. + // Don't use advanceUntilIdle() — startPeriodicUpdates() has an infinite loop. + advanceTimeBy(500) + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + + // Deactivate to cancel the periodic update coroutine + sosManager.deactivate() + } + + // ========== Persistence Tests ========== + + @Test + fun `sendSosMessages persists active state`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + val contact = makeContact("0a0b0c0d0e0f1011") + coEvery { contactRepository.getSosContacts() } returns listOf(contact) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(1, (state as SosState.Active).sentCount) + coVerify { settingsRepository.persistSosActiveState(1, 0) } + } + + @Test + fun `deactivate clears persisted state`() = + runTest { + triggerAndWaitForActive() + advanceUntilIdle() + assertTrue(sosManager.state.value is SosState.Active) + + sosManager.deactivate() + advanceUntilIdle() + + assertEquals(SosState.Idle, sosManager.state.value) + coVerify { settingsRepository.clearSosActiveState() } + } + + @Test + fun `sendSosMessages persists correct failure count`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + + val contact1 = makeContact("0a0b0c0d0e0f1011") + val contact2 = makeContact("1a1b1c1d1e1f2021") + coEvery { contactRepository.getSosContacts() } returns listOf(contact1, contact2) + + // Second contact fails + coEvery { + reticulumProtocol.sendLxmfMessageWithMethod( + match { + it.contentEquals( + contact2.destinationHash.chunked(2) + .map { b -> b.toInt(16).toByte() }.toByteArray(), + ) + }, + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } returns Result.failure(RuntimeException("Network error")) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + coVerify { settingsRepository.persistSosActiveState(1, 1) } + } + + @Test + fun `sendSosMessages with failed identity does not send but persists state`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + val contact = makeContact("0a0b0c0d0e0f1011") + coEvery { contactRepository.getSosContacts() } returns listOf(contact) + coEvery { reticulumProtocol.loadIdentity("default_identity") } returns Result.failure(RuntimeException("No identity")) + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(0, (state as SosState.Active).sentCount) + assertEquals(1, state.failedCount) + } + + // ========== Battery Level Tests ========== + + @Test + fun `message includes battery level when available`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + every { settingsRepository.sosIncludeLocation } returns flowOf(false) + + val contact = makeContact("0a0b0c0d0e0f1011") + coEvery { contactRepository.getSosContacts() } returns listOf(contact) + + val mockBatteryManager = mockk() + every { context.getSystemService(Context.BATTERY_SERVICE) } returns mockBatteryManager + every { mockBatteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns 73 + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(1, (state as SosState.Active).sentCount) + coVerify { + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = any(), + content = match { it.contains("Battery: 73%") }, + sourceIdentity = any(), + deliveryMethod = any(), + tryPropagationOnFail = any(), + imageData = any(), + imageFormat = any(), + fileAttachments = any(), + replyToMessageId = any(), + iconAppearance = any(), + telemetryJson = any(), + audioData = any(), + sosState = any(), + ) + } + } + + @Test + fun `message includes both GPS and battery when both available`() = + runTest { + every { settingsRepository.sosCountdownSeconds } returns flowOf(0) + every { settingsRepository.sosIncludeLocation } returns flowOf(true) + + val contact = makeContact("0a0b0c0d0e0f1011") + coEvery { contactRepository.getSosContacts() } returns listOf(contact) + + val mockLocation = mockk() + every { mockLocation.latitude } returns 48.8566 + every { mockLocation.longitude } returns 2.3522 + every { mockLocation.accuracy } returns 10f + every { mockLocation.hasAltitude() } returns false + every { mockLocation.hasSpeed() } returns false + every { mockLocation.hasBearing() } returns false + sosManager.locationProvider = { mockLocation } + + val mockBatteryManager = mockk() + every { context.getSystemService(Context.BATTERY_SERVICE) } returns mockBatteryManager + every { mockBatteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns 42 + every { mockBatteryManager.isCharging } returns false + + sosManager.trigger() + advanceUntilIdle() + + val state = sosManager.state.value + assertTrue("Expected Active state but got $state", state is SosState.Active) + assertEquals(1, (state as SosState.Active).sentCount) + coVerify { + reticulumProtocol.sendLxmfMessageWithMethod( + destinationHash = any(), + content = + match { + it.contains("GPS: 48.856600, 2.352200") && + it.contains("Battery: 42%") + }, + sourceIdentity = any(), + deliveryMethod = any(), + tryPropagationOnFail = any(), + imageData = any(), + imageFormat = any(), + fileAttachments = any(), + replyToMessageId = any(), + iconAppearance = any(), + telemetryJson = any(), + audioData = any(), + sosState = any(), + ) + } + } +} diff --git a/app/src/test/java/com/lxmf/messenger/service/SosTriggerDetectorTest.kt b/app/src/test/java/com/lxmf/messenger/service/SosTriggerDetectorTest.kt new file mode 100644 index 000000000..170b37a93 --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/service/SosTriggerDetectorTest.kt @@ -0,0 +1,583 @@ +package com.lxmf.messenger.service + +import android.content.Context +import com.lxmf.messenger.repository.SettingsRepository +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [SosTriggerDetector] spike-based tap detection and shake detection. + * + * Tests call [handleTap]/[handleShake] directly with controlled timestamps, + * bypassing the sensor layer. This isolates the detection state machines from + * Android sensor APIs. + */ +class SosTriggerDetectorTest { + private lateinit var context: Context + private lateinit var settingsRepository: SettingsRepository + private lateinit var sosManager: SosManager + private lateinit var detector: SosTriggerDetector + + @Before + fun setup() { + context = mockk(relaxed = true) + settingsRepository = mockk() + sosManager = mockk() + + every { sosManager.trigger() } just Runs + every { sosManager.state } returns MutableStateFlow(SosState.Idle) + + detector = SosTriggerDetector(context, settingsRepository, sosManager) + } + + @After + fun tearDown() { + clearAllMocks() + } + + // ========== Tap Detection — Spike-Based State Machine ========== + + /** + * Simulate a single tap: rising edge (above threshold) → falling edge (below threshold). + * @param startTime timestamp of the rising edge + * @param spikeDurationMs how long the spike lasts (must be ≤100ms for a valid tap) + * @param peakAcceleration net acceleration during the spike (must be > 4.0 m/s²) + */ + private fun simulateTap( + startTime: Long, + spikeDurationMs: Long = 50, + peakAcceleration: Float = 6.0f, + ) { + // Rising edge + detector.handleTap(peakAcceleration, startTime) + // Falling edge + detector.handleTap(1.0f, startTime + spikeDurationMs) + } + + /** + * Simulate N taps spaced evenly apart. + * @return the timestamp after the last tap's falling edge + */ + private fun simulateNTaps( + count: Int, + startTime: Long = 10_000L, + intervalMs: Long = 300L, + spikeDurationMs: Long = 50, + ): Long { + var t = startTime + repeat(count) { + simulateTap(t, spikeDurationMs) + t += intervalMs + } + return t + } + + @Test + fun `single tap does not trigger SOS`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + simulateTap(startTime = 10_000L) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `two taps do not trigger when three required`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + simulateNTaps(count = 2) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `three taps within window triggers SOS`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + simulateNTaps(count = 3) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `five taps triggers SOS when required count is 5`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 5 + + simulateNTaps(count = 5) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `four taps do not trigger when five required`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 5 + + simulateNTaps(count = 4) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `taps outside 2500ms window do not accumulate`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + // Two taps at the beginning + simulateTap(startTime = 10_000L) + simulateTap(startTime = 10_300L) + + // Third tap way outside the window (>2500ms later) + simulateTap(startTime = 13_000L) + + // Only 1 tap in window (the third one), first two expired + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `long spike is rejected as walking step`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + val t = 10_000L + // Three "taps" but each spike lasts 150ms (>MAX_TAP_SPIKE_MS of 100ms) + repeat(3) { i -> + val start = t + i * 300L + // Rising edge + detector.handleTap(6.0f, start) + // Sustained above threshold for 110ms (> 100ms limit) + detector.handleTap(6.0f, start + 110) + // Now it's been too long — spike is aborted by the state machine + // Falling edge after abort + detector.handleTap(1.0f, start + 150) + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `taps too close together are deduplicated`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + val t = 10_000L + // First tap + simulateTap(t) + // Second tap only 100ms later (< TAP_MIN_INTERVAL_MS of 150ms) + simulateTap(t + 100) + // Third tap at 200ms (still < 150ms from the second attempt, but the second + // was rejected, so 200ms from first registered tap is fine) + simulateTap(t + 200) + // Fourth tap + simulateTap(t + 400) + + // Only 3 registered: t+50 (first falling edge), t+250 (third falling edge, 200ms gap from first), t+450 + // That should be enough + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `cooldown prevents re-trigger after tap pattern`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + // First trigger + val t1End = simulateNTaps(count = 3, startTime = 10_000L) + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + + // Attempt to trigger again within cooldown (5000ms) + simulateNTaps(count = 3, startTime = t1End + 1_000L) + + // Still only 1 trigger + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `tap works again after cooldown expires`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + // First trigger + simulateNTaps(count = 3, startTime = 10_000L) + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + + // After cooldown (>5000ms from last trigger) + simulateNTaps(count = 3, startTime = 20_000L) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 2) { sosManager.trigger() } + } + + @Test + fun `below-threshold acceleration does not register as tap`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + val t = 10_000L + // Acceleration below TAP_THRESHOLD (4.0 m/s²) — no spike starts + repeat(5) { i -> + detector.handleTap(3.0f, t + i * 300L) + detector.handleTap(1.0f, t + i * 300L + 50) + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `resetTapState clears all tap state`() { + detector.activeModes = setOf(SosTriggerMode.TAP_PATTERN) + detector.requiredTapCount = 3 + + // Register 2 taps + simulateNTaps(count = 2, startTime = 10_000L) + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + + // Reset mid-sequence + detector.resetTapState() + + // Need 3 more taps from scratch + simulateNTaps(count = 2, startTime = 11_000L) + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + + simulateNTaps(count = 3, startTime = 12_000L) + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + // ========== Shake Detection ========== + + /** Mirrors the detector's threshold formula: (0.5 + sensitivity * 0.5) * g. */ + private fun shakeThreshold(sensitivity: Float) = (0.5f + sensitivity * 0.5f) * 9.81f + + @Test + fun `brief shake below duration does not trigger`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // Above threshold for only 200ms (< SHAKE_DURATION_MS of 500ms) + detector.handleShake(threshold + 5f, t) + detector.handleShake(threshold + 5f, t + 100) + detector.handleShake(threshold + 5f, t + 200) + // Drop below threshold + detector.handleShake(1.0f, t + 201) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `sustained shake triggers SOS`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // Sustained above threshold for 600ms (> SHAKE_DURATION_MS of 500ms) + // Simulate sensor events every 100ms + for (i in 0..6) { + detector.handleShake(threshold + 5f, t + i * 100L) + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `shake below threshold does not trigger`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // Acceleration below threshold for a long time + for (i in 0..20) { + detector.handleShake(threshold - 5f, t + i * 100L) + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `shake outside 1000ms window resets`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // Above threshold but window exceeds SHAKE_WINDOW_MS (1000ms) + detector.handleShake(threshold + 5f, t) + detector.handleShake(threshold + 5f, t + 400) + // Jump past the 1000ms window + detector.handleShake(threshold + 5f, t + 1_100) + + // Window expired, state should have reset; continue shaking + detector.handleShake(threshold + 5f, t + 1_200) + detector.handleShake(threshold + 5f, t + 1_300) + + // Not enough accumulated time in new window + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `gap in shake resets state`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // Shake for 300ms + detector.handleShake(threshold + 5f, t) + detector.handleShake(threshold + 5f, t + 100) + detector.handleShake(threshold + 5f, t + 200) + detector.handleShake(threshold + 5f, t + 300) + + // Drop below threshold with gap > 200ms + detector.handleShake(1.0f, t + 600) + + // Resume shaking — state was reset + detector.handleShake(threshold + 5f, t + 700) + detector.handleShake(threshold + 5f, t + 800) + detector.handleShake(threshold + 5f, t + 900) + + // Not enough continuous time since reset + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `shake cooldown prevents re-trigger`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // First trigger + for (i in 0..6) { + detector.handleShake(threshold + 5f, t + i * 100L) + } + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + + // Try again within cooldown (5000ms) + val t2 = t + 2_000L + for (i in 0..6) { + detector.handleShake(threshold + 5f, t2 + i * 100L) + } + + // Still only 1 trigger + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `shake works again after cooldown expires`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // First trigger + for (i in 0..6) { + detector.handleShake(threshold + 5f, t + i * 100L) + } + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + + // After cooldown (>5000ms) + val t2 = t + 20_000L + for (i in 0..6) { + detector.handleShake(threshold + 5f, t2 + i * 100L) + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 2) { sosManager.trigger() } + } + + @Test + fun `higher sensitivity requires less acceleration`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 1.0f // Low sensitivity → lower threshold + + val threshold = shakeThreshold(1.0f) + val t = 10_000L + + // This acceleration is above low threshold but would be below 2.5x threshold + for (i in 0..6) { + detector.handleShake(threshold + 2f, t + i * 100L) + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `bursty spikes separated by short dips do not trigger shake`() { + // Regression: previously, a dip below threshold ≤200ms did not reset + // lastShakeEventTime, so the next above-threshold sample would credit + // the ENTIRE dip duration into shakeAccumulatedMs, letting sparse + // bumps (running / dropped bag) falsely trigger SOS even at 4.0x. + // + // This test simulates 5 brief spikes (30 ms above threshold) spaced + // 150 ms apart — total above-threshold time ~150 ms, well under the + // 500 ms required. It must NOT trigger. + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 4.0f + + val threshold = shakeThreshold(4.0f) + val t = 10_000L + val spikeDuration = 30L + val interSpikeGap = 150L + + repeat(5) { i -> + val spikeStart = t + i * (spikeDuration + interSpikeGap) + // Above-threshold for `spikeDuration`, sampled every 10 ms + var s = spikeStart + while (s <= spikeStart + spikeDuration) { + detector.handleShake(threshold + 5f, s) + s += 10L + } + // Dip below threshold, sampled every 30 ms through the gap + var d = spikeStart + spikeDuration + 30L + while (d < spikeStart + spikeDuration + interSpikeGap) { + detector.handleShake(1.0f, d) + d += 30L + } + } + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `resetShakeState clears shake tracking`() { + detector.activeModes = setOf(SosTriggerMode.SHAKE) + detector.shakeSensitivity = 2.5f + + val threshold = shakeThreshold(2.5f) + val t = 10_000L + + // Accumulate 400ms of shake + for (i in 0..4) { + detector.handleShake(threshold + 5f, t + i * 100L) + } + + // Reset mid-shake + detector.resetShakeState() + + // Continue shaking — but accumulated time was reset + for (i in 5..7) { + detector.handleShake(threshold + 5f, t + i * 100L) + } + + // Only 300ms accumulated after reset, not enough + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + // ========== SosTriggerMode Enum Tests ========== + + @Test + fun `fromKey returns null for unknown key`() { + assertNull(SosTriggerMode.fromKey("unknown")) + } + + @Test + fun `fromKey returns correct mode for each key`() { + assertEquals(SosTriggerMode.SHAKE, SosTriggerMode.fromKey("shake")) + assertEquals(SosTriggerMode.TAP_PATTERN, SosTriggerMode.fromKey("tap_pattern")) + assertEquals(SosTriggerMode.POWER_BUTTON, SosTriggerMode.fromKey("power_button")) + } + + @Test + fun `fromKeys converts set of key strings to modes`() { + val modes = SosTriggerMode.fromKeys(setOf("shake", "power_button")) + assertEquals(setOf(SosTriggerMode.SHAKE, SosTriggerMode.POWER_BUTTON), modes) + } + + @Test + fun `fromKeys ignores unknown keys`() { + val modes = SosTriggerMode.fromKeys(setOf("shake", "unknown")) + assertEquals(setOf(SosTriggerMode.SHAKE), modes) + } + + // ========== Power Button Tests ========== + + @Test + fun `handlePowerPress triggers SOS after 3 rapid presses`() { + detector.activeModes = setOf(SosTriggerMode.POWER_BUTTON) + val t = 10_000L + + detector.handlePowerPress(t) + detector.handlePowerPress(t + 500L) + detector.handlePowerPress(t + 1000L) + + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 1) { sosManager.trigger() } + } + + @Test + fun `handlePowerPress does not trigger with only 2 presses`() { + detector.activeModes = setOf(SosTriggerMode.POWER_BUTTON) + val t = 10_000L + + detector.handlePowerPress(t) + detector.handlePowerPress(t + 500L) + + // SOS should not have been triggered — state stays Idle + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } + + @Test + fun `handlePowerPress does not trigger if presses are too spread out`() { + detector.activeModes = setOf(SosTriggerMode.POWER_BUTTON) + val t = 10_000L + + detector.handlePowerPress(t) + detector.handlePowerPress(t + 1500L) + detector.handlePowerPress(t + 3000L) // first press expired from window + + // SOS should not have been triggered — state stays Idle + assertEquals(SosState.Idle, sosManager.state.value) + verify(exactly = 0) { sosManager.trigger() } + } +} diff --git a/app/src/test/java/com/lxmf/messenger/service/TelemetryCollectorManagerTest.kt b/app/src/test/java/com/lxmf/messenger/service/TelemetryCollectorManagerTest.kt index c7cc029fe..0806d99a2 100644 --- a/app/src/test/java/com/lxmf/messenger/service/TelemetryCollectorManagerTest.kt +++ b/app/src/test/java/com/lxmf/messenger/service/TelemetryCollectorManagerTest.kt @@ -468,12 +468,13 @@ class TelemetryCollectorManagerTest { every { LocationCompat.isPlayServicesAvailable(any()) } returns false every { LocationCompat.getCurrentLocation(any(), any()) } answers { val callback = secondArg<(Location?) -> Unit>() - val location = Location("test").apply { - latitude = 48.8566 - longitude = 2.3522 - accuracy = 5f - time = System.currentTimeMillis() - } + val location = + Location("test").apply { + latitude = 48.8566 + longitude = 2.3522 + accuracy = 5f + time = System.currentTimeMillis() + } callback(location) } @@ -521,12 +522,13 @@ class TelemetryCollectorManagerTest { every { LocationCompat.isPlayServicesAvailable(any()) } returns false every { LocationCompat.getCurrentLocation(any(), any()) } answers { val callback = secondArg<(Location?) -> Unit>() - val location = Location("test").apply { - latitude = 48.8566 - longitude = 2.3522 - accuracy = 5f - time = System.currentTimeMillis() - } + val location = + Location("test").apply { + latitude = 48.8566 + longitude = 2.3522 + accuracy = 5f + time = System.currentTimeMillis() + } callback(location) } diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt index fb456b109..fc7e339f0 100644 --- a/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt +++ b/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt @@ -1015,8 +1015,10 @@ class ChatsScreenTest { every { mockViewModel.manualSyncResult } returns MutableSharedFlow() every { mockViewModel.draftsMap } returns MutableStateFlow(emptyMap()) - // Default: contacts are not saved + // Default: contacts are not saved, not SOS every { mockViewModel.isContactSaved(any()) } returns MutableStateFlow(false) + every { mockViewModel.isSosContact(any()) } returns MutableStateFlow(false) + every { mockViewModel.hasSosActive(any()) } returns MutableStateFlow(false) return mockViewModel } diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt index a56452790..ffef4cddb 100644 --- a/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt +++ b/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt @@ -625,6 +625,8 @@ class ContactsScreenTest { onEditNickname = {}, onViewDetails = {}, onRemove = {}, + isSos = false, + onToggleSos = {}, ) } @@ -647,6 +649,8 @@ class ContactsScreenTest { onEditNickname = {}, onViewDetails = {}, onRemove = {}, + isSos = false, + onToggleSos = {}, ) } @@ -666,6 +670,8 @@ class ContactsScreenTest { onEditNickname = {}, onViewDetails = {}, onRemove = {}, + isSos = false, + onToggleSos = {}, ) } @@ -687,6 +693,8 @@ class ContactsScreenTest { onEditNickname = {}, onViewDetails = {}, onRemove = {}, + isSos = false, + onToggleSos = {}, ) } @@ -710,6 +718,8 @@ class ContactsScreenTest { onEditNickname = { editCalled = true }, onViewDetails = {}, onRemove = {}, + isSos = false, + onToggleSos = {}, ) } @@ -733,6 +743,8 @@ class ContactsScreenTest { onEditNickname = {}, onViewDetails = { viewDetailsCalled = true }, onRemove = {}, + isSos = false, + onToggleSos = {}, ) } @@ -756,6 +768,8 @@ class ContactsScreenTest { onEditNickname = {}, onViewDetails = {}, onRemove = { removeCalled = true }, + isSos = false, + onToggleSos = {}, ) } @@ -1232,6 +1246,7 @@ class ContactsScreenTest { every { mockViewModel.contactCount } returns MutableStateFlow(contactCount) every { mockViewModel.searchQuery } returns MutableStateFlow(searchQuery) every { mockViewModel.currentRelayInfo } returns MutableStateFlow(currentRelayInfo) + every { mockViewModel.sosActiveSenders } returns MutableStateFlow(emptySet()) // Mock decodeQrCode to return null by default coEvery { mockViewModel.decodeQrCode(any()) } returns null diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/MigrationScreenPasswordDialogTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/MigrationScreenPasswordDialogTest.kt index 848c81931..ac6e1312e 100644 --- a/app/src/test/java/com/lxmf/messenger/ui/screens/MigrationScreenPasswordDialogTest.kt +++ b/app/src/test/java/com/lxmf/messenger/ui/screens/MigrationScreenPasswordDialogTest.kt @@ -13,11 +13,9 @@ import com.lxmf.messenger.viewmodel.MigrationUiState import com.lxmf.messenger.viewmodel.MigrationViewModel import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain diff --git a/app/src/test/java/com/lxmf/messenger/ui/util/MarkerDeclutterTest.kt b/app/src/test/java/com/lxmf/messenger/ui/util/MarkerDeclutterTest.kt index 9f13d102e..130fda273 100644 --- a/app/src/test/java/com/lxmf/messenger/ui/util/MarkerDeclutterTest.kt +++ b/app/src/test/java/com/lxmf/messenger/ui/util/MarkerDeclutterTest.kt @@ -26,9 +26,10 @@ class MarkerDeclutterTest { @Test fun `single marker is not offset`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 100f, 200f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 100f, 200f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(1, result.size) assertFalse(result[0].isOffset) @@ -40,10 +41,11 @@ class MarkerDeclutterTest { @Test fun `two distant markers are not offset`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 0f, 0f), - ScreenMarker("b", 11.0, 21.0, 200f, 200f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 0f, 0f), + ScreenMarker("b", 11.0, 21.0, 200f, 200f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(2, result.size) assertFalse(result[0].isOffset) @@ -53,10 +55,11 @@ class MarkerDeclutterTest { @Test fun `markers just outside threshold are not grouped`() { // Default threshold is 60px, place markers 61px apart - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 0f, 0f), - ScreenMarker("b", 11.0, 21.0, 61f, 0f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 0f, 0f), + ScreenMarker("b", 11.0, 21.0, 61f, 0f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(2, result.size) assertFalse(result[0].isOffset) @@ -67,10 +70,11 @@ class MarkerDeclutterTest { @Test fun `two overlapping markers are both offset`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 100f, 100f), - ScreenMarker("b", 10.001, 20.001, 110f, 105f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 100f, 100f), + ScreenMarker("b", 10.001, 20.001, 110f, 105f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(2, result.size) assertTrue(result[0].isOffset) @@ -79,10 +83,11 @@ class MarkerDeclutterTest { @Test fun `offset markers preserve original positions`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 100f, 100f), - ScreenMarker("b", 10.001, 20.001, 110f, 105f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 100f, 100f), + ScreenMarker("b", 10.001, 20.001, 110f, 105f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(10.0, result[0].originalLat, 0.001) assertEquals(20.0, result[0].originalLng, 0.001) @@ -92,10 +97,11 @@ class MarkerDeclutterTest { @Test fun `offset markers have display positions different from original`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 100f, 100f), - ScreenMarker("b", 10.001, 20.001, 110f, 105f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 100f, 100f), + ScreenMarker("b", 10.001, 20.001, 110f, 105f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) // Display positions should differ from original GPS positions val a = result.find { it.hash == "a" }!! @@ -106,10 +112,11 @@ class MarkerDeclutterTest { @Test fun `two offset markers are equidistant from centroid`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 100f, 100f), - ScreenMarker("b", 10.001, 20.001, 100f, 110f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 100f, 100f), + ScreenMarker("b", 10.001, 20.001, 100f, 110f), + ) // Use converter that preserves screen distances (identity mapping) val pixelConverter = ScreenToLatLng { x, y -> Pair(y.toDouble(), x.toDouble()) } val result = calculateDeclutteredPositions(markers, pixelConverter) @@ -117,14 +124,16 @@ class MarkerDeclutterTest { val cx = 100.0 // centroid X val cy = 105.0 // centroid Y - val distA = kotlin.math.sqrt( - (result[0].displayLng - cx) * (result[0].displayLng - cx) + - (result[0].displayLat - cy) * (result[0].displayLat - cy), - ) - val distB = kotlin.math.sqrt( - (result[1].displayLng - cx) * (result[1].displayLng - cx) + - (result[1].displayLat - cy) * (result[1].displayLat - cy), - ) + val distA = + kotlin.math.sqrt( + (result[0].displayLng - cx) * (result[0].displayLng - cx) + + (result[0].displayLat - cy) * (result[0].displayLat - cy), + ) + val distB = + kotlin.math.sqrt( + (result[1].displayLng - cx) * (result[1].displayLng - cx) + + (result[1].displayLat - cy) * (result[1].displayLat - cy), + ) assertEquals(distA, distB, 0.1) } @@ -132,14 +141,15 @@ class MarkerDeclutterTest { @Test fun `two separate clusters are handled independently`() { - val markers = listOf( - // Cluster 1 (near 0,0) - ScreenMarker("a1", 10.0, 20.0, 0f, 0f), - ScreenMarker("a2", 10.001, 20.001, 10f, 5f), - // Cluster 2 (near 500,500) - ScreenMarker("b1", 50.0, 60.0, 500f, 500f), - ScreenMarker("b2", 50.001, 60.001, 510f, 505f), - ) + val markers = + listOf( + // Cluster 1 (near 0,0) + ScreenMarker("a1", 10.0, 20.0, 0f, 0f), + ScreenMarker("a2", 10.001, 20.001, 10f, 5f), + // Cluster 2 (near 500,500) + ScreenMarker("b1", 50.0, 60.0, 500f, 500f), + ScreenMarker("b2", 50.001, 60.001, 510f, 505f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(4, result.size) // All should be offset (both clusters have 2 overlapping markers) @@ -148,13 +158,14 @@ class MarkerDeclutterTest { @Test fun `mixed isolated and clustered markers`() { - val markers = listOf( - // Cluster - ScreenMarker("a", 10.0, 20.0, 100f, 100f), - ScreenMarker("b", 10.001, 20.001, 110f, 105f), - // Isolated - ScreenMarker("c", 50.0, 60.0, 500f, 500f), - ) + val markers = + listOf( + // Cluster + ScreenMarker("a", 10.0, 20.0, 100f, 100f), + ScreenMarker("b", 10.001, 20.001, 110f, 105f), + // Isolated + ScreenMarker("c", 50.0, 60.0, 500f, 500f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(3, result.size) val a = result.find { it.hash == "a" }!! @@ -170,9 +181,10 @@ class MarkerDeclutterTest { @Test fun `large group uses scaled radius`() { // 8 markers at the same pixel position - val markers = (0 until 8).map { i -> - ScreenMarker("m$i", 10.0 + i * 0.0001, 20.0, 100f + i, 100f + i) - } + val markers = + (0 until 8).map { i -> + ScreenMarker("m$i", 10.0 + i * 0.0001, 20.0, 100f + i, 100f + i) + } val pixelConverter = ScreenToLatLng { x, y -> Pair(y.toDouble(), x.toDouble()) } val result = calculateDeclutteredPositions(markers, pixelConverter) @@ -188,13 +200,15 @@ class MarkerDeclutterTest { fun `radius scales with group size`() { // For a group of 8, minRadius = 8 * 75 / (2*PI) ≈ 95.5, which is > DECLUTTER_OFFSET_PX (70) // For a group of 2, minRadius = 2 * 75 / (2*PI) ≈ 23.9, so DECLUTTER_OFFSET_PX (70) is used - val smallGroup = listOf( - ScreenMarker("a", 10.0, 20.0, 100f, 100f), - ScreenMarker("b", 10.001, 20.001, 100f, 110f), - ) - val largeGroup = (0 until 8).map { i -> - ScreenMarker("m$i", 10.0, 20.0, 100f, 100f + i) - } + val smallGroup = + listOf( + ScreenMarker("a", 10.0, 20.0, 100f, 100f), + ScreenMarker("b", 10.001, 20.001, 100f, 110f), + ) + val largeGroup = + (0 until 8).map { i -> + ScreenMarker("m$i", 10.0, 20.0, 100f, 100f + i) + } val pixelConverter = ScreenToLatLng { x, y -> Pair(y.toDouble(), x.toDouble()) } val smallResult = calculateDeclutteredPositions(smallGroup, pixelConverter) @@ -202,31 +216,40 @@ class MarkerDeclutterTest { // Compute max offset distance from centroid for each group val smallCy = 105.0 - val smallMaxDist = smallResult.maxOf { dm -> - kotlin.math.sqrt((dm.displayLat - smallCy) * (dm.displayLat - smallCy) + - (dm.displayLng - 100.0) * (dm.displayLng - 100.0)) - } + val smallMaxDist = + smallResult.maxOf { dm -> + kotlin.math.sqrt( + (dm.displayLat - smallCy) * (dm.displayLat - smallCy) + + (dm.displayLng - 100.0) * (dm.displayLng - 100.0), + ) + } val largeCy = largeGroup.map { it.screenY.toDouble() }.average() val largeCx = 100.0 - val largeMaxDist = largeResult.maxOf { dm -> - kotlin.math.sqrt((dm.displayLat - largeCy) * (dm.displayLat - largeCy) + - (dm.displayLng - largeCx) * (dm.displayLng - largeCx)) - } + val largeMaxDist = + largeResult.maxOf { dm -> + kotlin.math.sqrt( + (dm.displayLat - largeCy) * (dm.displayLat - largeCy) + + (dm.displayLng - largeCx) * (dm.displayLng - largeCx), + ) + } // Large group should have bigger radius - assertTrue("Large group radius ($largeMaxDist) should be > small group radius ($smallMaxDist)", - largeMaxDist > smallMaxDist) + assertTrue( + "Large group radius ($largeMaxDist) should be > small group radius ($smallMaxDist)", + largeMaxDist > smallMaxDist, + ) } // ========== Custom thresholds ========== @Test fun `custom overlap threshold groups markers differently`() { - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 0f, 0f), - ScreenMarker("b", 11.0, 21.0, 50f, 0f), - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 0f, 0f), + ScreenMarker("b", 11.0, 21.0, 50f, 0f), + ) // With default threshold (60px), they overlap val resultOverlap = calculateDeclutteredPositions(markers, identityConverter, overlapThresholdPx = 60f) assertTrue(resultOverlap.all { it.isOffset }) @@ -240,11 +263,12 @@ class MarkerDeclutterTest { @Test fun `all marker hashes are preserved in output`() { - val markers = listOf( - ScreenMarker("alpha", 10.0, 20.0, 100f, 100f), - ScreenMarker("beta", 10.001, 20.001, 110f, 105f), - ScreenMarker("gamma", 50.0, 60.0, 500f, 500f), - ) + val markers = + listOf( + ScreenMarker("alpha", 10.0, 20.0, 100f, 100f), + ScreenMarker("beta", 10.001, 20.001, 110f, 105f), + ScreenMarker("gamma", 50.0, 60.0, 500f, 500f), + ) val result = calculateDeclutteredPositions(markers, identityConverter) val hashes = result.map { it.hash }.toSet() assertEquals(setOf("alpha", "beta", "gamma"), hashes) @@ -256,11 +280,12 @@ class MarkerDeclutterTest { fun `transitive overlap groups markers in chain`() { // A is near B, B is near C, but A is far from C // All three should end up in the same group via Union-Find - val markers = listOf( - ScreenMarker("a", 10.0, 20.0, 0f, 0f), - ScreenMarker("b", 10.001, 20.001, 50f, 0f), // 50px from A (< 60) - ScreenMarker("c", 10.002, 20.002, 100f, 0f), // 50px from B, 100px from A - ) + val markers = + listOf( + ScreenMarker("a", 10.0, 20.0, 0f, 0f), + ScreenMarker("b", 10.001, 20.001, 50f, 0f), // 50px from A (< 60) + ScreenMarker("c", 10.002, 20.002, 100f, 0f), // 50px from B, 100px from A + ) val result = calculateDeclutteredPositions(markers, identityConverter) assertEquals(3, result.size) // All should be in one group and offset diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/ApkSharingViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/ApkSharingViewModelTest.kt index 1dc5eae7a..cc2457209 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/ApkSharingViewModelTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/ApkSharingViewModelTest.kt @@ -155,15 +155,16 @@ class ApkSharingViewModelTest { @Test fun `ApkSharingState hotspot mode has all fields`() { - val state = ApkSharingState( - isServerRunning = true, - downloadUrl = "http://192.168.43.1:9090", - localIp = "192.168.43.1", - sharingMode = SharingMode.HOTSPOT, - hotspotSsid = "DIRECT-ab-MyPhone", - hotspotPassword = "s3cur3P4ss", - apkSizeBytes = 12_000_000, - ) + val state = + ApkSharingState( + isServerRunning = true, + downloadUrl = "http://192.168.43.1:9090", + localIp = "192.168.43.1", + sharingMode = SharingMode.HOTSPOT, + hotspotSsid = "DIRECT-ab-MyPhone", + hotspotPassword = "s3cur3P4ss", + apkSizeBytes = 12_000_000, + ) assertTrue(state.isServerRunning) assertEquals(SharingMode.HOTSPOT, state.sharingMode) diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt index b72465db2..c501948dc 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt @@ -588,32 +588,32 @@ class ContactsViewModelTest { // ========== Update Operations Tests ========== @Test - fun `updateNickname - calls repository`() = + fun `updateContact nickname - calls repository`() = runTest { // Given coEvery { contactRepository.updateNickname(any(), any()) } just Runs // When - val result = runCatching { viewModel.updateNickname(testDestHash, "New Nickname") } + val result = runCatching { viewModel.updateContact(testDestHash, nickname = "New Nickname") } advanceUntilIdle() // Then - assertTrue("updateNickname should complete without exception", result.isSuccess) + assertTrue("updateContact should complete without exception", result.isSuccess) coVerify { contactRepository.updateNickname(testDestHash, "New Nickname") } } @Test - fun `updateNotes - calls repository`() = + fun `updateContact notes - calls repository`() = runTest { // Given coEvery { contactRepository.updateNotes(any(), any()) } just Runs // When - val result = runCatching { viewModel.updateNotes(testDestHash, "Some notes") } + val result = runCatching { viewModel.updateContact(testDestHash, notes = "Some notes") } advanceUntilIdle() // Then - assertTrue("updateNotes should complete without exception", result.isSuccess) + assertTrue("updateContact should complete without exception", result.isSuccess) coVerify { contactRepository.updateNotes(testDestHash, "Some notes") } } @@ -853,17 +853,17 @@ class ContactsViewModelTest { } @Test - fun `updateNickname - handles errors gracefully`() = + fun `updateContact - handles errors gracefully`() = runTest { // Given coEvery { contactRepository.updateNickname(any(), any()) } throws RuntimeException("DB error") // When: Should not crash - val result = runCatching { viewModel.updateNickname(testDestHash, "Name") } + val result = runCatching { viewModel.updateContact(testDestHash, nickname = "Name") } advanceUntilIdle() // Then: Verify attempt was made and error was handled gracefully - assertTrue("updateNickname should handle errors gracefully", result.isSuccess) + assertTrue("updateContact should handle errors gracefully", result.isSuccess) coVerify { contactRepository.updateNickname(testDestHash, "Name") } } diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelAppearanceTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelAppearanceTest.kt index 6d2914443..a002053ec 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelAppearanceTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelAppearanceTest.kt @@ -91,7 +91,7 @@ class MapViewModelAppearanceTest { assertNotNull(result) assertEquals("a1b2c3", result!!.second) // second = foreground - assertEquals("0f1e2d", result.third) // third = background + assertEquals("0f1e2d", result.third) // third = background } @Test @@ -108,66 +108,72 @@ class MapViewModelAppearanceTest { @Test fun `fresh location returns FRESH`() { val now = System.currentTimeMillis() - val result = MapViewModel.calculateMarkerState( - timestamp = now - 60_000L, // 1 minute ago - expiresAt = now + 3600_000L, // expires in 1 hour - currentTime = now, - ) + val result = + MapViewModel.calculateMarkerState( + timestamp = now - 60_000L, // 1 minute ago + expiresAt = now + 3600_000L, // expires in 1 hour + currentTime = now, + ) assertEquals(MarkerState.FRESH, result) } @Test fun `stale location returns STALE`() { val now = System.currentTimeMillis() - val result = MapViewModel.calculateMarkerState( - timestamp = now - 6 * 60_000L, // 6 minutes ago (> 5 min threshold) - expiresAt = now + 3600_000L, - currentTime = now, - ) + val result = + MapViewModel.calculateMarkerState( + timestamp = now - 6 * 60_000L, // 6 minutes ago (> 5 min threshold) + expiresAt = now + 3600_000L, + currentTime = now, + ) assertEquals(MarkerState.STALE, result) } @Test fun `expired within grace period returns EXPIRED_GRACE_PERIOD`() { val now = System.currentTimeMillis() - val result = MapViewModel.calculateMarkerState( - timestamp = now - 10 * 60_000L, - expiresAt = now - 1000L, // just expired - currentTime = now, - ) + val result = + MapViewModel.calculateMarkerState( + timestamp = now - 10 * 60_000L, + expiresAt = now - 1000L, // just expired + currentTime = now, + ) assertEquals(MarkerState.EXPIRED_GRACE_PERIOD, result) } @Test fun `expired beyond grace period returns null`() { val now = System.currentTimeMillis() - val result = MapViewModel.calculateMarkerState( - timestamp = now - 10 * 60_000L, - expiresAt = now - 2 * 3600_000L, // expired 2 hours ago (> 1 hour grace) - currentTime = now, - ) + val result = + MapViewModel.calculateMarkerState( + timestamp = now - 10 * 60_000L, + expiresAt = now - 2 * 3600_000L, // expired 2 hours ago (> 1 hour grace) + currentTime = now, + ) assertNull(result) } @Test fun `null expiresAt with fresh timestamp returns FRESH`() { val now = System.currentTimeMillis() - val result = MapViewModel.calculateMarkerState( - timestamp = now - 60_000L, - expiresAt = null, // indefinite sharing - currentTime = now, - ) + val result = + MapViewModel.calculateMarkerState( + timestamp = now - 60_000L, + expiresAt = null, // indefinite sharing + currentTime = now, + ) assertEquals(MarkerState.FRESH, result) } @Test fun `null expiresAt with stale timestamp returns STALE`() { val now = System.currentTimeMillis() - val result = MapViewModel.calculateMarkerState( - timestamp = now - 10 * 60_000L, // 10 minutes ago - expiresAt = null, - currentTime = now, - ) + val result = + MapViewModel.calculateMarkerState( + timestamp = now - 10 * 60_000L, // 10 minutes ago + expiresAt = null, + currentTime = now, + ) assertEquals(MarkerState.STALE, result) } @@ -175,15 +181,16 @@ class MapViewModelAppearanceTest { @Test fun `ContactMarker uses telemetry appearance when available`() { - val marker = ContactMarker( - destinationHash = "abc123", - displayName = "Alice", - latitude = 37.7749, - longitude = -122.4194, - iconName = "person", // from telemetryAppearance?.first - iconForegroundColor = "ff0000", // from telemetryAppearance?.second - iconBackgroundColor = "00ff00", // from telemetryAppearance?.third - ) + val marker = + ContactMarker( + destinationHash = "abc123", + displayName = "Alice", + latitude = 37.7749, + longitude = -122.4194, + iconName = "person", // from telemetryAppearance?.first + iconForegroundColor = "ff0000", // from telemetryAppearance?.second + iconBackgroundColor = "00ff00", // from telemetryAppearance?.third + ) assertEquals("person", marker.iconName) assertEquals("ff0000", marker.iconForegroundColor) @@ -192,12 +199,13 @@ class MapViewModelAppearanceTest { @Test fun `ContactMarker defaults icon fields to null`() { - val marker = ContactMarker( - destinationHash = "abc123", - displayName = "Alice", - latitude = 37.7749, - longitude = -122.4194, - ) + val marker = + ContactMarker( + destinationHash = "abc123", + displayName = "Alice", + latitude = 37.7749, + longitude = -122.4194, + ) assertNull(marker.iconName) assertNull(marker.iconForegroundColor) diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/MessagingViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/MessagingViewModelTest.kt index df93433ee..c2cb9ee27 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/MessagingViewModelTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/MessagingViewModelTest.kt @@ -312,7 +312,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -357,7 +357,7 @@ class MessagingViewModelTest { runViewModelTest { // Setup: Mock failed LXMF send coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.failure(Exception("Network error")) coEvery { @@ -395,7 +395,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -501,7 +501,7 @@ class MessagingViewModelTest { // This avoids crashes when LXMF router isn't ready yet // Send a message to trigger identity loading coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(MessageReceipt(ByteArray(32), 3000L, testPeerHash.chunked(2).map { it.toInt(16).toByte() }.toByteArray())) coEvery { @@ -593,7 +593,9 @@ class MessagingViewModelTest { assertTrue("sendMessage should complete without error", result.isSuccess) // Verify: sendLxmfMessageWithMethod was NOT called - coVerify(exactly = 0) { failingProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } + coVerify( + exactly = 0, + ) { failingProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Verify: saveMessage was NOT called coVerify(exactly = 0) { failingRepository.saveMessage(any(), any(), any(), any()) } @@ -652,7 +654,7 @@ class MessagingViewModelTest { // Assert: Protocol NOT called coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Message NOT saved to database @@ -677,7 +679,7 @@ class MessagingViewModelTest { // Verify: No protocol call made coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Verify: No save to database @@ -701,7 +703,7 @@ class MessagingViewModelTest { // Assert: Protocol NOT called coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Message NOT saved @@ -725,7 +727,7 @@ class MessagingViewModelTest { // Assert: Protocol NOT called coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Message NOT saved @@ -752,7 +754,7 @@ class MessagingViewModelTest { // Assert: Protocol NOT called coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Message NOT saved @@ -772,7 +774,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -824,7 +826,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -844,7 +846,7 @@ class MessagingViewModelTest { // Assert: Protocol was called (message is valid) coVerify(exactly = 1) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Message was saved @@ -865,7 +867,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -884,7 +886,7 @@ class MessagingViewModelTest { // Assert: Protocol was called coVerify(exactly = 1) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Message was saved @@ -911,7 +913,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -964,7 +966,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -988,7 +990,7 @@ class MessagingViewModelTest { // Verify protocol was called coVerify(exactly = 1) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Image was cleared after successful send @@ -2710,7 +2712,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -2759,7 +2761,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { @@ -2782,7 +2784,7 @@ class MessagingViewModelTest { // Verify protocol was called coVerify(exactly = 1) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // File attachments should be cleared after successful send @@ -3390,7 +3392,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs @@ -3443,7 +3445,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs @@ -3475,7 +3477,7 @@ class MessagingViewModelTest { // Verify protocol was called coVerify(exactly = 1) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } // Assert: Pending reply was cleared after successful send @@ -3494,7 +3496,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs @@ -4432,7 +4434,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs @@ -4455,7 +4457,7 @@ class MessagingViewModelTest { fun `isSending is false after failed send`() = runViewModelTest { coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.failure(Exception("Network error")) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs @@ -4488,7 +4490,7 @@ class MessagingViewModelTest { // Protocol should not be called coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } } @@ -4518,7 +4520,7 @@ class MessagingViewModelTest { // Protocol should not be called for non-failed messages coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } } @@ -4549,7 +4551,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.updateMessageId(any(), any()) } just Runs @@ -4602,7 +4604,7 @@ class MessagingViewModelTest { // Mock send failure coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.failure(Exception("Network error")) val result = runCatching { viewModel.retryFailedMessage("msg-123") } @@ -4644,7 +4646,7 @@ class MessagingViewModelTest { // Protocol should not be called due to invalid hash coVerify(exactly = 0) { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } } @@ -4780,7 +4782,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs @@ -4829,7 +4831,7 @@ class MessagingViewModelTest { destinationHash = destHashBytes, ) coEvery { - reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(testReceipt) coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt index 888fd2d38..b3991b604 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt @@ -186,6 +186,24 @@ class SettingsViewModelIncomingMessageLimitTest { every { settingsRepository.telemetryAllowedRequestersFlow } returns MutableStateFlow(emptySet()) every { settingsRepository.includePrereleaseUpdates } returns MutableStateFlow(false) every { settingsRepository.sortMessagesBySentTime } returns flowOf(false) + // SOS settings flows + every { settingsRepository.sosEnabled } returns flowOf(false) + every { settingsRepository.sosMessageTemplate } returns flowOf("SOS! I need help. This is an emergency.") + every { settingsRepository.sosCountdownSeconds } returns flowOf(5) + every { settingsRepository.sosIncludeLocation } returns flowOf(true) + every { settingsRepository.sosSilentAutoAnswer } returns flowOf(false) + every { settingsRepository.sosShowFloatingButton } returns flowOf(false) + every { settingsRepository.sosDeactivationPin } returns flowOf(null) + every { settingsRepository.sosPeriodicUpdates } returns flowOf(false) + every { settingsRepository.sosUpdateIntervalSeconds } returns flowOf(120) + every { settingsRepository.sosTriggerModes } returns flowOf(emptySet()) + every { settingsRepository.sosShakeSensitivity } returns flowOf(2.5f) + every { settingsRepository.sosTapCount } returns flowOf(3) + every { settingsRepository.sosAudioEnabled } returns flowOf(false) + every { settingsRepository.sosAudioDurationSeconds } returns flowOf(30) + every { settingsRepository.sosFabOffsetX } returns flowOf(0f) + every { settingsRepository.sosFabOffsetY } returns flowOf(0f) + every { contactRepository.getSosContactsFlow() } returns flowOf(emptyList()) every { settingsRepository.tryPropagationOnFailFlow } returns MutableStateFlow(true) coEvery { settingsRepository.getLastUpdateCheckTime() } returns System.currentTimeMillis() diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt index d5bfb50c8..06eb18cba 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt @@ -177,6 +177,24 @@ class SettingsViewModelTest { every { settingsRepository.telemetryAllowedRequestersFlow } returns flowOf(emptySet()) every { settingsRepository.includePrereleaseUpdates } returns MutableStateFlow(false) every { settingsRepository.sortMessagesBySentTime } returns flowOf(false) + // SOS settings flows + every { settingsRepository.sosEnabled } returns flowOf(false) + every { settingsRepository.sosMessageTemplate } returns flowOf("SOS! I need help. This is an emergency.") + every { settingsRepository.sosCountdownSeconds } returns flowOf(5) + every { settingsRepository.sosIncludeLocation } returns flowOf(true) + every { settingsRepository.sosSilentAutoAnswer } returns flowOf(false) + every { settingsRepository.sosShowFloatingButton } returns flowOf(false) + every { settingsRepository.sosDeactivationPin } returns flowOf(null) + every { settingsRepository.sosPeriodicUpdates } returns flowOf(false) + every { settingsRepository.sosUpdateIntervalSeconds } returns flowOf(120) + every { settingsRepository.sosTriggerModes } returns flowOf(emptySet()) + every { settingsRepository.sosShakeSensitivity } returns flowOf(2.5f) + every { settingsRepository.sosTapCount } returns flowOf(3) + every { settingsRepository.sosAudioEnabled } returns flowOf(false) + every { settingsRepository.sosAudioDurationSeconds } returns flowOf(30) + every { settingsRepository.sosFabOffsetX } returns flowOf(0f) + every { settingsRepository.sosFabOffsetY } returns flowOf(0f) + every { contactRepository.getSosContactsFlow() } returns flowOf(emptyList()) coEvery { settingsRepository.getLastUpdateCheckTime() } returns System.currentTimeMillis() // Stub settings save methods diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/SosViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/SosViewModelTest.kt new file mode 100644 index 000000000..cf7d2c670 --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/SosViewModelTest.kt @@ -0,0 +1,82 @@ +package com.lxmf.messenger.viewmodel + +import com.lxmf.messenger.service.SosManager +import com.lxmf.messenger.service.SosState +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SosViewModelTest { + private lateinit var sosManager: SosManager + private lateinit var viewModel: SosViewModel + + @Before + fun setUp() { + sosManager = mockk() + every { sosManager.state } returns MutableStateFlow(SosState.Idle) + every { sosManager.trigger() } just Runs + every { sosManager.cancel() } just Runs + every { sosManager.deactivate(any()) } returns true + viewModel = SosViewModel(sosManager) + } + + @Test + fun `initial state is Idle`() { + assertEquals(SosState.Idle, viewModel.state.value) + } + + @Test + fun `state reflects manager state`() { + val stateFlow = MutableStateFlow(SosState.Idle) + every { sosManager.state } returns stateFlow + val vm = SosViewModel(sosManager) + + stateFlow.value = SosState.Active(2, 0) + assertEquals(SosState.Active(2, 0), vm.state.value) + } + + @Test + fun `trigger delegates to manager`() { + viewModel.trigger() + verify { sosManager.trigger() } + // State remains Idle since mock trigger doesn't change state + assertEquals(SosState.Idle, viewModel.state.value) + } + + @Test + fun `cancel delegates to manager`() { + viewModel.cancel() + verify { sosManager.cancel() } + // State remains Idle since mock cancel doesn't change state + assertEquals(SosState.Idle, viewModel.state.value) + } + + @Test + fun `deactivate without pin delegates to manager`() { + val result = viewModel.deactivate() + verify { sosManager.deactivate(null) } + assertTrue(result) + } + + @Test + fun `deactivate with pin delegates to manager`() { + val result = viewModel.deactivate("1234") + verify { sosManager.deactivate("1234") } + assertTrue(result) + } + + @Test + fun `deactivate returns false when manager returns false`() { + every { sosManager.deactivate(any()) } returns false + val result = viewModel.deactivate("wrong") + assertFalse(result) + } +} diff --git a/data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt b/data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt index 7e36e6978..ba92768f2 100644 --- a/data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt +++ b/data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt @@ -14,9 +14,9 @@ import java.io.File import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock import javax.inject.Inject import javax.inject.Singleton +import kotlin.concurrent.withLock /** * Provides decrypted identity key data at runtime. diff --git a/data/src/main/java/com/lxmf/messenger/data/db/dao/DraftDao.kt b/data/src/main/java/com/lxmf/messenger/data/db/dao/DraftDao.kt index 8b8ed0992..f4584f767 100644 --- a/data/src/main/java/com/lxmf/messenger/data/db/dao/DraftDao.kt +++ b/data/src/main/java/com/lxmf/messenger/data/db/dao/DraftDao.kt @@ -13,10 +13,16 @@ interface DraftDao { suspend fun insertOrReplaceDraft(draft: DraftEntity) @Query("SELECT * FROM drafts WHERE conversationHash = :peerHash AND identityHash = :identityHash LIMIT 1") - suspend fun getDraft(peerHash: String, identityHash: String): DraftEntity? + suspend fun getDraft( + peerHash: String, + identityHash: String, + ): DraftEntity? @Query("DELETE FROM drafts WHERE conversationHash = :peerHash AND identityHash = :identityHash") - suspend fun deleteDraft(peerHash: String, identityHash: String) + suspend fun deleteDraft( + peerHash: String, + identityHash: String, + ) @Query("SELECT * FROM drafts WHERE identityHash = :identityHash") fun observeDraftsForIdentity(identityHash: String): Flow> diff --git a/data/src/main/java/com/lxmf/messenger/data/db/dao/ReceivedLocationDao.kt b/data/src/main/java/com/lxmf/messenger/data/db/dao/ReceivedLocationDao.kt index b1ff62fb8..234995005 100644 --- a/data/src/main/java/com/lxmf/messenger/data/db/dao/ReceivedLocationDao.kt +++ b/data/src/main/java/com/lxmf/messenger/data/db/dao/ReceivedLocationDao.kt @@ -109,12 +109,44 @@ interface ReceivedLocationDao { @Query("DELETE FROM received_locations WHERE expiresAt IS NOT NULL AND expiresAt < :gracePeriodCutoff") suspend fun deleteExpiredLocations(gracePeriodCutoff: Long = System.currentTimeMillis() - 3600_000L) + /** + * Get SOS trail locations for a sender. + */ + @Query( + """ + SELECT * FROM received_locations + WHERE senderHash = :senderHash AND source = 'sos_trail' + ORDER BY timestamp ASC + LIMIT :limit + """, + ) + fun getSosTrailForSender( + senderHash: String, + limit: Int = 200, + ): Flow> + /** * Delete all locations for a sender (when contact is removed). */ @Query("DELETE FROM received_locations WHERE senderHash = :senderHash") suspend fun deleteLocationsForSender(senderHash: String) + /** + * Delete only SOS trail locations for a sender. + * Preserves location sharing positions. + */ + @Query("DELETE FROM received_locations WHERE senderHash = :senderHash AND source = 'sos_trail'") + suspend fun deleteSosTrailForSender(senderHash: String) + + /** + * Get distinct sender hashes that have recent SOS trail entries. + * Used to restore SosActiveTracker on startup. + */ + @Query( + "SELECT DISTINCT senderHash FROM received_locations WHERE source = 'sos_trail' AND timestamp > :sinceTimestamp", + ) + suspend fun getRecentSosTrailSenders(sinceTimestamp: Long): List + /** * Delete all locations (for data reset). */ diff --git a/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt b/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt index 808d24436..00058dac5 100644 --- a/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt +++ b/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt @@ -32,4 +32,10 @@ data class ReceivedLocationEntity( val receivedAt: Long, // When we received this update val approximateRadius: Int = 0, // Coarsening radius in meters (0 = precise) val appearanceJson: String? = null, // Icon appearance JSON: {"icon_name":"car","foreground_color":"RRGGBB","background_color":"RRGGBB"} -) + val source: String = SOURCE_LOCATION_SHARING, +) { + companion object { + const val SOURCE_LOCATION_SHARING = "location_sharing" + const val SOURCE_SOS_TRAIL = "sos_trail" + } +} diff --git a/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt b/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt index 4d46bca22..927594ca1 100644 --- a/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt +++ b/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt @@ -1689,9 +1689,17 @@ object DatabaseModule { } } + // Migration 43→44: Add source column + interface_first_seen table private val MIGRATION_43_44 = object : Migration(43, 44) { override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE received_locations ADD COLUMN source TEXT NOT NULL DEFAULT 'location_sharing'", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS idx_received_locations_source " + + "ON received_locations(source, senderHash, timestamp)", + ) database.execSQL( "CREATE TABLE IF NOT EXISTS interface_first_seen (" + "interfaceId TEXT NOT NULL PRIMARY KEY, " + diff --git a/data/src/main/java/com/lxmf/messenger/data/model/EnrichedContact.kt b/data/src/main/java/com/lxmf/messenger/data/model/EnrichedContact.kt index e74005b8e..a292028f4 100644 --- a/data/src/main/java/com/lxmf/messenger/data/model/EnrichedContact.kt +++ b/data/src/main/java/com/lxmf/messenger/data/model/EnrichedContact.kt @@ -65,6 +65,11 @@ data class EnrichedContact( } } + /** + * Whether this contact is tagged as an SOS emergency contact + */ + val isSosContact: Boolean get() = getTagsList().contains("sos") + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/data/src/main/java/com/lxmf/messenger/data/repository/ContactRepository.kt b/data/src/main/java/com/lxmf/messenger/data/repository/ContactRepository.kt index 6ccfd5872..5eda8e418 100644 --- a/data/src/main/java/com/lxmf/messenger/data/repository/ContactRepository.kt +++ b/data/src/main/java/com/lxmf/messenger/data/repository/ContactRepository.kt @@ -1,5 +1,6 @@ package com.lxmf.messenger.data.repository +import android.util.Log import com.lxmf.messenger.data.db.dao.AnnounceDao import com.lxmf.messenger.data.db.dao.ContactDao import com.lxmf.messenger.data.db.dao.LocalIdentityDao @@ -8,8 +9,10 @@ import com.lxmf.messenger.data.db.entity.ContactStatus import com.lxmf.messenger.data.model.EnrichedContact import com.lxmf.messenger.data.util.HashUtils.computeIdentityHash import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -104,6 +107,14 @@ class ContactRepository } } + /** + * Check if a contact is tagged as SOS as Flow for the active identity. + */ + fun isSosContactFlow(destinationHash: String): Flow = + getContactFlow(destinationHash).map { contact -> + contact?.tags?.contains("sos") == true + } + /** * Add a contact from an announce (when user stars an announce). * Display name will automatically use the announce's peerName via database COALESCE. @@ -657,4 +668,61 @@ class ContactRepository * Used during initialization before active identity is available. */ suspend fun getAnyRelay(): ContactEntity? = contactDao.getAnyMyRelay() + + // ========== SOS EMERGENCY CONTACTS ========== + + /** + * Get all contacts tagged as SOS emergency contacts for the active identity. + */ + suspend fun getSosContacts(): List { + return getEnrichedContacts().first().filter { it.isSosContact } + } + + /** + * Get SOS contacts as a Flow for observing changes. + */ + fun getSosContactsFlow(): Flow> = + getEnrichedContacts().flatMapLatest { contacts -> + flowOf(contacts.filter { it.isSosContact }) + } + + /** + * Toggle the "sos" tag on a contact. + */ + suspend fun toggleSosTag(destinationHash: String) { + val activeIdentity = localIdentityDao.getActiveIdentitySync() ?: return + val contact = contactDao.getContact(destinationHash, activeIdentity.identityHash) ?: return + val currentTags = contact.tags + val tagsList = + if (currentTags.isNullOrBlank()) { + mutableListOf() + } else { + try { + currentTags.trim() + .removePrefix("[") + .removeSuffix("]") + .split(",") + .map { it.trim().removeSurrounding("\"") } + .filter { it.isNotEmpty() } + .toMutableList() + } catch (e: Exception) { + Log.w("ContactRepository", "Failed to parse tags: $currentTags", e) + mutableListOf() + } + } + + if (tagsList.contains("sos")) { + tagsList.remove("sos") + } else { + tagsList.add("sos") + } + + val newTags = + if (tagsList.isEmpty()) { + null + } else { + "[${tagsList.joinToString(",") { "\"$it\"" }}]" + } + contactDao.updateTags(destinationHash, activeIdentity.identityHash, newTags) + } } diff --git a/data/src/test/java/com/lxmf/messenger/data/repository/ContactRepositorySosTest.kt b/data/src/test/java/com/lxmf/messenger/data/repository/ContactRepositorySosTest.kt new file mode 100644 index 000000000..ea192820b --- /dev/null +++ b/data/src/test/java/com/lxmf/messenger/data/repository/ContactRepositorySosTest.kt @@ -0,0 +1,323 @@ +// NoVerifyOnlyTests: Repository is a thin delegation layer; verifying correct DAO calls IS the behavior +// NoRelaxedMocks: DAO interfaces have many methods; tests explicitly stub what they need +@file:Suppress("NoVerifyOnlyTests", "NoRelaxedMocks") + +package com.lxmf.messenger.data.repository + +import com.lxmf.messenger.data.db.dao.AnnounceDao +import com.lxmf.messenger.data.db.dao.ContactDao +import com.lxmf.messenger.data.db.dao.LocalIdentityDao +import com.lxmf.messenger.data.db.entity.ContactEntity +import com.lxmf.messenger.data.db.entity.ContactStatus +import com.lxmf.messenger.data.db.entity.LocalIdentityEntity +import com.lxmf.messenger.data.model.EnrichedContact +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for ContactRepository SOS emergency contact methods and EnrichedContact.isSosContact. + * + * Tests cover: + * - getSosContacts: filtering enriched contacts by SOS tag + * - getSosContactsFlow: reactive stream of SOS contacts + * - toggleSosTag: adding/removing the "sos" tag via DAO interactions + * - isSosContact: pure data class tag parsing + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ContactRepositorySosTest { + private lateinit var contactDao: ContactDao + private lateinit var localIdentityDao: LocalIdentityDao + private lateinit var announceDao: AnnounceDao + private val testDispatcher = StandardTestDispatcher() + + private val testIdentityHash = "identity123" + private val testDestHash = "abc123" + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + contactDao = mockk(relaxed = true) + localIdentityDao = mockk(relaxed = true) + announceDao = mockk(relaxed = true) + + // Default: active identity exists + val identity = createTestIdentity() + every { localIdentityDao.getActiveIdentity() } returns flowOf(identity) + coEvery { localIdentityDao.getActiveIdentitySync() } returns identity + } + + @After + fun teardown() { + Dispatchers.resetMain() + clearAllMocks() + } + + // ========== Helper Functions ========== + + private fun createTestIdentity(hash: String = testIdentityHash) = + LocalIdentityEntity( + identityHash = hash, + displayName = "Test Identity", + destinationHash = "dest_$hash", + filePath = "/data/identity_$hash", + createdTimestamp = System.currentTimeMillis(), + lastUsedTimestamp = System.currentTimeMillis(), + isActive = true, + ) + + private fun createContact( + destinationHash: String = testDestHash, + tags: String? = null, + displayName: String = "Test", + ): EnrichedContact = + EnrichedContact( + destinationHash = destinationHash, + publicKey = ByteArray(64) { it.toByte() }, + displayName = displayName, + customNickname = null, + announceName = null, + lastSeenTimestamp = null, + hops = null, + isOnline = false, + hasConversation = false, + unreadCount = 0, + lastMessageTimestamp = null, + notes = null, + tags = tags, + addedTimestamp = System.currentTimeMillis(), + addedVia = "MANUAL", + isPinned = false, + ) + + private fun createContactEntity( + destinationHash: String = testDestHash, + tags: String? = null, + ) = ContactEntity( + destinationHash = destinationHash, + identityHash = testIdentityHash, + publicKey = ByteArray(64) { it.toByte() }, + customNickname = null, + notes = null, + tags = tags, + addedTimestamp = System.currentTimeMillis(), + addedVia = "MANUAL", + lastInteractionTimestamp = 0, + isPinned = false, + status = ContactStatus.ACTIVE, + ) + + // ========== isSosContact Tests (pure data class, no mocking) ========== + + @Test + fun `isSosContact returns true when tags contain sos`() { + val contact = createContact(tags = """["sos"]""") + assertTrue(contact.isSosContact) + } + + @Test + fun `isSosContact returns false when tags empty`() { + val contact = createContact(tags = "") + assertFalse(contact.isSosContact) + } + + @Test + fun `isSosContact returns false when tags null`() { + val contact = createContact(tags = null) + assertFalse(contact.isSosContact) + } + + @Test + fun `isSosContact returns false when tags contain other tags but not sos`() { + val contact = createContact(tags = """["friend","family"]""") + assertFalse(contact.isSosContact) + } + + // ========== getSosContacts Tests ========== + + @Test + fun `getSosContacts returns only contacts with sos tag`() = + runTest { + val repo = spyk(ContactRepository(contactDao, localIdentityDao, announceDao)) + every { repo.getEnrichedContacts() } returns + flowOf( + listOf( + createContact("hash1", tags = """["sos"]""", displayName = "Alice"), + createContact("hash2", tags = null, displayName = "Bob"), + createContact("hash3", tags = """["sos","friend"]""", displayName = "Carol"), + ), + ) + + val result = repo.getSosContacts() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, result.size) + assertEquals("hash1", result[0].destinationHash) + assertEquals("hash3", result[1].destinationHash) + } + + @Test + fun `getSosContacts returns empty when no sos contacts`() = + runTest { + val repo = spyk(ContactRepository(contactDao, localIdentityDao, announceDao)) + every { repo.getEnrichedContacts() } returns + flowOf( + listOf( + createContact("hash1", tags = null, displayName = "Alice"), + createContact("hash2", tags = """["friend"]""", displayName = "Bob"), + ), + ) + + val result = repo.getSosContacts() + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(result.isEmpty()) + } + + // ========== getSosContactsFlow Tests ========== + + @Test + fun `getSosContactsFlow emits filtered contacts`() = + runTest { + val repo = spyk(ContactRepository(contactDao, localIdentityDao, announceDao)) + every { repo.getEnrichedContacts() } returns + flowOf( + listOf( + createContact("hash1", tags = """["sos"]""", displayName = "Alice"), + createContact("hash2", tags = null, displayName = "Bob"), + createContact("hash3", tags = """["sos","family"]""", displayName = "Carol"), + ), + ) + + val result = repo.getSosContactsFlow().first() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, result.size) + assertEquals("hash1", result[0].destinationHash) + assertEquals("hash3", result[1].destinationHash) + } + + // ========== toggleSosTag Tests ========== + + @Test + fun `toggleSosTag adds sos tag to contact with no tags`() = + runTest { + val contactEntity = createContactEntity(tags = null) + coEvery { contactDao.getContact(testDestHash, testIdentityHash) } returns contactEntity + coEvery { contactDao.updateTags(any(), any(), any()) } just Runs + + val repo = ContactRepository(contactDao, localIdentityDao, announceDao) + repo.toggleSosTag(testDestHash) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + contactDao.updateTags( + testDestHash, + testIdentityHash, + match { it == """["sos"]""" }, + ) + } + } + + @Test + fun `toggleSosTag adds sos tag alongside existing tags`() = + runTest { + val contactEntity = createContactEntity(tags = """["friend"]""") + coEvery { contactDao.getContact(testDestHash, testIdentityHash) } returns contactEntity + coEvery { contactDao.updateTags(any(), any(), any()) } just Runs + + val repo = ContactRepository(contactDao, localIdentityDao, announceDao) + repo.toggleSosTag(testDestHash) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + contactDao.updateTags( + testDestHash, + testIdentityHash, + match { it != null && it.contains("\"friend\"") && it.contains("\"sos\"") }, + ) + } + } + + @Test + fun `toggleSosTag removes sos tag when already present`() = + runTest { + val contactEntity = createContactEntity(tags = """["sos","friend"]""") + coEvery { contactDao.getContact(testDestHash, testIdentityHash) } returns contactEntity + coEvery { contactDao.updateTags(any(), any(), any()) } just Runs + + val repo = ContactRepository(contactDao, localIdentityDao, announceDao) + repo.toggleSosTag(testDestHash) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + contactDao.updateTags( + testDestHash, + testIdentityHash, + match { it != null && it.contains("\"friend\"") && !it.contains("\"sos\"") }, + ) + } + } + + @Test + fun `toggleSosTag clears tags to null when sos was only tag`() = + runTest { + val contactEntity = createContactEntity(tags = """["sos"]""") + coEvery { contactDao.getContact(testDestHash, testIdentityHash) } returns contactEntity + coEvery { contactDao.updateTags(any(), any(), any()) } just Runs + coEvery { contactDao.updateTags(any(), any(), isNull()) } just Runs + + val repo = ContactRepository(contactDao, localIdentityDao, announceDao) + repo.toggleSosTag(testDestHash) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + contactDao.updateTags(testDestHash, testIdentityHash, isNull()) + } + } + + @Test + fun `toggleSosTag does nothing when no active identity`() = + runTest { + coEvery { localIdentityDao.getActiveIdentitySync() } returns null + + val repo = ContactRepository(contactDao, localIdentityDao, announceDao) + repo.toggleSosTag(testDestHash) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { contactDao.getContact(any(), any()) } + coVerify(exactly = 0) { contactDao.updateTags(any(), any(), any()) } + } + + @Test + fun `toggleSosTag does nothing when contact not found`() = + runTest { + coEvery { contactDao.getContact(testDestHash, testIdentityHash) } returns null + + val repo = ContactRepository(contactDao, localIdentityDao, announceDao) + repo.toggleSosTag(testDestHash) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { contactDao.updateTags(any(), any(), any()) } + } +} diff --git a/python/reticulum_wrapper.py b/python/reticulum_wrapper.py index 3faadf10c..ada973f32 100644 --- a/python/reticulum_wrapper.py +++ b/python/reticulum_wrapper.py @@ -82,10 +82,12 @@ def _global_exception_handler(exc_type, exc_value, exc_traceback): # Command IDs for FIELD_COMMANDS (Sideband telemetry collector protocol) COMMAND_TELEMETRY_REQUEST = 0x01 # Request telemetry from collector +COMMAND_SOS_STATE = 0x02 # SOS emergency state: ["active"], ["cancelled"], ["update"] # Sensor IDs (from Sideband sense.py) SID_TIME = 0x01 SID_LOCATION = 0x02 +SID_BATTERY = 0x04 # ============================================================================ @@ -194,7 +196,9 @@ def appearance_from_marker_symbol(symbol_key: str) -> Optional[list]: # ============================================================================ def pack_location_telemetry(lat: float, lon: float, accuracy: float, timestamp_ms: int, - altitude: float = 0.0, speed: float = 0.0, bearing: float = 0.0) -> bytes: + altitude: float = 0.0, speed: float = 0.0, bearing: float = 0.0, + battery_percent: int = None, battery_charging: bool = False, + battery_temperature: float = 0.0) -> bytes: """ Pack location data in Sideband Telemeter format for FIELD_TELEMETRY. @@ -208,6 +212,9 @@ def pack_location_telemetry(lat: float, lon: float, accuracy: float, timestamp_m altitude: Altitude in meters (default 0.0) speed: Speed in km/h (default 0.0) bearing: Bearing/heading in degrees (default 0.0) + battery_percent: Optional battery charge level (0-100) + battery_charging: Whether the device is charging (default False) + battery_temperature: Battery temperature in Celsius (default 0.0) Returns: msgpack-packed bytes for FIELD_TELEMETRY @@ -236,6 +243,12 @@ def pack_location_telemetry(lat: float, lon: float, accuracy: float, timestamp_m SID_LOCATION: location_packed, } + if battery_percent is not None: + # Sideband Battery format: [charge%, charging (bool), temperature_celsius] + bp = max(0, min(100, int(battery_percent))) + bt = float(battery_temperature) if battery_temperature is not None else 0.0 + telemetry[SID_BATTERY] = [bp, bool(battery_charging), bt] + return umsgpack.packb(telemetry) @@ -281,7 +294,7 @@ def unpack_location_telemetry(packed_data: bytes) -> Optional[Dict]: f"bear={bearing:.1f}° acc={accuracy:.1f}m " f"t={last_update}") - return { + result = { "type": "location_share", "lat": lat, "lng": lon, @@ -291,6 +304,26 @@ def unpack_location_telemetry(packed_data: bytes) -> Optional[Dict]: "speed": speed, "bearing": bearing, } + + # Extract battery data if present (Sideband format: [charge%, charging, temp]) + if SID_BATTERY in telemetry: + try: + import math + bat = telemetry[SID_BATTERY] + if isinstance(bat, (list, tuple)) and len(bat) >= 1: + bp = bat[0] + if isinstance(bp, (int, float)) and not (isinstance(bp, float) and (math.isnan(bp) or math.isinf(bp))): + result["battery_percent"] = int(bp) + if len(bat) >= 2: + result["battery_charging"] = bool(bat[1]) + if len(bat) >= 3: + bt = bat[2] + if isinstance(bt, (int, float)) and not (isinstance(bt, float) and (math.isnan(bt) or math.isinf(bt))): + result["battery_temperature"] = float(bt) + except Exception: + pass + + return result except Exception as e: log_warning("TelemetryHelper", "unpack_location_telemetry", f"Failed to unpack telemetry: {e}") @@ -3313,13 +3346,35 @@ def _on_lxmf_delivery(self, lxmf_message): # Field 4: icon appearance (already parsed above) pass elif key in (6, 7) and isinstance(value, (list, tuple)) and len(value) >= 2: - # Field 6/7: image/audio + # Field 6/7: image/audio as [format, bytes] if isinstance(value[1], bytes): if len(value[1]) > _MAX_INLINE_ATTACHMENT_BYTES: temp_path = self._write_attachment_staging(msg_hash, f"f{key}", value[1]) fields_serialized[str(key)] = [value[0], None, temp_path] else: fields_serialized[str(key)] = [value[0], value[1].hex()] + elif key == 7 and isinstance(value, bytes): + # Field 7: raw audio bytes (no format wrapper) + if len(value) > _MAX_INLINE_ATTACHMENT_BYTES: + temp_path = self._write_attachment_staging(msg_hash, "f7", value) + fields_serialized["7"] = ["m4a", None, temp_path] + else: + fields_serialized["7"] = ["m4a", value.hex()] + elif key == FIELD_TELEMETRY and isinstance(value, bytes): + # Field 2: unpack telemetry so Kotlin gets a JSON object with lat/lng + unpacked = unpack_location_telemetry(value) + if unpacked: + fields_serialized["2"] = unpacked + else: + fields_serialized["2"] = value.hex() + elif key == FIELD_COMMANDS and isinstance(value, list): + # Field 9: commands — extract SOS state for Kotlin + # Do NOT store under key "9" — it collides with replyToMessageId on Kotlin side + for cmd in value: + if isinstance(cmd, dict) and COMMAND_SOS_STATE in cmd: + args = cmd[COMMAND_SOS_STATE] + if isinstance(args, list) and len(args) > 0: + fields_serialized["sos_state"] = str(args[0]) else: fields_serialized[str(key)] = str(value) if fields_serialized: @@ -4333,7 +4388,10 @@ def send_lxmf_message_with_method(self, dest_hash: bytes, content: str, source_i image_data_path: str = None, file_attachments: list = None, file_attachment_paths: list = None, reply_to_message_id: str = None, - icon_name: str = None, icon_fg_color: str = None, icon_bg_color: str = None) -> Dict: + icon_name: str = None, icon_fg_color: str = None, icon_bg_color: str = None, + telemetry_json: str = None, + audio_data: bytes = None, audio_data_path: str = None, + sos_state: str = None) -> Dict: """ Send an LXMF message with explicit delivery method. @@ -4560,6 +4618,79 @@ def send_lxmf_message_with_method(self, dest_hash: bytes, content: str, source_i log_info("ReticulumWrapper", "send_lxmf_message_with_method", f"📎 Adding icon appearance: {icon_name}, fg={icon_fg_color} ({fg_bytes.hex()}), bg={icon_bg_color} ({bg_bytes.hex()})") + # Add FIELD_TELEMETRY from JSON if provided (SOS messages with location + battery) + if telemetry_json: + try: + import json + tel = json.loads(str(telemetry_json)) + packed = pack_location_telemetry( + lat=float(tel.get("lat", 0.0)), + lon=float(tel.get("lng", 0.0)), + accuracy=float(tel.get("acc", 0.0)), + timestamp_ms=int(tel.get("ts", 0)), + altitude=float(tel.get("altitude", 0.0)), + speed=float(tel.get("speed", 0.0)), + bearing=float(tel.get("bearing", 0.0)), + battery_percent=tel.get("battery_percent"), + battery_charging=tel.get("battery_charging", False), + battery_temperature=tel.get("battery_temperature", 0.0), + ) + if fields is None: + fields = {} + # Store raw msgpack bytes — receiver calls unpackb() on this + fields[FIELD_TELEMETRY] = packed + log_info("ReticulumWrapper", "send_lxmf_message_with_method", + f"📡 Adding FIELD_TELEMETRY: lat={tel.get('lat')}, lng={tel.get('lng')}, " + f"battery={tel.get('battery_percent')}%") + except Exception as e: + log_warning("ReticulumWrapper", "send_lxmf_message_with_method", + f"Failed to pack telemetry from JSON: {e}") + + # Add FIELD_AUDIO if audio data provided (SOS audio recording) + if audio_data_path: + try: + import os + _MAX_AUDIO_BYTES = 5 * 1024 * 1024 # 5 MB cap for SOS audio + file_size = os.path.getsize(str(audio_data_path)) + if file_size > _MAX_AUDIO_BYTES: + log_warning("ReticulumWrapper", "send_lxmf_message_with_method", + f"Audio file too large ({file_size} bytes, max {_MAX_AUDIO_BYTES}), skipping") + audio_data = None + else: + with open(str(audio_data_path), 'rb') as f: + audio_data = f.read() + log_info("ReticulumWrapper", "send_lxmf_message_with_method", + f"🎙️ Read audio from disk: {len(audio_data)} bytes") + try: + os.remove(str(audio_data_path)) + except Exception: + pass + except Exception as e: + log_warning("ReticulumWrapper", "send_lxmf_message_with_method", + f"Failed to read audio from {audio_data_path}: {e}") + audio_data = None + + if audio_data: + if hasattr(audio_data, '__iter__') and not isinstance(audio_data, (bytes, bytearray)): + audio_data = bytes(audio_data) + if fields is None: + fields = {} + fields[FIELD_AUDIO] = ["m4a", audio_data] + log_info("ReticulumWrapper", "send_lxmf_message_with_method", + f"🎙️ Attaching audio: {len(audio_data)} bytes, format=m4a, field_key={FIELD_AUDIO}") + + # Add FIELD_COMMANDS for SOS state if provided + if sos_state: + if fields is None: + fields = {} + sos_command = [{COMMAND_SOS_STATE: [str(sos_state)]}] + if FIELD_COMMANDS in fields: + fields[FIELD_COMMANDS].extend(sos_command) + else: + fields[FIELD_COMMANDS] = sos_command + log_info("ReticulumWrapper", "send_lxmf_message_with_method", + f"🆘 Adding FIELD_COMMANDS SOS state: {sos_state}") + # Create LXMF message with specified delivery method lxmf_message = LXMF.LXMessage( destination=recipient_lxmf_destination, @@ -6159,6 +6290,20 @@ def poll_received_messages(self) -> List[Dict]: f"Failed to parse icon appearance: {e}") fields_serialized[str(key)] = str(value) + elif key == FIELD_TELEMETRY and isinstance(value, bytes): + # Field 2: unpack telemetry so Kotlin gets a JSON object with lat/lng + unpacked = unpack_location_telemetry(value) + if unpacked: + fields_serialized["2"] = unpacked + else: + fields_serialized["2"] = value.hex() + elif key == FIELD_COMMANDS and isinstance(value, list): + # Field 9: extract SOS state for Kotlin (same as callback path) + for cmd in value: + if isinstance(cmd, dict) and COMMAND_SOS_STATE in cmd: + args = cmd[COMMAND_SOS_STATE] + if isinstance(args, list) and len(args) > 0: + fields_serialized["sos_state"] = str(args[0]) elif isinstance(value, (list, tuple)) and len(value) >= 2: # Image/audio format: [format_string, bytes_data] if isinstance(value[1], bytes): @@ -6171,7 +6316,7 @@ def poll_received_messages(self) -> List[Dict]: fields_serialized[str(key)] = value.hex() else: fields_serialized[str(key)] = str(value) - message_event['fields'] = fields_serialized + message_event['fields'] = json.dumps(fields_serialized) log_info("ReticulumWrapper", "poll_received_messages", f"📎 Message has {len(fields_serialized)} field(s): {list(fields_serialized.keys())}") new_messages.append(message_event) diff --git a/python/test_pr422_icon_appearance.py b/python/test_pr422_icon_appearance.py index 88f83d239..fdc69c773 100644 --- a/python/test_pr422_icon_appearance.py +++ b/python/test_pr422_icon_appearance.py @@ -844,10 +844,13 @@ def test_poll_icon_appearance_also_in_fields_dict(self): self.assertEqual(len(results), 1) result = results[0] self.assertIn('fields', result) - self.assertIn('4', result['fields']) - self.assertEqual(result['fields']['4']['icon_name'], "test-icon") - self.assertEqual(result['fields']['4']['foreground_color'], "112233") - self.assertEqual(result['fields']['4']['background_color'], "aabbcc") + # fields is now a JSON string (consistent with callback path) + import json + fields = json.loads(result['fields']) if isinstance(result['fields'], str) else result['fields'] + self.assertIn('4', fields) + self.assertEqual(fields['4']['icon_name'], "test-icon") + self.assertEqual(fields['4']['foreground_color'], "112233") + self.assertEqual(fields['4']['background_color'], "aabbcc") def test_poll_without_icon_appearance(self): """poll_received_messages without field 4 should not have icon_appearance.""" diff --git a/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/MockReticulumProtocol.kt b/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/MockReticulumProtocol.kt index 5df15e071..55b100f82 100644 --- a/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/MockReticulumProtocol.kt +++ b/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/MockReticulumProtocol.kt @@ -363,6 +363,9 @@ class MockReticulumProtocol : ReticulumProtocol { fileAttachments: List>?, replyToMessageId: String?, iconAppearance: IconAppearance?, + telemetryJson: String?, + audioData: ByteArray?, + sosState: String?, ): Result { // Mock implementation - same as sendLxmfMessage return Result.success( diff --git a/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/ReticulumProtocol.kt b/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/ReticulumProtocol.kt index 246f2b302..95de83747 100644 --- a/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/ReticulumProtocol.kt +++ b/reticulum/src/main/java/com/lxmf/messenger/reticulum/protocol/ReticulumProtocol.kt @@ -314,6 +314,9 @@ interface ReticulumProtocol { fileAttachments: List>? = null, replyToMessageId: String? = null, iconAppearance: IconAppearance? = null, + telemetryJson: String? = null, + audioData: ByteArray? = null, + sosState: String? = null, ): Result /**