wear: fix private message navigation

This commit is contained in:
callebtc 2026-07-29 18:10:25 +02:00
parent 5b79da2db6
commit d465f427a4
2 changed files with 155 additions and 29 deletions

View File

@ -66,13 +66,17 @@ class MainActivity : ComponentActivity() {
private var nicknameChosen by mutableStateOf(false) private var nicknameChosen by mutableStateOf(false)
private var notificationsGranted by mutableStateOf(false) private var notificationsGranted by mutableStateOf(false)
private var notificationPromptDismissed by mutableStateOf(false) private var notificationPromptDismissed by mutableStateOf(false)
private var pendingDmPeer by mutableStateOf<String?>(null) private var pendingLaunchRequest by mutableStateOf<WearLaunchRequest?>(null)
private var nextLaunchRequestID = 0L
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
.getBoolean("nickname_chosen", false) .getBoolean("nickname_chosen", false)
pendingDmPeer = privateMessagePeerFromIntent(intent) val notificationPeer = consumePrivateMessagePeer(intent)
if (savedInstanceState == null && notificationPeer != null) {
requestLaunch(WearLaunchTarget.Dm(notificationPeer))
}
refreshState() refreshState()
setContent { setContent {
BitchatWearTheme { BitchatWearTheme {
@ -96,8 +100,12 @@ class MainActivity : ComponentActivity() {
onSkip = { notificationPromptDismissed = true } onSkip = { notificationPromptDismissed = true }
) )
else -> WearNavHost( else -> WearNavHost(
openDmPeer = pendingDmPeer, launchRequest = pendingLaunchRequest,
onOpenDmHandled = { pendingDmPeer = null } onLaunchRequestHandled = { requestID ->
if (pendingLaunchRequest?.id == requestID) {
pendingLaunchRequest = null
}
}
) )
} }
} }
@ -122,7 +130,10 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
privateMessagePeerFromIntent(intent)?.let { pendingDmPeer = it } val notificationPeer = consumePrivateMessagePeer(intent)
requestLaunch(
notificationPeer?.let(WearLaunchTarget::Dm) ?: WearLaunchTarget.Chat
)
} }
private fun refreshState() { private fun refreshState() {
@ -142,6 +153,13 @@ class MainActivity : ComponentActivity() {
startForegroundService(Intent(this, WearMeshForegroundService::class.java)) startForegroundService(Intent(this, WearMeshForegroundService::class.java))
} }
private fun consumePrivateMessagePeer(intent: Intent?): String? {
val peerID = privateMessagePeerFromIntent(intent)
intent?.removeExtra(WearNotificationCoordinator.EXTRA_OPEN_DM)
intent?.removeExtra(WearNotificationCoordinator.EXTRA_PEER_ID)
return peerID
}
private fun privateMessagePeerFromIntent(intent: Intent?): String? { private fun privateMessagePeerFromIntent(intent: Intent?): String? {
if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) { if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) {
return null return null
@ -150,6 +168,13 @@ class MainActivity : ComponentActivity() {
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
} }
private fun requestLaunch(target: WearLaunchTarget) {
pendingLaunchRequest = WearLaunchRequest(
id = ++nextLaunchRequestID,
target = target
)
}
private fun notificationPermissionGranted(): Boolean { private fun notificationPermissionGranted(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@ -169,36 +194,74 @@ class MainActivity : ComponentActivity() {
} }
} }
@Composable internal sealed interface WearLaunchTarget {
fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { data object Chat : WearLaunchTarget
var screen by remember { mutableStateOf<WearScreen>(WearScreen.Chat) } data class Dm(val peerID: String) : WearLaunchTarget
val backStack = remember { mutableStateListOf<WearScreen>() } }
internal data class WearLaunchRequest(
val id: Long,
val target: WearLaunchTarget
)
internal class WearNavigationState {
private val backStack = mutableStateListOf<WearScreen>()
var screen by mutableStateOf<WearScreen>(WearScreen.Chat)
private set
val canGoBack: Boolean
get() = screen != WearScreen.Chat || backStack.isNotEmpty()
fun navigate(to: WearScreen) { fun navigate(to: WearScreen) {
backStack.add(screen) backStack.add(screen)
screen = to screen = to
} }
fun goBack(): Boolean { fun openChat() {
val previous = backStack.removeLastOrNull() backStack.clear()
return if (previous != null) { screen = WearScreen.Chat
screen = previous
true
} else false
} }
BackHandler(enabled = backStack.isNotEmpty()) { goBack() } fun openDmFromNotification(peerID: String) {
backStack.clear()
backStack.add(WearScreen.Chat)
screen = WearScreen.Dm(peerID)
}
LaunchedEffect(openDmPeer) { fun goBack(): Boolean {
openDmPeer?.let { peerID -> if (screen is WearScreen.Dm) {
backStack.clear() openChat()
screen = WearScreen.Dm(peerID) return true
onOpenDmHandled() }
val previous = backStack.removeLastOrNull() ?: return false
screen = previous
return true
}
}
@Composable
internal fun WearNavHost(
launchRequest: WearLaunchRequest?,
onLaunchRequestHandled: (Long) -> Unit
) {
val navigation = remember { WearNavigationState() }
BackHandler(enabled = navigation.canGoBack) { navigation.goBack() }
LaunchedEffect(launchRequest) {
launchRequest?.let { request ->
when (val target = request.target) {
WearLaunchTarget.Chat -> navigation.openChat()
is WearLaunchTarget.Dm -> navigation.openDmFromNotification(target.peerID)
}
onLaunchRequestHandled(request.id)
} }
} }
AnimatedContent( AnimatedContent(
targetState = screen, targetState = navigation.screen,
transitionSpec = { transitionSpec = {
fadeIn(tween(com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS)) togetherWith fadeIn(tween(com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS)) togetherWith
fadeOut(tween(com.bitchat.watch.ui.theme.BitchatMotion.QUICK_MS)) fadeOut(tween(com.bitchat.watch.ui.theme.BitchatMotion.QUICK_MS))
@ -207,12 +270,12 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) {
) { current -> ) { current ->
when (current) { when (current) {
is WearScreen.Chat -> ChatScreen( is WearScreen.Chat -> ChatScreen(
onOpenPeople = { navigate(WearScreen.People) }, onOpenPeople = { navigation.navigate(WearScreen.People) },
onOpenTextInput = { navigate(WearScreen.TextInput(null)) } onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) }
) )
is WearScreen.People -> PeopleScreen( is WearScreen.People -> PeopleScreen(
onOpenDm = { navigate(WearScreen.Dm(it)) }, onOpenDm = { navigation.navigate(WearScreen.Dm(it)) },
onEditNickname = { navigate(WearScreen.Nickname) } onEditNickname = { navigation.navigate(WearScreen.Nickname) }
) )
is WearScreen.Nickname -> { is WearScreen.Nickname -> {
val mesh = WearMeshService.peek() val mesh = WearMeshService.peek()
@ -223,13 +286,15 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) {
confirmLabel = "Save", confirmLabel = "Save",
onConfirm = { name -> onConfirm = { name ->
mesh?.setNickname(name) mesh?.setNickname(name)
goBack() navigation.goBack()
} }
) )
} }
is WearScreen.Dm -> DmScreen( is WearScreen.Dm -> DmScreen(
peerID = current.peerID, peerID = current.peerID,
onOpenTextInput = { navigate(WearScreen.TextInput(current.peerID)) } onOpenTextInput = {
navigation.navigate(WearScreen.TextInput(current.peerID))
}
) )
is WearScreen.TextInput -> { is WearScreen.TextInput -> {
val mesh = WearMeshService.peek() val mesh = WearMeshService.peek()
@ -244,7 +309,7 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) {
sendPrivateMessage(m, current.peerID, nick, text, sendScope) sendPrivateMessage(m, current.peerID, nick, text, sendScope)
} }
} }
goBack() navigation.goBack()
} }
) )
} }

