From 71c257669a9ee90ca7fd87435c7f97af4f8aecc7 Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Tue, 28 Jul 2026 20:55:15 +0200
Subject: [PATCH] watch works
---
docs/wear-os-implementation-plan.md | 57 +++-
gradle/libs.versions.toml | 3 +-
tools/release_gate/mesh_lab.py | 19 +-
wear/build.gradle.kts | 1 +
.../watch/testhook/WearTestHookDriver.kt | 38 +++
wear/src/main/AndroidManifest.xml | 1 +
.../java/com/bitchat/watch/MainActivity.kt | 29 +-
.../com/bitchat/watch/mesh/WearMeshService.kt | 69 ++++
.../com/bitchat/watch/ui/ChatActionBar.kt | 178 ++++++++++
.../java/com/bitchat/watch/ui/ChatScreen.kt | 250 +++++---------
.../java/com/bitchat/watch/ui/DmScreen.kt | 115 ++++---
.../java/com/bitchat/watch/ui/SendHelpers.kt | 78 +++++
.../com/bitchat/watch/ui/TextInputScreen.kt | 156 +++++++++
.../bitchat/watch/ui/VoiceNoteController.kt | 88 +++++
.../com/bitchat/watch/ui/media/MediaItems.kt | 306 ++++++++++++++++++
15 files changed, 1134 insertions(+), 254 deletions(-)
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt
diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md
index fecc0c8b..eebe74b1 100644
--- a/docs/wear-os-implementation-plan.md
+++ b/docs/wear-os-implementation-plan.md
@@ -11,7 +11,7 @@
| 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 |
+| M5 | Files/images receive + voice notes (push-to-talk) + input redesign | in-progress |
| M6 | ADB test hook & mesh_lab interop | done |
| M7 | Polish & final design pass | done |
@@ -226,22 +226,51 @@ auto-initiates the handshake. Session recovery after watch force-stop verified b
---
-### M5 — File/image receive & display — **DEFERRED**
+### M5 — Files/images receive + voice notes (push-to-talk) + input redesign
-> Deferred to a later day (after M7). Milestones M6 and M7 do not depend on M5 and proceed
-> without it. The mesh_lab `file` scenario for the watch is skipped until M5 is un-deferred.
+> Revised scope (was: receive-only, deferred). Now includes voice messages as a first-class
+> input method and a native-Wear bottom-action redesign of the composer.
-- [ ] Receive broadcast files (`MessageType.FILE_TRANSFER`, `BitchatFilePacket` TLV decode,
- fragment reassembly — all shared)
-- [ ] Receive Noise-encrypted private files (`NoisePayloadType.FILE_TRANSFER`)
-- [ ] Inline image rendering in chat timelines; full-screen image viewer (pinch/crown zoom);
- non-image files saved with a way to open/share them
-- [ ] Transfer progress indicator; respect shared fragment/size caps
-- [ ] Design check: screencaps of inline image, full-screen viewer, transfer progress
+**Files & images (receive + display)**
-**Success criteria**: phone→watch image renders inline in both global chat and DM; SHA-256 of
-received file matches sender; screencap set approved. (Sending files from the watch is out of
-scope.)
+- [ ] Receive broadcast + Noise-encrypted private files (shared `BitchatFilePacket` TLV,
+ `FileUtils.saveIncomingFile`, `messageTypeForMime` — already wired via shared `MessageHandler`)
+- [ ] Image messages render as compact inline thumbnails (rounded, fit-width); tap → full-screen
+ viewer (black surface, fit-to-screen, dismiss) — mirrors the phone's `ImageMessageItem` /
+ `FullScreenImageViewer`
+- [ ] Non-media files: compact chip (name + size)
+- [ ] mesh_lab: add `file_recv` to the wear test hook; enable `file` + `file_private` scenarios
+ for the watch
+
+**Voice notes (first-class)**
+
+- [ ] RECORD_AUDIO permission (manifest + just-in-time runtime request)
+- [ ] Push-to-talk recording: press-and-hold starts recording, release sends (phone UX constants:
+ 220 ms hold threshold, 10 s cap, ~80 ms amplitude polls); full-screen overlay that fades in
+ with a live waveform animation + elapsed time; shared `VoiceRecorder` (16 kHz mono AAC,
+ `audio/mp4`, `.m4a`)
+- [ ] Send as `BitchatFilePacket` broadcast in global chat (`MeshCore.sendFileBroadcast`); in a
+ DM thread send Noise-encrypted (`MeshCore.sendFilePrivate` with handshake/prep retry)
+- [ ] Received voice notes (`BitchatMessageType.Audio`, `content` = local path) render as a
+ voice-note bubble: play/pause + waveform (shared `Waveform.kt` extractor, 120 bins) +
+ duration; `MediaPlayer` playback
+
+**Input redesign (native Wear bottom actions)**
+
+- [ ] Replace the inline composer text field with two bottom action buttons docked via the
+ native Wear pattern (`ScreenScaffold` `edgeButton` slot + `ButtonGroup` — adapts to round and
+ square screens):
+ - keyboard button → full-screen text input screen (field auto-focused, IME opens; the Pixel
+ Watch Gboard provides built-in dictation there too)
+ - mic button → push-to-talk (press-and-hold record, release send) with the full-screen
+ waveform overlay
+- [ ] Remove the old inline `ChatComposer` row from chat/DM screens
+
+**Success criteria**: phone→watch image renders inline and full-screen; `mesh_lab.py scenario
+file` and `file_private` (phone→watch) green with SHA-256 match; push-to-talk voice note
+recorded on the watch arrives on the phone and plays (phone log/evidence), and a phone-sent
+voice note renders + plays on the watch; screencap design review of all new screens/overlays on
+the round display.
---
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4745b097..b20767c0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ appcompat = "1.7.1"
# Compose
compose-bom = "2026.06.01"
+compose-icons-extended = "1.7.8"
# Navigation
navigation-compose = "2.9.8"
@@ -88,7 +89,7 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
-androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
+androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" }
# Lifecycle
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py
index 163c9998..811e7f0f 100644
--- a/tools/release_gate/mesh_lab.py
+++ b/tools/release_gate/mesh_lab.py
@@ -64,6 +64,7 @@ WATCH_PERMISSIONS = [
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_ADVERTISE",
"android.permission.POST_NOTIFICATIONS",
+ "android.permission.RECORD_AUDIO",
]
@@ -230,7 +231,9 @@ class Device:
if isinstance(value, bool):
args += ["--ez", key, "true" if value else "false"]
elif isinstance(value, int):
- args += ["--el", key, str(value)]
+ # `am` stores --el as Long and --ei as Integer; on-device
+ # readers use getIntExtra, so int extras must go via --ei.
+ args += ["--ei", key, str(value)]
else:
args += ["--es", key, str(value)]
try:
@@ -654,8 +657,9 @@ SCENARIOS = {
"identity_reset": scenario_identity_reset,
}
-# Scenarios supported when device B is a watch (file transfer deferred on the watch).
-WATCH_SCENARIOS = ["dm", "broadcast", "raw", "session_recovery", "identity_reset"]
+# 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"]
def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
@@ -715,7 +719,8 @@ def build_parser() -> argparse.ArgumentParser:
raw = commands.add_parser("cmd", help="send a raw test-hook command to one device")
raw.add_argument("--serial", required=True)
raw.add_argument("cmd")
- raw.add_argument("--extra", action="append", default=[], help="key=value extra (repeatable)")
+ raw.add_argument("--extra", action="append", default=[], help="key=value string extra (repeatable)")
+ raw.add_argument("--extra-int", action="append", default=[], help="key=value int extra (repeatable)")
raw.add_argument("--timeout-ms", type=int, default=60_000)
return parser
@@ -747,10 +752,14 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps(evidence, indent=2, default=str))
return 0 if evidence["status"] == "pass" else 1
elif args.command == "cmd":
+ extras: dict[str, object] = {}
extras: dict[str, object] = {}
for item in args.extra:
key, _, value = item.partition("=")
- extras[key] = int(value) if value.isdigit() else value
+ extras[key] = value
+ for item in args.extra_int:
+ key, _, value = item.partition("=")
+ extras[key] = int(value)
result = Device(args.serial, "device").cmd(args.cmd, timeout_ms=args.timeout_ms, **extras)
print(json.dumps(result, indent=2, default=str))
return 0 if result.get("status") == "ok" else 1
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
index 75df1a45..16b73259 100644
--- a/wear/build.gradle.kts
+++ b/wear/build.gradle.kts
@@ -80,6 +80,7 @@ val sharedSourceIncludes = listOf(
"com/bitchat/android/nostr/Bech32.kt",
"com/bitchat/android/nostr/GeohashAliasRegistry.kt",
"com/bitchat/android/features/file/FileUtils.kt",
+ "com/bitchat/android/features/voice/**",
"com/bitchat/android/ui/debug/DebugSettingsManager.kt",
"com/bitchat/android/ui/debug/DebugPreferenceManager.kt",
"com/bitchat/android/util/AppConstants.kt",
diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt
index 484f9fe3..c43ef258 100644
--- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt
+++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt
@@ -50,6 +50,7 @@ object WearTestHookDriver {
"dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent)
"raw_send" -> rawSend(context, intent)
+ "file_recv" -> fileRecv(context, intent)
"state" -> state(context)
"clear_results" -> clearResults(context)
else -> err(cmd, "unknown command: $cmd")
@@ -280,6 +281,43 @@ object WearTestHookDriver {
.put("peer", peerID)
}
+ // MARK: - File transfer (receive only; the watch does not send files via test hook)
+
+ private suspend fun fileRecv(context: Context, intent: Intent): JSONObject {
+ val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L)
+ val nameContains = intent.getStringExtra("name_contains")
+ val startTime = System.currentTimeMillis()
+ val dirs = listOf(
+ File(context.cacheDir, "files/incoming"),
+ File(context.cacheDir, "images/incoming")
+ )
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (System.currentTimeMillis() < deadline) {
+ val candidate = dirs
+ .flatMap { it.listFiles()?.toList() ?: emptyList() }
+ .filter { it.lastModified() >= startTime - 5_000 }
+ .filter { nameContains == null || it.name.contains(nameContains) }
+ .maxByOrNull { it.lastModified() }
+ if (candidate != null) {
+ val size1 = candidate.length()
+ delay(500)
+ if (candidate.length() == size1 && size1 > 0) {
+ return ok("file_recv")
+ .put("path", candidate.absolutePath)
+ .put("name", candidate.name)
+ .put("bytes", size1)
+ .put(
+ "sha256",
+ java.security.MessageDigest.getInstance("SHA-256")
+ .digest(candidate.readBytes()).toHex()
+ )
+ }
+ }
+ delay(250)
+ }
+ return err("file_recv", "timeout after ${timeoutMs}ms")
+ }
+
// MARK: - State
private fun state(context: Context): JSONObject {
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index b4823e1e..9fc4d290 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -17,6 +17,7 @@
+
when (current) {
- is WearScreen.Chat -> ChatScreen(onOpenPeople = { navigate(WearScreen.People) })
+ is WearScreen.Chat -> ChatScreen(
+ onOpenPeople = { navigate(WearScreen.People) },
+ onOpenTextInput = { navigate(WearScreen.TextInput(null)) }
+ )
is WearScreen.People -> PeopleScreen(onOpenDm = { navigate(WearScreen.Dm(it)) })
- is WearScreen.Dm -> DmScreen(peerID = current.peerID)
+ is WearScreen.Dm -> DmScreen(
+ peerID = current.peerID,
+ onOpenTextInput = { navigate(WearScreen.TextInput(current.peerID)) }
+ )
+ is WearScreen.TextInput -> {
+ val mesh = WearMeshService.peek()
+ com.bitchat.watch.ui.TextInputScreen(
+ onSend = { text ->
+ mesh?.let { m ->
+ if (current.peerID == null) {
+ sendPublicMessage(m, text)
+ } else {
+ val nick = m.getPeerNickname(current.peerID) ?: current.peerID
+ sendPrivateMessage(m, current.peerID, nick, text)
+ }
+ }
+ goBack()
+ }
+ )
+ }
}
}
}
diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
index bb512cf6..5e151809 100644
--- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
+++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
@@ -95,6 +95,7 @@ class WearMeshService private constructor(private val context: Context) {
}
}
routed.peerID?.let { pid ->
+ maybeAutoHandshake(pid)
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
} catch (_: Exception) { }
@@ -201,6 +202,31 @@ class WearMeshService private constructor(private val context: Context) {
}
}
+ /**
+ * Proactively establish a Noise session with peers we have no session for (throttled to
+ * one attempt per peer per 60 s). Peers may hold a stale session after we restart — the
+ * protocol has no decrypt-failure kick path, so our fresh handshake replaces it and
+ * restores encrypted DM/file delivery.
+ */
+ private val handshakeAttempts = java.util.concurrent.ConcurrentHashMap()
+
+ private fun maybeAutoHandshake(peerID: String) {
+ if (peerID == myPeerID || hasEstablishedSession(peerID)) return
+ val now = System.currentTimeMillis()
+ val last = handshakeAttempts[peerID] ?: 0L
+ if (now - last < 60_000) return
+ handshakeAttempts[peerID] = now
+ serviceScope.launch {
+ delay(1_500)
+ if (!hasEstablishedSession(peerID)) {
+ try {
+ Log.d(TAG, "Auto-initiating Noise handshake with ${peerID.take(8)}")
+ initiateNoiseHandshake(peerID)
+ } catch (_: Exception) { }
+ }
+ }
+ }
+
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
try {
when {
@@ -298,6 +324,49 @@ class WearMeshService private constructor(private val context: Context) {
return connectionManager.connectToAddress(address)
}
+ fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
+ meshCore.sendFileBroadcast(file)
+ }
+
+ /**
+ * Noise-encrypted private file transfer with session/prep retry (mirrors the phone's
+ * dispatchFileSend): ensures an established session, then retries transient
+ * preparation states (AwaitingPeerState/NeedsHandshake) before giving up.
+ */
+ fun sendFilePrivateEncrypted(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
+ serviceScope.launch {
+ val sessionDeadline = System.currentTimeMillis() + 15_000
+ while (!hasEstablishedSession(recipientPeerID) && System.currentTimeMillis() < sessionDeadline) {
+ try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { }
+ delay(500)
+ }
+ val transferId = com.bitchat.android.mesh.MeshPacketUtils.sha256Hex(
+ file.encode() ?: return@launch
+ )
+ val prepDeadline = System.currentTimeMillis() + 30_000
+ while (System.currentTimeMillis() < prepDeadline) {
+ when (val prep = meshCore.prepareFilePrivate(recipientPeerID, file, transferId, allowLegacyFallback = false)) {
+ is com.bitchat.android.mesh.PrivateMediaPreparation.Ready -> {
+ prep.transfer.commit()
+ return@launch
+ }
+ com.bitchat.android.mesh.PrivateMediaPreparation.AwaitingPeerState,
+ com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake -> {
+ if (prep == com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake) {
+ try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { }
+ }
+ delay(500)
+ }
+ else -> {
+ Log.w(TAG, "private voice note preparation failed: $prep")
+ return@launch
+ }
+ }
+ }
+ Log.w(TAG, "private voice note preparation timed out")
+ }
+ }
+
fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID)
fun getPeerNicknames(): Map = meshCore.getPeerNicknames()
diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt
new file mode 100644
index 00000000..46fac58a
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt
@@ -0,0 +1,178 @@
+package com.bitchat.watch.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Keyboard
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.Text
+import com.bitchat.watch.ui.media.WaveformBars
+import com.bitchat.watch.ui.theme.BitchatMotion
+import com.bitchat.watch.ui.theme.ChatVisualTokens
+import com.bitchat.watch.ui.theme.LocalBitchatPalette
+
+/**
+ * Native Wear bottom action bar (designed for the ScreenScaffold `edgeButton` slot): a keyboard
+ * button that opens the text input screen, and a push-to-talk mic button — press and hold to
+ * record, release to send. Recording state lives in [VoiceNoteController], hoisted at screen
+ * level so [VoiceRecordOverlay] can render full-screen outside this slot.
+ */
+@Composable
+fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val palette = LocalBitchatPalette.current
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted -> if (granted) voice.start() }
+
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(38.dp)
+ .clip(CircleShape)
+ .background(palette.inputButton)
+ .clickable { onKeyboard() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Keyboard,
+ contentDescription = "type message",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Box(
+ modifier = Modifier
+ .size(38.dp)
+ .clip(CircleShape)
+ .background(
+ if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton
+ )
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ val granted = ContextCompat.checkSelfPermission(
+ context, Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ if (granted) {
+ voice.start()
+ } else {
+ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ tryAwaitRelease()
+ voice.stop(send = true)
+ }
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Mic,
+ contentDescription = "push to talk",
+ tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+}
+
+/**
+ * Full-screen push-to-talk overlay: fades in over the chat with a live waveform, elapsed time,
+ * and a release hint. Rendered as a sibling of the screen content (NOT inside the edgeButton
+ * slot, which would clip it to the slot bounds).
+ */
+@Composable
+fun VoiceRecordOverlay(voice: VoiceNoteController) {
+ val palette = LocalBitchatPalette.current
+ AnimatedVisibility(
+ visible = voice.recording,
+ enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
+ exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Mic,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(26.dp)
+ )
+ }
+ WaveformBars(
+ samples = voice.liveSamples,
+ progress = 1f,
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .padding(top = 16.dp)
+ .fillMaxWidth()
+ .height(44.dp)
+ )
+ Text(
+ text = "%d:%02d".format(
+ voice.elapsedMs / 1000 / 60,
+ voice.elapsedMs / 1000 % 60
+ ) + " / 0:10",
+ style = ChatVisualTokens.SenderStyle,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = 10.dp)
+ )
+ Text(
+ text = "release to send",
+ style = ChatVisualTokens.SystemActionStyle,
+ color = palette.textTertiary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ }
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
index 3a305dd3..a1b31a80 100644
--- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
+++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
@@ -1,29 +1,13 @@
package com.bitchat.watch.ui
-import android.app.Activity
-import android.content.Intent
-import android.speech.RecognizerIntent
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Send
-import androidx.compose.material.icons.filled.Mic
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -34,45 +18,48 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.items
-import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
-import androidx.wear.compose.material3.Icon
-import androidx.wear.compose.material3.IconButton
+import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.bitchat.android.model.BitchatMessage
-import com.bitchat.android.model.DeliveryStatus
+import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
+import com.bitchat.watch.ui.media.FileMessageChip
+import com.bitchat.watch.ui.media.FullScreenImageViewer
+import com.bitchat.watch.ui.media.ImageMessageItem
+import com.bitchat.watch.ui.media.VoiceNoteItem
+import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
+import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
-fun ChatScreen(onOpenPeople: () -> Unit) {
+fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val messages by AppStateStore.publicMessages.collectAsState()
val peers by AppStateStore.peers.collectAsState()
val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
- val listState = rememberScalingLazyListState()
+ val listState = androidx.compose.foundation.lazy.rememberLazyListState()
val palette = LocalBitchatPalette.current
val haptics = LocalHapticFeedback.current
+ var viewerPath by remember { mutableStateOf(null) }
+ val voice = rememberVoiceNoteController { path ->
+ mesh?.let { sendVoiceNote(it, null, path) }
+ }
var previousCount by remember { mutableStateOf(messages.size) }
LaunchedEffect(messages.size) {
@@ -81,28 +68,35 @@ fun ChatScreen(onOpenPeople: () -> Unit) {
if (last != null && last.senderPeerID != myPeerID) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
- // Keep the newest message visible (header is index 0, messages follow)
+ // Keep the newest message visible (index 0 in reverse layout)
if (messages.isNotEmpty()) {
- listState.animateScrollToItem(messages.size)
+ listState.animateScrollToItem(0)
}
}
previousCount = messages.size
}
- ScreenScaffold(scrollState = listState) {
- // 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(
+ androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
+ ScreenScaffold(scrollState = listState) {
+ // Bottom padding keeps the last message just above the action bar, so messages
+ // scroll all the way down to the buttons.
+ // LazyColumn + reverseLayout: newest message anchors at the bottom above the action
+ // bar; empty space collects at the top (ScalingLazyColumn center-anchors short
+ // content, which left an awkward gap above the buttons).
+ androidx.compose.foundation.lazy.LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp)
+ reverseLayout = true,
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
+ top = 40.dp,
+ bottom = 56.dp
+ )
) {
- item {
- ChatHeader(
- peerCount = peers.size,
- unreadDms = unreadDms.values.sum(),
- onOpenPeople = onOpenPeople
+ items(messages.asReversed(), key = { it.id }) { message ->
+ MessageItem(
+ message = message,
+ myPeerID = myPeerID,
+ onOpenImage = { viewerPath = it }
)
}
if (messages.isEmpty()) {
@@ -118,17 +112,31 @@ fun ChatScreen(onOpenPeople: () -> Unit) {
)
}
}
- items(messages, key = { it.id }) { message ->
- MessageItem(message = message, myPeerID = myPeerID)
+ item {
+ ChatHeader(
+ peerCount = peers.size,
+ unreadDms = unreadDms.values.sum(),
+ onOpenPeople = onOpenPeople
+ )
}
}
- ChatComposer(
- onSend = { text ->
- mesh?.let { sendPublicMessage(it, text) }
- },
- modifier = Modifier.align(Alignment.BottomCenter)
- )
}
+
+ // Always-visible action bar (the framework's edgeButton slot auto-hides on scroll,
+ // which would make push-to-talk unreachable mid-conversation).
+ ChatActionBar(
+ onKeyboard = onOpenTextInput,
+ voice = voice,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 10.dp)
+ )
+
+ VoiceRecordOverlay(voice)
+ }
+
+ viewerPath?.let { path ->
+ FullScreenImageViewer(path = path, onClose = { viewerPath = null })
}
}
@@ -165,7 +173,11 @@ private fun ChatHeader(peerCount: Int, unreadDms: Int, onOpenPeople: () -> Unit)
}
@Composable
-fun MessageItem(message: BitchatMessage, myPeerID: String) {
+fun MessageItem(
+ message: BitchatMessage,
+ myPeerID: String,
+ onOpenImage: (String) -> Unit = {}
+) {
val palette = LocalBitchatPalette.current
val isSelf = message.senderPeerID == myPeerID
val senderColor = when {
@@ -178,16 +190,12 @@ fun MessageItem(message: BitchatMessage, myPeerID: String) {
LaunchedEffect(message.id) { appeared = true }
val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (appeared) 1f else 0f,
- animationSpec = androidx.compose.animation.core.tween(
- com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS
- ),
+ animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS),
label = "msgAlpha"
)
val offset by androidx.compose.animation.core.animateDpAsState(
targetValue = if (appeared) 0.dp else 6.dp,
- animationSpec = androidx.compose.animation.core.tween(
- com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS
- ),
+ animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS),
label = "msgOffset"
)
@@ -214,129 +222,25 @@ fun MessageItem(message: BitchatMessage, myPeerID: String) {
color = palette.textTertiary
)
}
- Text(
- text = message.content,
- style = ChatVisualTokens.MessageBodyStyle,
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(top = 1.dp)
- )
- }
-}
-
-@Composable
-fun ChatComposer(onSend: (String) -> Unit, modifier: Modifier = Modifier) {
- val palette = LocalBitchatPalette.current
- var text by remember { mutableStateOf("") }
-
- val dictationLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.StartActivityForResult()
- ) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- val spoken = result.data
- ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
- ?.firstOrNull()
- if (!spoken.isNullOrBlank()) {
- onSend(spoken.trim())
- text = ""
- }
- }
- }
-
- fun send() {
- val trimmed = text.trim()
- if (trimmed.isNotEmpty()) {
- onSend(trimmed)
- text = ""
- }
- }
-
- Row(
- modifier = modifier
- .fillMaxWidth()
- .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
- ),
- cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
- keyboardActions = KeyboardActions(onSend = { send() }),
- modifier = Modifier
- .weight(1f)
- .clip(RoundedCornerShape(18.dp))
- .background(palette.inputSurface)
- .padding(horizontal = 12.dp, vertical = 8.dp),
- decorationBox = { innerTextField ->
- Box {
- if (text.isEmpty()) {
- Text(
- text = "message",
- style = ChatVisualTokens.MessageBodyStyle,
- color = palette.textTertiary
- )
- }
- innerTextField()
- }
- }
- )
- IconButton(
- onClick = {
- dictationLauncher.launch(
- Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
- putExtra(
- RecognizerIntent.EXTRA_LANGUAGE_MODEL,
- RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
- )
- putExtra(RecognizerIntent.EXTRA_PROMPT, "speak your message")
- }
- )
- },
- modifier = Modifier
- .padding(start = 4.dp)
- .size(34.dp)
- ) {
- Icon(
- imageVector = Icons.Filled.Mic,
- contentDescription = "dictate",
- tint = MaterialTheme.colorScheme.primary
+ when (message.type) {
+ BitchatMessageType.Image -> ImageMessageItem(
+ path = message.content.trim(),
+ onOpen = onOpenImage
)
- }
- IconButton(
- onClick = { send() },
- enabled = text.isNotBlank(),
- modifier = Modifier
- .padding(start = 2.dp)
- .size(34.dp)
- .clip(CircleShape)
- ) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.Send,
- contentDescription = "send",
- tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary
- else palette.textTertiary
+ BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim())
+ BitchatMessageType.File -> FileMessageChip(
+ name = File(message.content.trim()).name,
+ sizeBytes = File(message.content.trim()).length()
+ )
+ BitchatMessageType.Message -> Text(
+ text = message.content,
+ style = ChatVisualTokens.MessageBodyStyle,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = 1.dp)
)
}
}
}
-private fun sendPublicMessage(mesh: WearMeshService, content: String) {
- mesh.sendMessage(content)
- AppStateStore.addPublicMessage(
- BitchatMessage(
- sender = mesh.nickname,
- content = content,
- timestamp = Date(),
- senderPeerID = mesh.myPeerID,
- deliveryStatus = DeliveryStatus.Sent
- )
- )
-}
-
private fun formatTime(date: Date): String =
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
index b1f21a31..72c3b315 100644
--- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
+++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
@@ -1,11 +1,10 @@
package com.bitchat.watch.ui
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
+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.Arrangement
-import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -21,30 +20,30 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
-import com.bitchat.android.model.BitchatMessage
-import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
+import com.bitchat.watch.ui.media.FullScreenImageViewer
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
-import java.util.Date
@Composable
-fun DmScreen(peerID: String) {
+fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
val privateMessages by AppStateStore.privateMessages.collectAsState()
val messages = privateMessages[peerID] ?: emptyList()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
val palette = LocalBitchatPalette.current
- val listState = rememberScalingLazyListState()
+ val listState = androidx.compose.foundation.lazy.rememberLazyListState()
val haptics = LocalHapticFeedback.current
+ var viewerPath by remember { mutableStateOf(null) }
+ val voice = rememberVoiceNoteController { path ->
+ mesh?.let { sendVoiceNote(it, peerID, path) }
+ }
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
var sessionEstablished by remember {
@@ -73,17 +72,45 @@ fun DmScreen(peerID: String) {
if (last != null && last.senderPeerID != myPeerID) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
+ if (messages.isNotEmpty()) {
+ listState.animateScrollToItem(0)
+ }
}
previousCount = messages.size
}
- ScreenScaffold(scrollState = listState) {
- Box(modifier = Modifier.fillMaxSize()) {
- ScalingLazyColumn(
+ androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
+ ScreenScaffold(scrollState = listState) {
+ androidx.compose.foundation.lazy.LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp)
+ reverseLayout = true,
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
+ top = 40.dp,
+ bottom = 56.dp
+ )
) {
+ items(messages.asReversed(), key = { it.id }) { message ->
+ MessageItem(
+ message = message,
+ myPeerID = myPeerID,
+ onOpenImage = { viewerPath = it }
+ )
+ }
+ 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)
+ )
+ }
+ }
item {
Row(
modifier = Modifier
@@ -106,51 +133,21 @@ fun DmScreen(peerID: String) {
)
}
}
- 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)
- }
}
- ChatComposer(
- onSend = { text ->
- mesh?.let { sendPrivateMessage(it, peerID, nickname, text) }
- },
- modifier = Modifier.align(Alignment.BottomCenter)
- )
}
+
+ ChatActionBar(
+ onKeyboard = onOpenTextInput,
+ voice = voice,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 10.dp)
+ )
+
+ VoiceRecordOverlay(voice)
+ }
+
+ viewerPath?.let { path ->
+ FullScreenImageViewer(path = path, onClose = { viewerPath = null })
}
}
-
-private fun sendPrivateMessage(
- mesh: WearMeshService,
- peerID: String,
- recipientNickname: String,
- content: String
-) {
- mesh.sendPrivateMessage(content, peerID, recipientNickname)
- AppStateStore.addPrivateMessage(
- peerID,
- BitchatMessage(
- sender = mesh.nickname,
- content = content,
- timestamp = Date(),
- isPrivate = true,
- recipientNickname = recipientNickname,
- senderPeerID = mesh.myPeerID,
- deliveryStatus = DeliveryStatus.Sent
- )
- )
-}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt
new file mode 100644
index 00000000..07820f7a
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt
@@ -0,0 +1,78 @@
+package com.bitchat.watch.ui
+
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.BitchatMessageType
+import com.bitchat.android.model.DeliveryStatus
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.watch.mesh.WearMeshService
+import java.io.File
+import java.util.Date
+
+internal fun sendPublicMessage(mesh: WearMeshService, content: String) {
+ mesh.sendMessage(content)
+ AppStateStore.addPublicMessage(
+ BitchatMessage(
+ sender = mesh.nickname,
+ content = content,
+ timestamp = Date(),
+ senderPeerID = mesh.myPeerID,
+ deliveryStatus = DeliveryStatus.Sent
+ )
+ )
+}
+
+internal fun sendPrivateMessage(
+ mesh: WearMeshService,
+ peerID: String,
+ recipientNickname: String,
+ content: String
+) {
+ mesh.sendPrivateMessage(content, peerID, recipientNickname)
+ AppStateStore.addPrivateMessage(
+ peerID,
+ BitchatMessage(
+ sender = mesh.nickname,
+ content = content,
+ timestamp = Date(),
+ isPrivate = true,
+ recipientNickname = recipientNickname,
+ senderPeerID = mesh.myPeerID,
+ deliveryStatus = DeliveryStatus.Sent
+ )
+ )
+}
+
+/**
+ * Send a recorded voice note. Global chat: broadcast file packet. DM thread: Noise-encrypted
+ * private file transfer. Local echo renders immediately (content = local path, type = Audio).
+ */
+internal fun sendVoiceNote(mesh: WearMeshService, peerID: String?, path: String) {
+ val file = File(path)
+ if (!file.isFile) return
+ val packet = BitchatFilePacket(
+ fileName = file.name,
+ fileSize = file.length(),
+ mimeType = "audio/mp4",
+ content = file.readBytes()
+ )
+ if (peerID == null) {
+ mesh.sendFileBroadcast(packet)
+ } else {
+ mesh.sendFilePrivateEncrypted(peerID, packet)
+ }
+ val echo = BitchatMessage(
+ sender = mesh.nickname,
+ content = path,
+ type = BitchatMessageType.Audio,
+ timestamp = Date(),
+ isPrivate = peerID != null,
+ senderPeerID = mesh.myPeerID,
+ deliveryStatus = DeliveryStatus.Sent
+ )
+ if (peerID == null) {
+ AppStateStore.addPublicMessage(echo)
+ } else {
+ AppStateStore.addPrivateMessage(peerID, echo)
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt
new file mode 100644
index 00000000..9fb2cb14
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt
@@ -0,0 +1,156 @@
+package com.bitchat.watch.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.speech.RecognizerIntent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.IconButton
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.Text
+import com.bitchat.watch.ui.theme.ChatVisualTokens
+import com.bitchat.watch.ui.theme.LocalBitchatPalette
+
+/**
+ * Full-screen text input: field auto-focused so the watch IME (with its built-in dictation)
+ * opens immediately, plus a dedicated dictation button using the system speech recognizer.
+ */
+@Composable
+fun TextInputScreen(onSend: (String) -> Unit) {
+ val palette = LocalBitchatPalette.current
+ var text by remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
+
+ val dictationLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val spoken = result.data
+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
+ ?.firstOrNull()
+ if (!spoken.isNullOrBlank()) {
+ onSend(spoken.trim())
+ }
+ }
+ }
+
+ fun send() {
+ val trimmed = text.trim()
+ if (trimmed.isNotEmpty()) {
+ keyboardController?.hide()
+ onSend(trimmed)
+ text = ""
+ }
+ }
+
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ BasicTextField(
+ value = text,
+ onValueChange = { text = it },
+ singleLine = true,
+ textStyle = ChatVisualTokens.MessageBodyStyle.copy(
+ color = MaterialTheme.colorScheme.onSurface
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = { send() }),
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .clip(RoundedCornerShape(18.dp))
+ .background(palette.inputSurface)
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ decorationBox = { innerTextField ->
+ Box {
+ if (text.isEmpty()) {
+ Text(
+ text = "message",
+ style = ChatVisualTokens.MessageBodyStyle,
+ color = palette.textTertiary
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 10.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = {
+ dictationLauncher.launch(
+ Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+ putExtra(RecognizerIntent.EXTRA_PROMPT, "speak your message")
+ }
+ )
+ },
+ modifier = Modifier.size(38.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Mic,
+ contentDescription = "dictate",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ IconButton(
+ onClick = { send() },
+ enabled = text.isNotBlank(),
+ modifier = Modifier.size(38.dp)
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = "send",
+ tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary
+ else palette.textTertiary
+ )
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt
new file mode 100644
index 00000000..a901caea
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt
@@ -0,0 +1,88 @@
+package com.bitchat.watch.ui
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import com.bitchat.android.features.voice.VoiceRecorder
+import com.bitchat.android.features.voice.normalizeAmplitudeSample
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private const val MAX_RECORDING_MS = 10_000L
+private const val MIN_RECORDING_MS = 600L
+private const val AMPLITUDE_POLL_MS = 80L
+private const val LIVE_BARS = 32
+
+/**
+ * Push-to-talk recording controller: start on press, stop on release, 10 s cap, ~80 ms
+ * amplitude polls into a rolling live-waveform buffer. Hoisted to screen level so the
+ * full-screen overlay can render outside the edge-button slot.
+ */
+class VoiceNoteController(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val onSendVoice: (String) -> Unit
+) {
+ private val recorder = VoiceRecorder(context.applicationContext)
+
+ var recording by mutableStateOf(false)
+ private set
+ var elapsedMs by mutableLongStateOf(0L)
+ private set
+ var liveSamples by mutableStateOf(FloatArray(LIVE_BARS))
+ private set
+
+ private var pollJob: Job? = null
+ private var startedAt = 0L
+
+ fun start() {
+ if (recording) return
+ recorder.start() ?: return
+ startedAt = System.currentTimeMillis()
+ elapsedMs = 0L
+ liveSamples = FloatArray(LIVE_BARS)
+ recording = true
+ pollJob = scope.launch {
+ while (true) {
+ delay(AMPLITUDE_POLL_MS)
+ val amp = normalizeAmplitudeSample(recorder.pollAmplitude())
+ liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp
+ val elapsed = System.currentTimeMillis() - startedAt
+ elapsedMs = elapsed
+ if (elapsed >= MAX_RECORDING_MS) {
+ stop(send = true)
+ break
+ }
+ }
+ }
+ }
+
+ fun stop(send: Boolean) {
+ if (!recording) return
+ recording = false
+ pollJob?.cancel()
+ pollJob = null
+ val file = recorder.stop()
+ val elapsed = System.currentTimeMillis() - startedAt
+ if (send && file != null && elapsed >= MIN_RECORDING_MS) {
+ onSendVoice(file.absolutePath)
+ } else {
+ file?.delete()
+ }
+ }
+}
+
+@Composable
+fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ return remember { VoiceNoteController(context, scope, onSendVoice) }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt
new file mode 100644
index 00000000..c2fbf983
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt
@@ -0,0 +1,306 @@
+package com.bitchat.watch.ui.media
+
+import android.graphics.BitmapFactory
+import android.media.MediaPlayer
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.Text
+import com.bitchat.android.features.voice.AudioWaveformExtractor
+import com.bitchat.android.features.voice.VoiceWaveformCache
+import com.bitchat.watch.ui.theme.ChatVisualTokens
+import com.bitchat.watch.ui.theme.LocalBitchatPalette
+import kotlinx.coroutines.delay
+import java.io.File
+
+/**
+ * Compact inline image thumbnail; tap opens the full-screen viewer.
+ */
+@Composable
+fun ImageMessageItem(path: String, onOpen: (String) -> Unit) {
+ val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
+ if (bitmap == null) {
+ FileMessageChip(name = File(path).name, sizeBytes = File(path).length())
+ return
+ }
+ Image(
+ painter = BitmapPainter(bitmap.asImageBitmap()),
+ contentDescription = "image",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .widthIn(max = 120.dp)
+ .aspectRatio(
+ (bitmap.width.toFloat() / bitmap.height.toFloat()).coerceIn(0.6f, 1.8f)
+ )
+ .clip(RoundedCornerShape(10.dp))
+ .clickable { onOpen(path) }
+ )
+}
+
+/**
+ * Full-screen image viewer (mirrors the phone's FullScreenImageViewer): black surface,
+ * fit-to-screen, tap or swipe-back to dismiss.
+ */
+@Composable
+fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
+ Dialog(
+ onDismissRequest = onClose,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .clickable { onClose() },
+ contentAlignment = Alignment.Center
+ ) {
+ val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
+ if (bitmap != null) {
+ Image(
+ painter = BitmapPainter(bitmap.asImageBitmap()),
+ contentDescription = "image fullscreen",
+ contentScale = ContentScale.Fit,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = "close",
+ tint = Color.White.copy(alpha = 0.7f),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 24.dp)
+ .size(20.dp)
+ )
+ }
+ }
+}
+
+/**
+ * Voice-note bubble: play/pause + waveform (120 bins, extracted locally like the phone) +
+ * duration/progress. Playback via MediaPlayer.
+ */
+@Composable
+fun VoiceNoteItem(path: String) {
+ val palette = LocalBitchatPalette.current
+ var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) }
+ var playing by remember { mutableStateOf(false) }
+ var progress by remember { mutableFloatStateOf(0f) }
+ var durationMs by remember { mutableIntStateOf(0) }
+ val player = remember { MediaPlayer() }
+
+ LaunchedEffect(path) {
+ if (samples == null) {
+ AudioWaveformExtractor.extractAsync(path) { extracted ->
+ if (extracted != null) {
+ VoiceWaveformCache.put(path, extracted)
+ samples = extracted
+ }
+ }
+ }
+ }
+
+ DisposableEffect(path) {
+ runCatching {
+ player.reset()
+ player.setDataSource(path)
+ player.setOnCompletionListener {
+ playing = false
+ progress = 0f
+ }
+ player.prepare()
+ durationMs = player.duration
+ }
+ onDispose {
+ runCatching { if (player.isPlaying) player.stop() }
+ runCatching { player.release() }
+ }
+ }
+
+ LaunchedEffect(playing) {
+ while (playing) {
+ progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f
+ delay(100)
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(palette.inputSurface)
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(26.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ .clickable {
+ if (playing) {
+ player.pause()
+ playing = false
+ } else {
+ runCatching { player.start() }
+ playing = true
+ }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ if (playing) {
+ Canvas(modifier = Modifier.size(10.dp)) {
+ val w = size.width
+ val h = size.height
+ drawRoundRect(
+ color = Color.Black,
+ topLeft = Offset(0f, 0f),
+ size = Size(w * 0.35f, h),
+ cornerRadius = CornerRadius(1.dp.toPx())
+ )
+ drawRoundRect(
+ color = Color.Black,
+ topLeft = Offset(w * 0.65f, 0f),
+ size = Size(w * 0.35f, h),
+ cornerRadius = CornerRadius(1.dp.toPx())
+ )
+ }
+ } else {
+ Icon(
+ imageVector = Icons.Filled.PlayArrow,
+ contentDescription = "play",
+ tint = Color.Black,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ WaveformBars(
+ samples = samples,
+ progress = progress,
+ modifier = Modifier
+ .padding(start = 6.dp)
+ .weight(1f)
+ .height(22.dp),
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = palette.textTertiary.copy(alpha = 0.5f)
+ )
+ Text(
+ text = formatDuration(if (playing) (durationMs * progress).toInt() else durationMs),
+ style = ChatVisualTokens.SystemActionStyle,
+ color = palette.textTertiary,
+ modifier = Modifier.padding(start = 6.dp)
+ )
+ }
+}
+
+@Composable
+fun WaveformBars(
+ samples: FloatArray?,
+ progress: Float,
+ modifier: Modifier = Modifier,
+ activeColor: Color,
+ inactiveColor: Color
+) {
+ Canvas(modifier = modifier) {
+ val bars = 32
+ val values = samples?.let { com.bitchat.android.features.voice.resampleWave(it, bars) }
+ ?: FloatArray(bars) { 0.3f }
+ val barWidth = size.width / (bars * 2 - 1)
+ for (i in 0 until bars) {
+ val v = values.getOrElse(i) { 0f }.coerceIn(0.08f, 1f)
+ val barHeight = size.height * v
+ val x = i * barWidth * 2
+ drawRoundRect(
+ color = if (i.toFloat() / bars <= progress) activeColor else inactiveColor,
+ topLeft = Offset(x, (size.height - barHeight) / 2f),
+ size = Size(barWidth, barHeight),
+ cornerRadius = CornerRadius(barWidth / 2f)
+ )
+ }
+ }
+}
+
+/**
+ * Compact chip for non-media files.
+ */
+@Composable
+fun FileMessageChip(name: String, sizeBytes: Long) {
+ val palette = LocalBitchatPalette.current
+ Row(
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(palette.inputSurface)
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = name,
+ style = ChatVisualTokens.SystemActionStyle,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = formatSize(sizeBytes),
+ style = ChatVisualTokens.SystemActionStyle,
+ color = palette.textTertiary
+ )
+ }
+ }
+}
+
+private fun formatDuration(ms: Int): String {
+ val totalSeconds = (ms / 1000).coerceAtLeast(0)
+ return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
+}
+
+private fun formatSize(bytes: Long): String = when {
+ bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576f)
+ bytes >= 1024 -> "%.1f KB".format(bytes / 1024f)
+ else -> "$bytes B"
+}