mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
commit
79e04ed3cc
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
213
wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt
Normal file
213
wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt
Normal 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"
|
||||
}
|
||||
@ -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(" ") }
|
||||
}
|
||||
140
wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt
Normal file
140
wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
17
wear/src/main/res/drawable/ic_spec_star.xml
Normal file
17
wear/src/main/res/drawable/ic_spec_star.xml
Normal 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>
|
||||
17
wear/src/main/res/drawable/ic_spec_star_filled.xml
Normal file
17
wear/src/main/res/drawable/ic_spec_star_filled.xml
Normal 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>
|
||||
109
wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt
Normal file
109
wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user