diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt index a5146c34..8aa8a303 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -3,7 +3,9 @@ package com.bitchat.android.testhook import android.content.Context import android.content.Intent import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.features.file.FileUtils +import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.TransferProgressManager @@ -63,6 +65,18 @@ object TestHookDriver { "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) "dm_recv" -> dmRecv(context, intent) "msg_recv" -> msgRecv(context, intent) + "favorite_set" -> favoriteSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "favorite_status" -> favoriteStatus(context, intent.requiredString("peer")) + "verification_set" -> verificationSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "verification_status" -> verificationStatus(context, intent.requiredString("peer")) "file_send" -> fileSend(context, intent) "file_recv" -> fileRecv(context, intent) "file_cancel" -> fileCancel(context, intent.requiredString("transfer_id")) @@ -278,6 +292,68 @@ object TestHookDriver { .put("msg_id", found.id) } + // MARK: - Favorite and verification state + + private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + val peerInfo = mesh.getPeerInfo(peerID) + ?: return err("favorite_set", "peer is not known") + val noisePublicKey = peerInfo.noisePublicKey + ?: return err("favorite_set", "peer Noise key is unavailable") + FavoritesPersistenceService.initialize(context) + FavoritesPersistenceService.shared.updateFavoriteStatus( + noisePublicKey = noisePublicKey, + nickname = peerInfo.nickname, + isFavorite = enabled + ) + mesh.sendFavoriteNotification(peerID, enabled) + return favoriteStatus(context, peerID) + } + + private fun favoriteStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + FavoritesPersistenceService.initialize(context) + val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + ?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + val isFavorite = relationship?.isFavorite == true + val theyFavoritedUs = relationship?.theyFavoritedUs == true + return ok("favorite_status") + .put("peer", peerID) + .put("is_favorite", isFavorite) + .put("they_favorited_us", theyFavoritedUs) + .put("is_mutual", isFavorite && theyFavoritedUs) + .put( + "star_state", + when { + isFavorite -> "filled" + theyFavoritedUs -> "outlined_orange" + else -> "outlined" + } + ) + } + + private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + val fingerprint = mesh.getPeerFingerprint(peerID) + ?: return err("verification_set", "peer fingerprint is unavailable") + SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled) + return verificationStatus(context, peerID) + } + + private fun verificationStatus(context: Context, peerID: String): JSONObject { + val fingerprint = mesh(context).getPeerFingerprint(peerID) + val verified = fingerprint != null && + SecureIdentityStateManager(context).getVerifiedFingerprints().any { + it.equals(fingerprint, ignoreCase = true) + } + return ok("verification_status") + .put("peer", peerID) + .put("fingerprint", fingerprint ?: JSONObject.NULL) + .put("verified", verified) + } + // MARK: - File transfer private suspend fun fileSend(context: Context, intent: Intent): JSONObject { diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index 60801bb8..ae453bed 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -48,6 +48,25 @@ data class FavoriteRelationship( } } +internal fun FavoriteRelationship?.withPeerFavoritedUs( + noisePublicKey: ByteArray, + theyFavoritedUs: Boolean, + now: Date = Date() +): FavoriteRelationship { + return this?.copy( + theyFavoritedUs = theyFavoritedUs, + lastUpdated = now + ) ?: FavoriteRelationship( + peerNoisePublicKey = noisePublicKey, + peerNostrPublicKey = null, + peerNickname = "Unknown", + isFavorite = false, + theyFavoritedUs = theyFavoritedUs, + favoritedAt = now, + lastUpdated = now + ) +} + interface FavoritesChangeListener { fun onFavoriteChanged(noiseKeyHex: String) fun onAllCleared() @@ -234,18 +253,13 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val existing = favorites[keyHex] + val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs) - if (existing != null) { - val updated = existing.copy( - theyFavoritedUs = theyFavoritedUs, - lastUpdated = Date() - ) - favorites[keyHex] = updated - saveFavorites() - notifyChanged(keyHex) + favorites[keyHex] = updated + saveFavorites() + notifyChanged(keyHex) - Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") - } + Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } diff --git a/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt b/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt new file mode 100644 index 00000000..123a0ba4 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.favorites + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class FavoriteRelationshipTest { + + @Test + fun `first inbound favorite creates a received relationship`() { + val noiseKey = ByteArray(32) { it.toByte() } + + val relationship = null.withPeerFavoritedUs( + noisePublicKey = noiseKey, + theyFavoritedUs = true, + now = Date(123L) + ) + + assertFalse(relationship.isFavorite) + assertTrue(relationship.theyFavoritedUs) + assertTrue(relationship.peerNoisePublicKey.contentEquals(noiseKey)) + } + + @Test + fun `inbound favorite preserves our existing favorite`() { + val noiseKey = ByteArray(32) { it.toByte() } + val existing = FavoriteRelationship( + peerNoisePublicKey = noiseKey, + peerNostrPublicKey = null, + peerNickname = "peer", + isFavorite = true, + theyFavoritedUs = false, + favoritedAt = Date(10L), + lastUpdated = Date(20L) + ) + + val relationship = existing.withPeerFavoritedUs( + noisePublicKey = noiseKey, + theyFavoritedUs = true, + now = Date(30L) + ) + + assertTrue(relationship.isFavorite) + assertTrue(relationship.theyFavoritedUs) + assertTrue(relationship.isMutual) + } +} diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 00fdc98e..c8de2c55 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -296,6 +296,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | Scenario | What it asserts | |---|---| | `dm` | Noise handshake both ways, encrypted DM round trips with content match | +| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification | | `broadcast` | public mesh message A→B | | `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | | `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing | @@ -323,7 +324,8 @@ python3 tools/release_gate/mesh_lab.py cmd --serial state # full mesh See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`, -`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `file_send`, +`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`, +`favorite_status`, `verification_set`, `verification_status`, `file_send`, `file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`). ### Troubleshooting diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 214d4af0..b3c04b70 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -424,6 +424,101 @@ def scenario_dm(a: Device, b: Device) -> dict: } +def scenario_favorite_verification(a: Device, b: Device) -> dict: + """Assert the three-state favorite exchange and local cryptographic verification.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + identity_a = whoami(a)["identity_fingerprint"] + + a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b) + b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a) + + def wait_for_status( + device: Device, + command: str, + peer_id: str, + predicate, + timeout_s: int = 30, + ) -> dict: + deadline = time.monotonic() + timeout_s + last: dict = {} + while time.monotonic() < deadline: + last = device.cmd_ok(command, peer=peer_id) + if predicate(last): + return last + time.sleep(1) + raise MeshLabError( + f"[{device.alias}] {command} did not reach the expected state: {last}" + ) + + # Start from a known non-favorite relationship without clearing either app's data. + a.cmd_ok("favorite_set", peer=id_b, enabled=False) + b.cmd_ok("favorite_set", peer=id_a, enabled=False) + neutral_a = wait_for_status( + a, + "favorite_status", + id_b, + lambda state: not state["is_favorite"] and not state["they_favorited_us"], + ) + neutral_b = wait_for_status( + b, + "favorite_status", + id_a, + lambda state: not state["is_favorite"] and not state["they_favorited_us"], + ) + + # A favorites B. B must show the orange outline while its own favorite remains false. + a.cmd_ok("favorite_set", peer=id_b, enabled=True) + received_only = wait_for_status( + b, + "favorite_status", + id_a, + lambda state: ( + not state["is_favorite"] + and state["they_favorited_us"] + and state["star_state"] == "outlined_orange" + ), + ) + + # B favorites back. Both relationships become mutual and B's star becomes filled. + b.cmd_ok("favorite_set", peer=id_a, enabled=True) + mutual_b = wait_for_status( + b, + "favorite_status", + id_a, + lambda state: state["is_mutual"] and state["star_state"] == "filled", + ) + mutual_a = wait_for_status( + a, + "favorite_status", + id_b, + lambda state: state["is_mutual"] and state["star_state"] == "filled", + ) + + # The code B displays for A must be A's SHA-256 Noise identity fingerprint. + verification_before = b.cmd_ok("verification_status", peer=id_a) + if verification_before.get("fingerprint") != identity_a: + raise MeshLabError("displayed verification fingerprint does not match peer identity") + b.cmd_ok("verification_set", peer=id_a, enabled=True) + verification_after = wait_for_status( + b, + "verification_status", + id_a, + lambda state: state["verified"], + ) + + return { + "neutral": {"a": neutral_a, "b": neutral_b}, + "received_favorite": received_only, + "mutual": {"a": mutual_a, "b": mutual_b}, + "verification": { + "fingerprint_matches_peer_identity": True, + "before": verification_before, + "after": verification_after, + }, + } + + def scenario_broadcast(a: Device, b: Device) -> dict: """Public broadcast from A received by B.""" id_a = whoami(a)["peer_id"] @@ -682,6 +777,7 @@ def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> d SCENARIOS = { "dm": scenario_dm, + "favorite_verification": scenario_favorite_verification, "broadcast": scenario_broadcast, # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only # the small fixture is end-to-end receivable. @@ -707,7 +803,16 @@ SCENARIOS = { # Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends, # the watch must receive with matching digests). -WATCH_SCENARIOS = ["dm", "broadcast", "raw", "file", "file_private", "session_recovery", "identity_reset"] +WATCH_SCENARIOS = [ + "dm", + "favorite_verification", + "broadcast", + "raw", + "file", + "file_private", + "session_recovery", + "identity_reset", +] def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt index c43ef258..6b68c628 100644 --- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -10,6 +10,7 @@ import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.services.AppStateStore import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.service.WearMeshForegroundService +import com.bitchat.watch.ui.WearPeerIdentityState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull @@ -49,6 +50,18 @@ object WearTestHookDriver { "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) "dm_recv" -> dmRecv(context, intent) "msg_recv" -> msgRecv(context, intent) + "favorite_set" -> favoriteSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "favorite_status" -> favoriteStatus(context, intent.requiredString("peer")) + "verification_set" -> verificationSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "verification_status" -> verificationStatus(context, intent.requiredString("peer")) "raw_send" -> rawSend(context, intent) "file_recv" -> fileRecv(context, intent) "state" -> state(context) @@ -252,6 +265,55 @@ object WearTestHookDriver { .put("msg_id", found.id) } + // MARK: - Favorite and verification state + + private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + if (!WearPeerIdentityState.setFavorite(peerID, enabled, mesh)) { + return err("favorite_set", "peer Noise identity is unavailable") + } + return favoriteStatus(context, peerID) + } + + private fun favoriteStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + val identity = WearPeerIdentityState.snapshot(peerID, mesh) + return ok("favorite_status") + .put("peer", peerID) + .put("is_favorite", identity.isFavorite) + .put("they_favorited_us", identity.theyFavoritedUs) + .put("is_mutual", identity.isFavorite && identity.theyFavoritedUs) + .put( + "star_state", + when (identity.favoriteIndicator) { + com.bitchat.watch.ui.FavoriteIndicator.Favorite -> "filled" + com.bitchat.watch.ui.FavoriteIndicator.FavoritedUs -> "outlined_orange" + com.bitchat.watch.ui.FavoriteIndicator.None -> "outlined" + } + ) + } + + private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + if (!WearPeerIdentityState.setVerified(peerID, enabled, mesh)) { + return err("verification_set", "peer fingerprint is unavailable") + } + return verificationStatus(context, peerID) + } + + private fun verificationStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + val identity = WearPeerIdentityState.snapshot(peerID, mesh) + return ok("verification_status") + .put("peer", peerID) + .put("fingerprint", identity.fingerprint ?: JSONObject.NULL) + .put("verified", identity.isVerified) + } + // MARK: - Raw packet injection private fun rawSend(context: Context, intent: Intent): JSONObject { diff --git a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt index f480cfdd..8a52c1d8 100644 --- a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt +++ b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt @@ -3,11 +3,13 @@ package com.bitchat.watch import android.app.Application import com.bitchat.android.mesh.PowerManager import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.ui.WearPeerIdentityState class BitchatWatchApplication : Application() { override fun onCreate() { super.onCreate() PowerManager.getInstance(applicationContext) WearNotificationCoordinator.getInstance(applicationContext) + WearPeerIdentityState.initialize(applicationContext) } } diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index 7770a6f4..e128a08a 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -27,8 +27,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -46,6 +47,8 @@ import com.bitchat.watch.ui.ChatScreen import com.bitchat.watch.ui.DmScreen import com.bitchat.watch.ui.NicknameSetupScreen import com.bitchat.watch.ui.PeopleScreen +import com.bitchat.watch.ui.UserDetailScreen +import com.bitchat.watch.ui.VerificationCodeScreen import com.bitchat.watch.ui.WearChatState import com.bitchat.watch.ui.sendPrivateMessage import com.bitchat.watch.ui.sendPublicMessage @@ -56,6 +59,8 @@ sealed interface WearScreen { data object People : WearScreen data object Nickname : WearScreen data class Dm(val peerID: String) : WearScreen + data class UserDetail(val peerID: String) : WearScreen + data class Verification(val peerID: String) : WearScreen data class TextInput(val peerID: String?) : WearScreen } @@ -66,13 +71,24 @@ class MainActivity : ComponentActivity() { private var nicknameChosen by mutableStateOf(false) private var notificationsGranted by mutableStateOf(false) private var notificationPromptDismissed by mutableStateOf(false) - private var pendingDmPeer by mutableStateOf(null) + private var pendingLaunchRequest by mutableStateOf(null) + private var nextLaunchRequestID = 0L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) .getBoolean("nickname_chosen", false) - pendingDmPeer = privateMessagePeerFromIntent(intent) + pendingLaunchRequest = restoreWearLaunchRequest( + savedInstanceState?.getStringArrayList(KEY_PENDING_LAUNCH_REQUEST) + ) + nextLaunchRequestID = maxOf( + savedInstanceState?.getLong(KEY_NEXT_LAUNCH_REQUEST_ID, 0L) ?: 0L, + pendingLaunchRequest?.id ?: 0L + ) + val notificationPeer = consumePrivateMessagePeer(intent) + if (savedInstanceState == null && notificationPeer != null) { + requestLaunch(WearLaunchTarget.Dm(notificationPeer)) + } refreshState() setContent { BitchatWearTheme { @@ -96,14 +112,29 @@ class MainActivity : ComponentActivity() { onSkip = { notificationPromptDismissed = true } ) else -> WearNavHost( - openDmPeer = pendingDmPeer, - onOpenDmHandled = { pendingDmPeer = null } + launchRequest = pendingLaunchRequest, + onLaunchRequestHandled = { requestID -> + if (pendingLaunchRequest?.id == requestID) { + pendingLaunchRequest = null + } + } ) } } } } + override fun onSaveInstanceState(outState: Bundle) { + outState.putLong(KEY_NEXT_LAUNCH_REQUEST_ID, nextLaunchRequestID) + pendingLaunchRequest?.let { + outState.putStringArrayList( + KEY_PENDING_LAUNCH_REQUEST, + it.toSavedStateValues() + ) + } + super.onSaveInstanceState(outState) + } + override fun onResume() { super.onResume() WearChatState.setAppInForeground(true) @@ -122,7 +153,10 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - privateMessagePeerFromIntent(intent)?.let { pendingDmPeer = it } + val notificationPeer = consumePrivateMessagePeer(intent) + requestLaunch( + notificationPeer?.let(WearLaunchTarget::Dm) ?: WearLaunchTarget.Chat + ) } private fun refreshState() { @@ -142,6 +176,13 @@ class MainActivity : ComponentActivity() { 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? { if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) { return null @@ -150,6 +191,13 @@ class MainActivity : ComponentActivity() { ?.takeIf { it.isNotBlank() } } + private fun requestLaunch(target: WearLaunchTarget) { + pendingLaunchRequest = WearLaunchRequest( + id = ++nextLaunchRequestID, + target = target + ) + } + private fun notificationPermissionGranted(): Boolean { return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( @@ -159,6 +207,9 @@ class MainActivity : ComponentActivity() { } companion object { + private const val KEY_NEXT_LAUNCH_REQUEST_ID = "next_launch_request_id" + private const val KEY_PENDING_LAUNCH_REQUEST = "pending_launch_request" + fun requiredPermissions(): List = buildList { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { add(Manifest.permission.BLUETOOTH_SCAN) @@ -169,36 +220,175 @@ class MainActivity : ComponentActivity() { } } -@Composable -fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { - var screen by remember { mutableStateOf(WearScreen.Chat) } - val backStack = remember { mutableStateListOf() } +internal sealed interface WearLaunchTarget { + data object Chat : WearLaunchTarget + data class Dm(val peerID: String) : WearLaunchTarget +} + +internal data class WearLaunchRequest( + val id: Long, + val target: WearLaunchTarget +) + +internal fun WearLaunchRequest.toSavedStateValues(): ArrayList { + val (type, peerID) = when (val launchTarget = target) { + WearLaunchTarget.Chat -> "chat" to "" + is WearLaunchTarget.Dm -> "dm" to launchTarget.peerID + } + return arrayListOf(id.toString(), type, peerID) +} + +internal fun restoreWearLaunchRequest(values: List?): WearLaunchRequest? { + if (values?.size != 3) return null + val id = values[0].toLongOrNull()?.takeIf { it > 0L } ?: return null + val target = when (values[1]) { + "chat" -> WearLaunchTarget.Chat + "dm" -> values[2] + .takeIf(String::isNotBlank) + ?.let(WearLaunchTarget::Dm) + ?: return null + else -> return null + } + return WearLaunchRequest(id = id, target = target) +} + +internal class WearNavigationState( + initialScreen: WearScreen = WearScreen.Chat, + initialBackStack: List = emptyList() +) { + private val backStack = mutableStateListOf().apply { + addAll(initialBackStack) + } + + var screen by mutableStateOf(initialScreen) + private set + + val canGoBack: Boolean + get() = screen != WearScreen.Chat || backStack.isNotEmpty() fun navigate(to: WearScreen) { backStack.add(screen) screen = to } - fun goBack(): Boolean { - val previous = backStack.removeLastOrNull() - return if (previous != null) { - screen = previous - true - } else false + fun openChat() { + backStack.clear() + screen = WearScreen.Chat } - BackHandler(enabled = backStack.isNotEmpty()) { goBack() } + fun openDmFromNotification(peerID: String) { + backStack.clear() + backStack.add(WearScreen.Chat) + screen = WearScreen.Dm(peerID) + } - LaunchedEffect(openDmPeer) { - openDmPeer?.let { peerID -> - backStack.clear() - screen = WearScreen.Dm(peerID) - onOpenDmHandled() + fun goBack(): Boolean { + if (screen is WearScreen.Dm) { + openChat() + return true + } + + val previous = backStack.removeLastOrNull() ?: return false + screen = previous + return true + } + + internal fun toSavedStateValues(): List { + return buildList { + add(SAVED_STATE_VERSION) + (listOf(screen) + backStack).forEach { savedScreen -> + val (type, peerID) = encodeScreen(savedScreen) + add(type) + add(peerID) + } + } + } + + companion object { + private const val SAVED_STATE_VERSION = "1" + + val Saver = listSaver( + save = { it.toSavedStateValues() }, + restore = ::restore + ) + + internal fun restore(values: List): WearNavigationState? { + if ( + values.firstOrNull() != SAVED_STATE_VERSION || + values.size < 3 || + (values.size - 1) % 2 != 0 + ) { + return null + } + + val screens = mutableListOf() + var index = 1 + while (index < values.size) { + val restoredScreen = decodeScreen( + type = values[index], + peerID = values[index + 1] + ) ?: return null + screens += restoredScreen + index += 2 + } + return WearNavigationState( + initialScreen = screens.first(), + initialBackStack = screens.drop(1) + ) + } + + private fun encodeScreen(screen: WearScreen): Pair = when (screen) { + WearScreen.Chat -> "chat" to "" + WearScreen.People -> "people" to "" + WearScreen.Nickname -> "nickname" to "" + is WearScreen.Dm -> "dm" to screen.peerID + is WearScreen.UserDetail -> "user_detail" to screen.peerID + is WearScreen.Verification -> "verification" to screen.peerID + is WearScreen.TextInput -> screen.peerID?.let { "text_dm" to it } + ?: ("text_public" to "") + } + + private fun decodeScreen(type: String, peerID: String): WearScreen? = when (type) { + "chat" -> WearScreen.Chat + "people" -> WearScreen.People + "nickname" -> WearScreen.Nickname + "dm" -> peerID.takeIf(String::isNotBlank)?.let(WearScreen::Dm) + "user_detail" -> peerID + .takeIf(String::isNotBlank) + ?.let(WearScreen::UserDetail) + "verification" -> peerID + .takeIf(String::isNotBlank) + ?.let(WearScreen::Verification) + "text_dm" -> peerID.takeIf(String::isNotBlank)?.let(WearScreen::TextInput) + "text_public" -> WearScreen.TextInput(null) + else -> null + } + } +} + +@Composable +internal fun WearNavHost( + launchRequest: WearLaunchRequest?, + onLaunchRequestHandled: (Long) -> Unit +) { + val navigation = rememberSaveable(saver = WearNavigationState.Saver) { + 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( - targetState = screen, + targetState = navigation.screen, transitionSpec = { fadeIn(tween(com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS)) togetherWith fadeOut(tween(com.bitchat.watch.ui.theme.BitchatMotion.QUICK_MS)) @@ -207,12 +397,12 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { ) { current -> when (current) { is WearScreen.Chat -> ChatScreen( - onOpenPeople = { navigate(WearScreen.People) }, - onOpenTextInput = { navigate(WearScreen.TextInput(null)) } + onOpenPeople = { navigation.navigate(WearScreen.People) }, + onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) } ) is WearScreen.People -> PeopleScreen( - onOpenDm = { navigate(WearScreen.Dm(it)) }, - onEditNickname = { navigate(WearScreen.Nickname) } + onOpenDm = { navigation.navigate(WearScreen.Dm(it)) }, + onEditNickname = { navigation.navigate(WearScreen.Nickname) } ) is WearScreen.Nickname -> { val mesh = WearMeshService.peek() @@ -223,14 +413,26 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { confirmLabel = "Save", onConfirm = { name -> mesh?.setNickname(name) - goBack() + navigation.goBack() } ) } is WearScreen.Dm -> DmScreen( peerID = current.peerID, - onOpenTextInput = { navigate(WearScreen.TextInput(current.peerID)) } + onOpenUserDetail = { + navigation.navigate(WearScreen.UserDetail(current.peerID)) + }, + onOpenTextInput = { + navigation.navigate(WearScreen.TextInput(current.peerID)) + } ) + is WearScreen.UserDetail -> UserDetailScreen( + peerID = current.peerID, + onOpenVerification = { + navigation.navigate(WearScreen.Verification(current.peerID)) + } + ) + is WearScreen.Verification -> VerificationCodeScreen(peerID = current.peerID) is WearScreen.TextInput -> { val mesh = WearMeshService.peek() val sendScope = androidx.compose.runtime.rememberCoroutineScope() @@ -244,7 +446,7 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { sendPrivateMessage(m, current.peerID, nick, text, sendScope) } } - goBack() + navigation.goBack() } ) } diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index 66dded9d..d66dd751 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -4,6 +4,7 @@ import android.bluetooth.BluetoothDevice import android.content.Context import android.util.Log import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.mesh.BluetoothConnectionManager import com.bitchat.android.mesh.BluetoothConnectionManagerDelegate import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy @@ -306,6 +307,32 @@ class WearMeshService private constructor(private val context: Context) { meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname) } + /** + * Favorite controls travel as encrypted private control messages. If the profile is opened + * while a handshake is still settling, wait briefly rather than silently losing the change. + */ + fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) { + serviceScope.launch { + val deadline = System.currentTimeMillis() + 15_000L + if (!hasEstablishedSession(peerID)) { + runCatching { initiateNoiseHandshake(peerID) } + } + while (!hasEstablishedSession(peerID) && System.currentTimeMillis() < deadline) { + delay(250L) + } + if (!hasEstablishedSession(peerID)) { + Log.w(TAG, "Favorite update could not be sent before the Noise timeout") + return@launch + } + val recipientNickname = getPeerNickname(peerID) ?: peerID.take(8) + meshCore.sendPrivateMessage( + content = FavoriteControlMessage.encode(isFavorite, npub = null), + recipientPeerID = peerID, + recipientNickname = recipientNickname + ) + } + } + fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) fun hasEstablishedSession(peerID: String): Boolean = meshCore.hasEstablishedSession(peerID) diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index 689251a1..05ebf23c 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -1,5 +1,6 @@ package com.bitchat.watch.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement @@ -9,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxSize 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.Verified import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -23,6 +26,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults import androidx.wear.compose.foundation.rotary.rotaryScrollable import androidx.compose.ui.text.font.FontWeight @@ -30,9 +34,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.foundation.lazy.items import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.R import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.notification.WearNotificationCoordinator import com.bitchat.watch.ui.media.FullScreenImageViewer @@ -42,7 +48,11 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette import com.bitchat.watch.ui.theme.colorForPeer @Composable -fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) { +fun DmScreen( + peerID: String, + onOpenUserDetail: () -> Unit, + onOpenTextInput: () -> Unit +) { val context = LocalContext.current val privateMessages by AppStateStore.privateMessages.collectAsState() val messages = privateMessages[peerID] ?: emptyList() @@ -55,6 +65,10 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) { } val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) + val identityRevision by WearPeerIdentityState.revision.collectAsState() + val identity = remember(peerID, identityRevision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } var sessionEstablished by remember { mutableStateOf(mesh?.hasEstablishedSession(peerID) == true) } @@ -87,7 +101,10 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) { nickname = nickname, peerID = peerID, sessionEstablished = sessionEstablished, - expanded = expanded + expanded = expanded, + isFavorite = identity.isFavorite, + isVerified = identity.isVerified, + onClick = onOpenUserDetail ) }, actionBar = { @@ -105,7 +122,10 @@ private fun DmHeader( nickname: String, peerID: String, sessionEstablished: Boolean, - expanded: Boolean + expanded: Boolean, + isFavorite: Boolean, + isVerified: Boolean, + onClick: () -> Unit ) { val palette = LocalBitchatPalette.current // Floating title row: full-size at the newest messages, shrinks to its dense form @@ -127,6 +147,10 @@ private fun DmHeader( Row( modifier = Modifier .fillMaxWidth() + .clickable( + onClickLabel = "Open user details", + onClick = onClick + ) .padding(horizontal = 8.dp, vertical = headerVPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically @@ -146,5 +170,25 @@ private fun DmHeader( size = headerIconSize, modifier = Modifier.padding(start = 5.dp) ) + if (isFavorite) { + Icon( + painter = painterResource(R.drawable.ic_spec_star_filled), + contentDescription = "Favorite", + tint = palette.accentOrange, + modifier = Modifier + .padding(start = 4.dp) + .size(headerIconSize) + ) + } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = "Verified", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 4.dp) + .size(headerIconSize) + ) + } } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt index 17568387..1994f976 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -5,11 +5,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize 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.Verified import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -17,11 +21,13 @@ import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.R import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.ui.theme.LocalBitchatPalette import com.bitchat.watch.ui.theme.colorForPeer @@ -38,6 +44,7 @@ fun PeerDebugScreen() { val palette = LocalBitchatPalette.current val nicknames = mesh?.getPeerNicknames() ?: emptyMap() val rssi = mesh?.getPeerRSSI() ?: emptyMap() + val identityRevision by WearPeerIdentityState.revision.collectAsState() ScreenScaffold(scrollState = listState) { ScalingLazyColumn( @@ -68,6 +75,10 @@ fun PeerDebugScreen() { } items(peers) { peerID -> val nick = nicknames[peerID] ?: peerID.take(8) + val encrypted = mesh?.hasEstablishedSession(peerID) == true + val identity = androidx.compose.runtime.remember(peerID, identityRevision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } Row( modifier = Modifier .fillMaxWidth() @@ -83,6 +94,33 @@ fun PeerDebugScreen() { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) + if (encrypted) { + NoiseLockIcon( + state = NoiseSessionUiState.Established, + size = 11.dp, + modifier = Modifier.padding(start = 4.dp) + ) + } + if (identity.isFavorite) { + Icon( + painter = painterResource(R.drawable.ic_spec_star_filled), + contentDescription = "Favorite", + tint = palette.accentOrange, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } + if (identity.isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = "Verified", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } rssi[peerID]?.let { Text( text = "${it}dBm", diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index f701a887..3687ec3d 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.material.icons.filled.Verified import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -28,6 +30,7 @@ import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.R import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.ui.theme.ChatVisualTokens import com.bitchat.watch.ui.theme.LocalBitchatPalette @@ -41,9 +44,15 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { val listState = rememberScalingLazyListState() val palette = LocalBitchatPalette.current val nicknames = mesh?.getPeerNicknames() ?: emptyMap() + val identityRevision by WearPeerIdentityState.revision.collectAsState() // Peers with unread messages float to the top so they are easy to see and reach. - val sortedPeers = androidx.compose.runtime.remember(peers, unread, nicknames) { + val sortedPeers = androidx.compose.runtime.remember( + peers, + unread, + nicknames, + identityRevision + ) { peers.sortedWith( compareByDescending { (unread[it] ?: 0) > 0 } .thenBy { (nicknames[it] ?: it).lowercase() } @@ -85,10 +94,13 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { } items(sortedPeers, key = { it }) { peerID -> val nick = nicknames[peerID] ?: peerID.take(8) + val identity = WearPeerIdentityState.snapshot(peerID, mesh) PersonRow( nickname = nick, peerID = peerID, encrypted = mesh?.hasEstablishedSession(peerID) == true, + isFavorite = identity.isFavorite, + isVerified = identity.isVerified, unreadCount = unread[peerID] ?: 0, onClick = { onOpenDm(peerID) } ) @@ -136,6 +148,8 @@ private fun PersonRow( nickname: String, peerID: String, encrypted: Boolean, + isFavorite: Boolean, + isVerified: Boolean, unreadCount: Int, onClick: () -> Unit ) { @@ -168,6 +182,26 @@ private fun PersonRow( modifier = Modifier.padding(start = 4.dp) ) } + if (isFavorite) { + Icon( + painter = painterResource(R.drawable.ic_spec_star_filled), + contentDescription = "Favorite", + tint = palette.accentOrange, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = "Verified", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } } if (!encrypted) { Text( diff --git a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt new file mode 100644 index 00000000..a36c7ec6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -0,0 +1,213 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +@Composable +fun UserDetailScreen( + peerID: String, + onOpenVerification: () -> Unit +) { + val mesh = WearMeshService.peek() + val revision by WearPeerIdentityState.revision.collectAsState() + val identity = androidx.compose.runtime.remember(peerID, revision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } + val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) + ) { + item { + ListHeader { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = nickname, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = colorForPeer(nickname + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "User details", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + } + + item { + Card( + onClick = { + mesh?.let { + WearPeerIdentityState.setFavorite( + peerID = peerID, + isFavorite = !identity.isFavorite, + mesh = it + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + if (identity.isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = when { + identity.isFavorite -> "Favorite" + identity.theyFavoritedUs -> "They favorited you" + else -> "Not a favorite" + }, + tint = if ( + identity.isFavorite || identity.theyFavoritedUs + ) { + palette.accentOrange + } else { + palette.textTertiary + }, + modifier = Modifier.size(24.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = favoriteTitle(identity), + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = favoriteSubtitle(identity), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + } + } + + item { + Card( + onClick = onOpenVerification, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (identity.isVerified) { + Icons.Filled.Verified + } else { + Icons.Filled.Lock + }, + contentDescription = null, + tint = if (identity.isVerified) { + MaterialTheme.colorScheme.primary + } else { + palette.textTertiary + }, + modifier = Modifier.size(22.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = if (identity.isVerified) { + "Identity verified" + } else { + "Verification code" + }, + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Compare cryptographic fingerprints", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + } + } + + item { + Text( + text = "Peer ${peerID.take(8)}", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + } + } +} + +private fun favoriteTitle(identity: WearPeerIdentitySnapshot): String = when { + identity.isFavorite && identity.theyFavoritedUs -> "Mutual favorite" + identity.isFavorite -> "Favorited" + identity.theyFavoritedUs -> "Favorite back" + else -> "Add favorite" +} + +private fun favoriteSubtitle(identity: WearPeerIdentitySnapshot): String = when { + identity.isFavorite && identity.theyFavoritedUs -> "You favorited each other" + identity.isFavorite -> "Remove from favorites" + identity.theyFavoritedUs -> "They favorited you" + else -> "Keep this person easy to find" +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt new file mode 100644 index 00000000..c4c4ef0f --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -0,0 +1,177 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +@Composable +fun VerificationCodeScreen(peerID: String) { + val mesh = WearMeshService.peek() + val revision by WearPeerIdentityState.revision.collectAsState() + val identity = androidx.compose.runtime.remember(peerID, revision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } + val myFingerprint = WearPeerIdentityState.myFingerprint(mesh) + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) + ) { + item { + ListHeader { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = if (identity.isVerified) { + Icons.Filled.Verified + } else { + Icons.Outlined.Warning + }, + contentDescription = null, + tint = if (identity.isVerified) { + MaterialTheme.colorScheme.primary + } else { + palette.accentOrange + } + ) + Text( + text = if (identity.isVerified) "Verified" else "Verify identity", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + item { + FingerprintCard( + title = "Their code", + fingerprint = identity.fingerprint + ) + } + + item { + FingerprintCard( + title = "Your code", + fingerprint = myFingerprint + ) + } + + item { + Text( + text = "Compare both full codes in person or over a trusted channel.", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + item { + Button( + onClick = { + WearPeerIdentityState.setVerified( + peerID = peerID, + verified = !identity.isVerified, + mesh = mesh + ) + }, + enabled = identity.fingerprint != null + ) { + Text( + if (identity.isVerified) { + "Remove verification" + } else { + "Mark verified" + } + ) + } + } + } + } +} + +@Composable +private fun FingerprintCard( + title: String, + fingerprint: String? +) { + val palette = LocalBitchatPalette.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = title, + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + fontWeight = FontWeight.Bold + ) + Text( + text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 13.sp + ), + color = if (fingerprint == null) { + palette.accentOrange + } else { + MaterialTheme.colorScheme.onSurface + }, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } +} + +fun formatVerificationCode(fingerprint: String): String { + return fingerprint + .uppercase() + .chunked(4) + .chunked(4) + .joinToString("\n") { line -> line.joinToString(" ") } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt new file mode 100644 index 00000000..0a636251 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt @@ -0,0 +1,140 @@ +package com.bitchat.watch.ui + +import android.content.Context +import com.bitchat.android.favorites.FavoriteRelationship +import com.bitchat.android.favorites.FavoritesChangeListener +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.watch.mesh.WearMeshService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +enum class FavoriteIndicator { + None, + FavoritedUs, + Favorite +} + +data class WearPeerIdentitySnapshot( + val isFavorite: Boolean, + val theyFavoritedUs: Boolean, + val favoriteIndicator: FavoriteIndicator, + val fingerprint: String?, + val isVerified: Boolean +) + +fun favoriteIndicator( + isFavorite: Boolean, + theyFavoritedUs: Boolean +): FavoriteIndicator = when { + isFavorite -> FavoriteIndicator.Favorite + theyFavoritedUs -> FavoriteIndicator.FavoritedUs + else -> FavoriteIndicator.None +} + +/** + * Process-wide bridge between the shared identity stores and the Watch Compose UI. + * + * Favorites are keyed by the authenticated Noise public key, while screens navigate with the + * short mesh peer ID. The shared persistence service resolves that mapping and notifies this + * object whenever either side changes the relationship. + */ +object WearPeerIdentityState : FavoritesChangeListener { + private val _revision = MutableStateFlow(0L) + val revision: StateFlow = _revision.asStateFlow() + + @Volatile + private var initialized = false + + private lateinit var identityManager: SecureIdentityStateManager + + fun initialize(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + FavoritesPersistenceService.initialize(context.applicationContext) + identityManager = SecureIdentityStateManager(context.applicationContext) + FavoritesPersistenceService.shared.addListener(this) + initialized = true + } + publishChange() + } + + fun snapshot(peerID: String, mesh: WearMeshService?): WearPeerIdentitySnapshot { + check(initialized) { "WearPeerIdentityState must be initialized by the application" } + val relationship = relationship(peerID, mesh) + val fingerprint = mesh?.getPeerFingerprint(peerID) + ?: relationship?.peerNoisePublicKey?.let(identityManager::generateFingerprint) + val isVerified = fingerprint != null && + identityManager.getVerifiedFingerprints().any { + it.equals(fingerprint, ignoreCase = true) + } + val isFavorite = relationship?.isFavorite == true + val theyFavoritedUs = relationship?.theyFavoritedUs == true + return WearPeerIdentitySnapshot( + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + favoriteIndicator = favoriteIndicator(isFavorite, theyFavoritedUs), + fingerprint = fingerprint, + isVerified = isVerified + ) + } + + fun setFavorite( + peerID: String, + isFavorite: Boolean, + mesh: WearMeshService + ): Boolean { + check(initialized) { "WearPeerIdentityState must be initialized by the application" } + val peerInfo = mesh.getPeerInfo(peerID) ?: return false + val noisePublicKey = peerInfo.noisePublicKey ?: return false + val nickname = mesh.getPeerNickname(peerID) + ?: peerInfo.nickname.takeIf(String::isNotBlank) + ?: peerID.take(8) + + FavoritesPersistenceService.shared.updateFavoriteStatus( + noisePublicKey = noisePublicKey, + nickname = nickname, + isFavorite = isFavorite + ) + mesh.sendFavoriteNotification(peerID, isFavorite) + return true + } + + fun setVerified( + peerID: String, + verified: Boolean, + mesh: WearMeshService? + ): Boolean { + check(initialized) { "WearPeerIdentityState must be initialized by the application" } + val fingerprint = snapshot(peerID, mesh).fingerprint ?: return false + identityManager.setVerifiedFingerprint(fingerprint.lowercase(), verified) + publishChange() + return true + } + + fun myFingerprint(mesh: WearMeshService?): String? = mesh?.getIdentityFingerprint() + + override fun onFavoriteChanged(noiseKeyHex: String) { + publishChange() + } + + override fun onAllCleared() { + publishChange() + } + + private fun relationship( + peerID: String, + mesh: WearMeshService? + ): FavoriteRelationship? { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.let { return it } + val noisePublicKey = mesh?.getPeerInfo(peerID)?.noisePublicKey ?: return null + return FavoritesPersistenceService.shared.getFavoriteStatus(noisePublicKey) + } + + private fun publishChange() { + _revision.update { it + 1L } + } +} diff --git a/wear/src/main/res/drawable/ic_spec_star.xml b/wear/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..d132b2ce --- /dev/null +++ b/wear/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_spec_star_filled.xml b/wear/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..a3fae5e5 --- /dev/null +++ b/wear/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt b/wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt new file mode 100644 index 00000000..e59d5153 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt @@ -0,0 +1,109 @@ +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) + } + + @Test + fun `user detail and verification return through dm before main chat`() { + val navigation = WearNavigationState() + navigation.openDmFromNotification("peer-a") + navigation.navigate(WearScreen.UserDetail("peer-a")) + navigation.navigate(WearScreen.Verification("peer-a")) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.UserDetail("peer-a"), navigation.screen) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Dm("peer-a"), navigation.screen) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Chat, navigation.screen) + } + + @Test + fun `navigation state survives activity recreation`() { + val navigation = WearNavigationState() + navigation.openDmFromNotification("peer-a") + navigation.navigate(WearScreen.UserDetail("peer-a")) + navigation.navigate(WearScreen.Verification("peer-a")) + + val restored = WearNavigationState.restore(navigation.toSavedStateValues()) + + requireNotNull(restored) + assertEquals(WearScreen.Verification("peer-a"), restored.screen) + assertTrue(restored.goBack()) + assertEquals(WearScreen.UserDetail("peer-a"), restored.screen) + assertTrue(restored.goBack()) + assertEquals(WearScreen.Dm("peer-a"), restored.screen) + assertTrue(restored.goBack()) + assertEquals(WearScreen.Chat, restored.screen) + } + + @Test + fun `unhandled notification launch survives activity recreation`() { + val request = WearLaunchRequest( + id = 42L, + target = WearLaunchTarget.Dm("peer-a") + ) + + val restored = restoreWearLaunchRequest(request.toSavedStateValues()) + + assertEquals(request, restored) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt new file mode 100644 index 00000000..1b241690 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt @@ -0,0 +1,38 @@ +package com.bitchat.watch.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearPeerIdentityStateTest { + + @Test + fun `favorite indicator matches Android three-state star`() { + assertEquals( + FavoriteIndicator.None, + favoriteIndicator(isFavorite = false, theyFavoritedUs = false) + ) + assertEquals( + FavoriteIndicator.FavoritedUs, + favoriteIndicator(isFavorite = false, theyFavoritedUs = true) + ) + assertEquals( + FavoriteIndicator.Favorite, + favoriteIndicator(isFavorite = true, theyFavoritedUs = false) + ) + assertEquals( + FavoriteIndicator.Favorite, + favoriteIndicator(isFavorite = true, theyFavoritedUs = true) + ) + } + + @Test + fun `verification code keeps every fingerprint character`() { + val fingerprint = (0 until 64).joinToString("") { (it % 16).toString(16) } + + val formatted = formatVerificationCode(fingerprint) + + assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace)) + assertEquals(4, formatted.lines().size) + assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size }) + } +}