Merge pull request #820 from permissionlesstech/codex/fix-wear-private-message-navigation

Improve Watch private-chat navigation and peer identity UI
This commit is contained in:
callebtc 2026-07-29 19:23:43 +02:00 committed by GitHub
commit 79e04ed3cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1411 additions and 46 deletions

View File

@ -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 {

View File

@ -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<FavoriteRelationship> = favorites.values.filter { it.isMutual }

View File

@ -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)
}
}

View File

@ -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 <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

View File

@ -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:

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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<String?>(null)
private var pendingLaunchRequest by mutableStateOf<WearLaunchRequest?>(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<String> = 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>(WearScreen.Chat) }
val backStack = remember { mutableStateListOf<WearScreen>() }
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<String> {
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<String>?): 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<WearScreen> = emptyList()
) {
private val backStack = mutableStateListOf<WearScreen>().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<String> {
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<WearNavigationState, String>(
save = { it.toSavedStateValues() },
restore = ::restore
)
internal fun restore(values: List<String>): WearNavigationState? {
if (
values.firstOrNull() != SAVED_STATE_VERSION ||
values.size < 3 ||
(values.size - 1) % 2 != 0
) {
return null
}
val screens = mutableListOf<WearScreen>()
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<String, String> = 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()
}
)
}

View File

@ -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)

View File

@ -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)
)
}
}
}

View File

@ -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",

View File

@ -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<String> { (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(

View File

@ -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"
}

View File

@ -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(" ") }
}

View File

@ -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<Long> = _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 }
}
}

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group
android:translateX="-16"
android:translateY="-446">
<path
android:fillColor="#00000000"
android:pathData="M26,446.925L28.1637,453.836H35.375L29.645,458.143L31.8975,465.075L26,460.79L20.1025,465.075L22.355,458.143L16.625,453.836H23.8363L26,446.925Z"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
</group>
</vector>

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group
android:translateX="-16"
android:translateY="-446">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M26,446.925L28.1637,453.836H35.375L29.645,458.143L31.8975,465.075L26,460.79L20.1025,465.075L22.355,458.143L16.625,453.836H23.8363L26,446.925Z"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
</group>
</vector>

View File

@ -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)
}
}

View File

@ -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 })
}
}