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