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