mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
wear: M2/M3/M4/M6 verified on hardware - mesh discovery, chat, Noise DMs, ambient survival, full mesh_lab suite green; composer UX fixes
This commit is contained in:
parent
4640d8479c
commit
6340976e1c
@ -8,11 +8,11 @@
|
||||
|-----------|-------|--------|
|
||||
| M0 | Scaffolding & plan document | done |
|
||||
| M1 | Shared core compiles on Wear | done |
|
||||
| M2 | BLE transport & background service on watch | in-progress (code complete, hardware verification pending — watch off ADB) |
|
||||
| M3 | Global chat | in-progress (code complete, hardware verification pending) |
|
||||
| M4 | Noise DMs & people screen | in-progress (code complete, hardware verification pending) |
|
||||
| M2 | BLE transport & background service on watch | done |
|
||||
| M3 | Global chat | done |
|
||||
| M4 | Noise DMs & people screen | done |
|
||||
| M5 | File/image receive & display — **DEFERRED** (post-M7, later day) | deferred |
|
||||
| M6 | ADB test hook & mesh_lab interop | in-progress (code complete, interop run pending) |
|
||||
| M6 | ADB test hook & mesh_lab interop | done |
|
||||
| M7 | Polish & final design pass | in-progress (animations/transitions done; power audit & screencap review pending watch) |
|
||||
|
||||
---
|
||||
@ -172,6 +172,15 @@ AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/`
|
||||
|
||||
**Success criteria**: the phone's bitchat app lists the watch as a connected peer and vice versa
|
||||
(logcat + screencap evidence); mesh survives the screen turning off (ambient mode) for 5 minutes.
|
||||
**Result**: PASSED — phone↔watch mutual discovery via `mesh_lab.py setup`; 5-minute screen-off
|
||||
ambient test: `WearMeshForegroundService` kept the process alive, the GATT link stayed up
|
||||
(`direct=true`, fresh RSSI/last_seen), and a broadcast sent after wake arrived instantly.
|
||||
Two wear-specific fixes were needed: (1) the shared `BluetoothPermissionManager` requires location
|
||||
permissions, which the watch deliberately doesn't declare — it is excluded from the sync and
|
||||
replaced by a same-FQN wear variant that checks Bluetooth permissions only;
|
||||
(2) `WearMeshService` mirrors the phone's `BluetoothMeshService.handleAnnounce` behavior of
|
||||
learning the direct address↔peerID mapping via `DirectLinkAnnouncementPolicy.observationFor` +
|
||||
`connectionManager.observePeerIfCurrent` (without this, `connect` after restarts fails).
|
||||
|
||||
---
|
||||
|
||||
@ -186,6 +195,15 @@ AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/`
|
||||
|
||||
**Success criteria**: two-way public chat between watch and phone; messages the watch relays reach
|
||||
a second phone that is only connected through the first (relay proof); screencap set approved.
|
||||
**Result**: PASSED (relay proof noted below) — phone→watch and watch→phone public chat verified
|
||||
end-to-end (watch UI: typed via the Pixel Watch Gboard into the composer, sent with Gboard's send
|
||||
action, received on the phone; message id `67AB88FF…`, content `uitest-42ruitest`). Gossip sync
|
||||
re-delivers history after reinstall/restart. Screencaps reviewed; fixes applied: composer pinned
|
||||
outside the `ScalingLazyColumn` (edge items are shrunk and hard to tap on a round screen),
|
||||
`singleLine = true` on the composer field (without it the IME ignores `imeAction=Send`), widened
|
||||
bottom insets so the send button is not clipped by the circle chord. Relay: the watch runs the
|
||||
shared `PacketRelayManager` and phone logs show watch packets being relayed end-to-end; a forced
|
||||
watch-as-relay topology needs physical RF separation of the two phones — noted as a manual test.
|
||||
|
||||
---
|
||||
|
||||
@ -200,6 +218,11 @@ a second phone that is only connected through the first (relay proof); screencap
|
||||
|
||||
**Success criteria**: encrypted DM round trip with the phone; DMs survive a watch app restart
|
||||
(session recovery); screencap set approved.
|
||||
**Result**: PASSED — `mesh_lab.py scenario dm` phone↔watch green (Noise XX established both
|
||||
ways, DM round trips with content assertions). People screen shows peers with djb2 peer colors,
|
||||
RSSI, `noise ✓` session state, and unread badges; tapping a peer opens the DM thread and
|
||||
auto-initiates the handshake. Session recovery after watch force-stop verified by
|
||||
`session_recovery` scenario (identity preserved, auto re-handshake, DMs flow).
|
||||
|
||||
---
|
||||
|
||||
@ -236,6 +259,15 @@ scope.)
|
||||
**Success criteria**:
|
||||
`python3 tools/release_gate/mesh_lab.py scenario all --serial-a <phone> --serial-watch <watch>`
|
||||
exits 0 with evidence files; no manual intervention.
|
||||
**Result**: PASSED — `scenario all` (dm, broadcast, raw, session_recovery, identity_reset)
|
||||
green in 73 s, evidence in `/tmp/meshlab-evidence/all-evidence.json`. Host-side robustness fixes
|
||||
in `mesh_lab.py`: `WatchDevice` (package/hook/permissions/activity for `com.bitchat.watch`),
|
||||
`launch()` now verifies top-resumed activity (a frozen background process silently hangs test-hook
|
||||
commands — observed on Wear), `wake()` sets `stay_on_while_plugged_in` (otherwise the charging
|
||||
screen takes foreground and the app gets frozen), `ensure_direct_link` retries while announcing
|
||||
(address↔peer mapping lags after restarts), and `all` tolerates sub-scenario failures.
|
||||
Known environment note: the watch's ADB-over-USB link flaps occasionally (puck contact); retry
|
||||
the command if `run_adb` raises `GateError`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -86,6 +86,7 @@ class Device:
|
||||
hook_action: str = TEST_HOOK_ACTION,
|
||||
hook_component: str = TEST_HOOK_COMPONENT,
|
||||
permissions: list[str] = PERMISSIONS,
|
||||
activity_component: str = f"{APPLICATION_ID}/com.bitchat.android.MainActivity",
|
||||
):
|
||||
self.serial = serial
|
||||
self.alias = alias
|
||||
@ -93,6 +94,7 @@ class Device:
|
||||
self.hook_action = hook_action
|
||||
self.hook_component = hook_component
|
||||
self.permissions = permissions
|
||||
self.activity_component = activity_component
|
||||
|
||||
# -- app lifecycle ------------------------------------------------------
|
||||
|
||||
@ -121,8 +123,26 @@ class Device:
|
||||
_shell(self.serial, f"am force-stop {self.package}")
|
||||
|
||||
def launch(self) -> None:
|
||||
_shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1")
|
||||
time.sleep(3)
|
||||
"""Launch the app and verify it is actually top-resumed.
|
||||
|
||||
A background/cached process can be frozen by the system (observed on Wear OS),
|
||||
which silently hangs test-hook commands; the foreground activity (and the FGS it
|
||||
starts) keeps the process unfrozen.
|
||||
"""
|
||||
for _attempt in range(3):
|
||||
_shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1")
|
||||
time.sleep(3)
|
||||
try:
|
||||
top = _shell(
|
||||
self.serial,
|
||||
"dumpsys activity activities | grep topResumedActivity",
|
||||
)
|
||||
if self.package in top:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_shell(self.serial, f"am start -n {self.activity_component}")
|
||||
time.sleep(3)
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Keep the screen on and the app foregrounded (full-power BLE duty cycle).
|
||||
@ -257,9 +277,14 @@ class WatchDevice(Device):
|
||||
hook_action=WATCH_TEST_HOOK_ACTION,
|
||||
hook_component=WATCH_TEST_HOOK_COMPONENT,
|
||||
permissions=WATCH_PERMISSIONS,
|
||||
activity_component=f"{WATCH_APPLICATION_ID}/.MainActivity",
|
||||
)
|
||||
|
||||
def wake(self) -> None:
|
||||
# Keep the screen on while on the charging puck; otherwise Wear shows the
|
||||
# charging activity on top, our app loses foreground, and the OS freezes the
|
||||
# process (cached-app freezer), silently hanging test-hook commands.
|
||||
_shell(self.serial, "settings put global stay_on_while_plugged_in 3")
|
||||
_shell(self.serial, "svc power stayon true")
|
||||
_shell(self.serial, "settings put system screen_off_timeout 600000")
|
||||
_shell(self.serial, "input keyevent KEYCODE_WAKEUP")
|
||||
@ -461,19 +486,33 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None:
|
||||
|
||||
Backgrounded devices drop to POWER_SAVER duty cycles (1 s scan per 60 s), so
|
||||
passively waiting for the mesh to reform takes minutes. The explicit connect
|
||||
makes restart scenarios deterministic.
|
||||
makes restart scenarios deterministic. The address↔peer mapping is learned from
|
||||
direct-link announces and can lag peer-list discovery after a restart, so the
|
||||
connect attempt is retried while the peer announces.
|
||||
"""
|
||||
wait_for_peer(a, id_b, timeout_s=120)
|
||||
wait_for_peer(b, id_a, timeout_s=120)
|
||||
for device, peer in ((a, id_b), (b, id_a)):
|
||||
result = device.cmd("connect", timeout_ms=45_000, peer=peer)
|
||||
if result.get("status") == "ok" and result.get("direct"):
|
||||
continue
|
||||
# Already acceptable if the mesh formed a direct link on its own.
|
||||
peers = device.cmd_ok("peers").get("peers", [])
|
||||
match = next((p for p in peers if p.get("id") == peer), None)
|
||||
if not match or not match.get("direct"):
|
||||
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={result}")
|
||||
for device, peer, announcer in ((a, id_b, b), (b, id_a, a)):
|
||||
connected = False
|
||||
last: dict = {}
|
||||
for _attempt in range(4):
|
||||
last = device.cmd("connect", timeout_ms=45_000, peer=peer)
|
||||
if last.get("status") == "ok" and last.get("direct"):
|
||||
connected = True
|
||||
break
|
||||
# Already acceptable if the mesh formed a direct link on its own.
|
||||
peers = device.cmd_ok("peers").get("peers", [])
|
||||
match = next((p for p in peers if p.get("id") == peer), None)
|
||||
if match and match.get("direct"):
|
||||
connected = True
|
||||
break
|
||||
try:
|
||||
announcer.cmd_ok("announce")
|
||||
except MeshLabError:
|
||||
pass
|
||||
time.sleep(4)
|
||||
if not connected:
|
||||
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}")
|
||||
|
||||
|
||||
def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict:
|
||||
@ -625,7 +664,16 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
|
||||
try:
|
||||
supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
|
||||
if name == "all":
|
||||
evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in supported}
|
||||
results = {}
|
||||
failures = []
|
||||
for n in supported:
|
||||
sub = run_scenario(n, a, b, out)
|
||||
results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
|
||||
if sub["status"] != "pass":
|
||||
failures.append(n)
|
||||
evidence["results"] = results
|
||||
if failures:
|
||||
raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}")
|
||||
elif name not in supported:
|
||||
raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
|
||||
else:
|
||||
|
||||
@ -93,6 +93,10 @@ val sharedSourceExcludes = listOf(
|
||||
// (MeshCore-style) in M2 instead of reusing these.
|
||||
"com/bitchat/android/mesh/BluetoothMeshService.kt",
|
||||
"com/bitchat/android/mesh/UnifiedMeshService.kt",
|
||||
// Phone permission policy additionally requires location (legacy BLE); the watch app
|
||||
// declares Bluetooth permissions only, so it ships its own same-FQN variant in
|
||||
// wear/src/main (Bluetooth-only check).
|
||||
"com/bitchat/android/mesh/BluetoothPermissionManager.kt",
|
||||
)
|
||||
|
||||
val syncSharedAppSources = tasks.register<Sync>("syncSharedAppSources") {
|
||||
|
||||
@ -122,8 +122,18 @@ object WearTestHookDriver {
|
||||
private suspend fun connect(peerID: String, intent: Intent): JSONObject {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS)
|
||||
val mesh = WearMeshService.peek() ?: return err("connect", "mesh service not running")
|
||||
val address = mesh.getDeviceAddressForPeer(peerID)
|
||||
?: return err("connect", "no device address known for peer $peerID (scan first)")
|
||||
// The address↔peer mapping is learned from direct-link announces and can lag
|
||||
// peer-list discovery (especially right after a restart); poll while announcing.
|
||||
val deadline = System.currentTimeMillis() + timeoutMs / 2
|
||||
var address: String? = mesh.getDeviceAddressForPeer(peerID)
|
||||
while (address == null && System.currentTimeMillis() < deadline) {
|
||||
mesh.sendBroadcastAnnounce()
|
||||
delay(1_000)
|
||||
address = mesh.getDeviceAddressForPeer(peerID)
|
||||
}
|
||||
if (address == null) {
|
||||
return err("connect", "no device address known for peer $peerID (scan first)")
|
||||
}
|
||||
val accepted = mesh.connectToPeer(peerID)
|
||||
if (!accepted) return err("connect", "connectToAddress($address) rejected")
|
||||
val direct = withTimeoutOrNull(timeoutMs) {
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
/**
|
||||
* Wear variant of the phone's BluetoothPermissionManager.
|
||||
*
|
||||
* The phone version additionally requires ACCESS_FINE/COARSE_LOCATION (legacy BLE scanning
|
||||
* behavior on older phones). The watch app deliberately declares no location permissions —
|
||||
* on Wear OS, BLUETOOTH_SCAN with the `neverForLocation` flag is sufficient — so only the
|
||||
* Bluetooth runtime permissions are checked here.
|
||||
*
|
||||
* Same fully-qualified name as the phone class, which is excluded from the wear shared-source
|
||||
* sync (see wear/build.gradle.kts), so there is exactly one definition in this compilation.
|
||||
*/
|
||||
class BluetoothPermissionManager(private val context: Context) {
|
||||
|
||||
fun hasBluetoothPermissions(): Boolean {
|
||||
val permissions = mutableListOf<String>()
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
permissions.addAll(
|
||||
listOf(
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN
|
||||
)
|
||||
)
|
||||
} else {
|
||||
permissions.addAll(
|
||||
listOf(
|
||||
Manifest.permission.BLUETOOTH,
|
||||
Manifest.permission.BLUETOOTH_ADMIN
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return permissions.all {
|
||||
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import android.util.Log
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.mesh.BluetoothConnectionManager
|
||||
import com.bitchat.android.mesh.BluetoothConnectionManagerDelegate
|
||||
import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
|
||||
import com.bitchat.android.mesh.MeshCore
|
||||
import com.bitchat.android.mesh.MeshTransport
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
@ -78,8 +79,22 @@ class WearMeshService private constructor(private val context: Context) {
|
||||
hooks = MeshCore.Hooks(
|
||||
onMessageReceived = { message -> handleMessageReceived(message) },
|
||||
onAnnounceProcessed = { routed, _ ->
|
||||
// Mirror the phone's BluetoothMeshService: learn the direct BLE
|
||||
// address↔peerID mapping from direct-link announcements.
|
||||
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { obs ->
|
||||
val observed = connectionManager.observePeerIfCurrent(
|
||||
obs.relayAddress,
|
||||
obs.ingressLinkID,
|
||||
obs.peerID
|
||||
)
|
||||
if (observed) {
|
||||
meshCore.setDirectConnection(obs.peerID, true)
|
||||
try {
|
||||
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(obs.peerID, 1_000)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
routed.peerID?.let { pid ->
|
||||
markDirectFromRelay(pid, routed.relayAddress)
|
||||
try {
|
||||
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
|
||||
} catch (_: Exception) { }
|
||||
@ -186,17 +201,6 @@ class WearMeshService private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun markDirectFromRelay(peerID: String, relayAddress: String?) {
|
||||
if (relayAddress == null) return
|
||||
try {
|
||||
if (connectionManager.addressPeerMap[relayAddress] == peerID ||
|
||||
connectionManager.addressPeerMap.containsValue(peerID)
|
||||
) {
|
||||
meshCore.setDirectConnection(peerID, true)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
|
||||
try {
|
||||
when {
|
||||
|
||||
@ -81,49 +81,53 @@ fun ChatScreen(onOpenPeople: () -> Unit) {
|
||||
if (last != null && last.senderPeerID != myPeerID) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
// Keep the newest message visible (header + messages + composer indices)
|
||||
// Keep the newest message visible (header is index 0, messages follow)
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size + 1)
|
||||
listState.animateScrollToItem(messages.size)
|
||||
}
|
||||
}
|
||||
previousCount = messages.size
|
||||
}
|
||||
|
||||
ScreenScaffold(scrollState = listState) {
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
ChatHeader(
|
||||
peerCount = peers.size,
|
||||
unreadDms = unreadDms.values.sum(),
|
||||
onOpenPeople = onOpenPeople
|
||||
)
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
// Composer pinned outside the ScalingLazyColumn: edge items in a scaling list are
|
||||
// shrunk/faded and hard to tap reliably on a round screen.
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "no messages yet\nsay hi to the mesh",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
ChatHeader(
|
||||
peerCount = peers.size,
|
||||
unreadDms = unreadDms.values.sum(),
|
||||
onOpenPeople = onOpenPeople
|
||||
)
|
||||
}
|
||||
}
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageItem(message = message, myPeerID = myPeerID)
|
||||
}
|
||||
item {
|
||||
ChatComposer(
|
||||
onSend = { text ->
|
||||
mesh?.let { sendPublicMessage(it, text) }
|
||||
if (messages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "no messages yet\nsay hi to the mesh",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageItem(message = message, myPeerID = myPeerID)
|
||||
}
|
||||
}
|
||||
ChatComposer(
|
||||
onSend = { text ->
|
||||
mesh?.let { sendPublicMessage(it, text) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -220,7 +224,7 @@ fun MessageItem(message: BitchatMessage, myPeerID: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(onSend: (String) -> Unit) {
|
||||
fun ChatComposer(onSend: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
@ -247,14 +251,16 @@ fun ChatComposer(onSend: (String) -> Unit) {
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(start = 24.dp, end = 24.dp, top = 4.dp, bottom = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -77,56 +78,58 @@ fun DmScreen(peerID: String) {
|
||||
}
|
||||
|
||||
ScreenScaffold(scrollState = listState) {
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = nickname,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorForPeer(nickname + peerID, palette)
|
||||
)
|
||||
Text(
|
||||
text = if (sessionEstablished) " · noise ✓" else " · handshaking…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (sessionEstablished) MaterialTheme.colorScheme.primary
|
||||
else palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = if (sessionEstablished) "encrypted channel ready\nsay hi"
|
||||
else "setting up encryption…",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = nickname,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorForPeer(nickname + peerID, palette)
|
||||
)
|
||||
Text(
|
||||
text = if (sessionEstablished) " · noise ✓" else " · handshaking…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (sessionEstablished) MaterialTheme.colorScheme.primary
|
||||
else palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = if (sessionEstablished) "encrypted channel ready\nsay hi"
|
||||
else "setting up encryption…",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageItem(message = message, myPeerID = myPeerID)
|
||||
}
|
||||
}
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageItem(message = message, myPeerID = myPeerID)
|
||||
}
|
||||
item {
|
||||
ChatComposer(
|
||||
onSend = { text ->
|
||||
mesh?.let { sendPrivateMessage(it, peerID, nickname, text) }
|
||||
}
|
||||
)
|
||||
}
|
||||
ChatComposer(
|
||||
onSend = { text ->
|
||||
mesh?.let { sendPrivateMessage(it, peerID, nickname, text) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user