watch works

This commit is contained in:
callebtc 2026-07-28 20:55:15 +02:00
parent bd3bb8c774
commit 71c257669a
15 changed files with 1134 additions and 254 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.VIBRATE" />
<application

View File

@ -44,12 +44,15 @@ import com.bitchat.watch.ui.DmScreen
import com.bitchat.watch.ui.NicknameSetupScreen
import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.sendPrivateMessage
import com.bitchat.watch.ui.sendPublicMessage
import com.bitchat.watch.ui.theme.BitchatWearTheme
sealed interface WearScreen {
data object Chat : WearScreen
data object People : WearScreen
data class Dm(val peerID: String) : WearScreen
data class TextInput(val peerID: String?) : WearScreen
}
class MainActivity : ComponentActivity() {
@ -149,9 +152,31 @@ fun WearNavHost() {
label = "screenTransition"
) { current ->
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()
}
)
}
}
}
}

View File

@ -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<String, Long>()
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<String, String> = meshCore.getPeerNicknames()

View File

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

View File

@ -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<String?>(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)

View File

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

View File

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

View File

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

View File

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

View File

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