View File

@ -0,0 +1,61 @@
package com.bitchat.watch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WearNavigationStateTest {
@Test
fun `notification dm goes back to main chat`() {
val navigation = WearNavigationState()
navigation.navigate(WearScreen.People)
navigation.navigate(WearScreen.Nickname)
navigation.openDmFromNotification("peer-a")
assertEquals(WearScreen.Dm("peer-a"), navigation.screen)
assertTrue(navigation.goBack())
assertEquals(WearScreen.Chat, navigation.screen)
assertFalse(navigation.canGoBack)
}
@Test
fun `dm opened from people also goes back to main chat`() {
val navigation = WearNavigationState()
navigation.navigate(WearScreen.People)
navigation.navigate(WearScreen.Dm("peer-a"))
assertTrue(navigation.goBack())
assertEquals(WearScreen.Chat, navigation.screen)
assertFalse(navigation.canGoBack)
}
@Test
fun `dm text input returns to dm before main chat`() {
val navigation = WearNavigationState()
navigation.navigate(WearScreen.People)
navigation.navigate(WearScreen.Dm("peer-a"))
navigation.navigate(WearScreen.TextInput("peer-a"))
assertTrue(navigation.goBack())
assertEquals(WearScreen.Dm("peer-a"), navigation.screen)
assertTrue(navigation.goBack())
assertEquals(WearScreen.Chat, navigation.screen)
assertFalse(navigation.canGoBack)
}
@Test
fun `normal app launch resets an open dm to main chat`() {
val navigation = WearNavigationState()
navigation.openDmFromNotification("peer-a")
navigation.openChat()
assertEquals(WearScreen.Chat, navigation.screen)
assertFalse(navigation.canGoBack)
}
}