From 43a88808a50e5268dd704c166c00b3accdff028c Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Tue, 28 Jul 2026 16:43:49 +0200
Subject: [PATCH] wear: M2/M3/M4/M6 code - BLE mesh service, chat/DM/people UI,
test hook, mesh_lab watch support (hardware verification pending)
---
docs/wear-os-implementation-plan.md | 8 +-
tools/release_gate/mesh_lab.py | 129 +++++--
wear/build.gradle.kts | 1 +
wear/src/debug/AndroidManifest.xml | 12 +
.../watch/testhook/WearTestHookDriver.kt | 341 ++++++++++++++++++
.../watch/testhook/WearTestHookReceiver.kt | 61 ++++
wear/src/main/AndroidManifest.xml | 6 +
.../bitchat/watch/BitchatWatchApplication.kt | 11 +
.../java/com/bitchat/watch/MainActivity.kt | 164 ++++++++-
.../com/bitchat/watch/mesh/WearMeshService.kt | 319 ++++++++++++++++
.../service/WearMeshForegroundService.kt | 78 ++++
.../java/com/bitchat/watch/ui/ChatScreen.kt | 300 +++++++++++++++
.../java/com/bitchat/watch/ui/DmScreen.kt | 153 ++++++++
.../bitchat/watch/ui/NicknameSetupScreen.kt | 100 +++++
.../com/bitchat/watch/ui/PeerDebugScreen.kt | 98 +++++
.../java/com/bitchat/watch/ui/PeopleScreen.kt | 134 +++++++
.../com/bitchat/watch/ui/WearChatState.kt | 32 ++
wear/src/main/res/values/strings.xml | 3 +
18 files changed, 1914 insertions(+), 36 deletions(-)
create mode 100644 wear/src/debug/AndroidManifest.xml
create mode 100644 wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt
create mode 100644 wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt
create mode 100644 wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt
diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md
index 458b6a8c..e878565f 100644
--- a/docs/wear-os-implementation-plan.md
+++ b/docs/wear-os-implementation-plan.md
@@ -8,11 +8,11 @@
|-----------|-------|--------|
| M0 | Scaffolding & plan document | done |
| M1 | Shared core compiles on Wear | done |
-| M2 | BLE transport & background service on watch | pending |
-| M3 | Global chat | pending |
-| M4 | Noise DMs & people screen | pending |
+| M2 | BLE transport & background service on watch | in-progress (code complete, hardware verification pending — watch off ADB) |
+| M3 | Global chat | in-progress (code complete, hardware verification pending) |
+| M4 | Noise DMs & people screen | in-progress (code complete, hardware verification pending) |
| M5 | File/image receive & display — **DEFERRED** (post-M7, later day) | deferred |
-| M6 | ADB test hook & mesh_lab interop | pending |
+| M6 | ADB test hook & mesh_lab interop | in-progress (code complete, interop run pending) |
| M7 | Polish & final design pass | pending |
---
diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py
index 821ce844..ddbe4656 100644
--- a/tools/release_gate/mesh_lab.py
+++ b/tools/release_gate/mesh_lab.py
@@ -44,6 +44,10 @@ RESULTS_DIR = "cache/testhook/results"
DEVICE_TMP_DIR = "/data/local/tmp/meshlab"
APP_FIXTURE_DIR = f"/data/data/{APPLICATION_ID}/cache/fixtures"
+WATCH_APPLICATION_ID = "com.bitchat.watch"
+WATCH_TEST_HOOK_ACTION = "com.bitchat.watch.TEST_HOOK"
+WATCH_TEST_HOOK_COMPONENT = f"{WATCH_APPLICATION_ID}/com.bitchat.watch.testhook.WearTestHookReceiver"
+
PERMISSIONS = [
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT",
@@ -55,6 +59,13 @@ PERMISSIONS = [
"android.permission.RECORD_AUDIO",
]
+WATCH_PERMISSIONS = [
+ "android.permission.BLUETOOTH_SCAN",
+ "android.permission.BLUETOOTH_CONNECT",
+ "android.permission.BLUETOOTH_ADVERTISE",
+ "android.permission.POST_NOTIFICATIONS",
+]
+
class MeshLabError(Exception):
pass
@@ -65,11 +76,23 @@ def _shell(serial: str, command: str) -> str:
class Device:
- """One ADB-connected phone running a debug build with the test hook."""
+ """One ADB-connected device running a debug build with the test hook."""
- def __init__(self, serial: str, alias: str):
+ def __init__(
+ self,
+ serial: str,
+ alias: str,
+ package: str = APPLICATION_ID,
+ hook_action: str = TEST_HOOK_ACTION,
+ hook_component: str = TEST_HOOK_COMPONENT,
+ permissions: list[str] = PERMISSIONS,
+ ):
self.serial = serial
self.alias = alias
+ self.package = package
+ self.hook_action = hook_action
+ self.hook_component = hook_component
+ self.permissions = permissions
# -- app lifecycle ------------------------------------------------------
@@ -82,23 +105,23 @@ class Device:
raise MeshLabError(f"[{self.alias}] install failed: {result.stdout} {result.stderr}")
def grant_permissions(self) -> None:
- for perm in PERMISSIONS:
+ for perm in self.permissions:
subprocess.run(
- [find_adb(), "-s", self.serial, "shell", "pm", "grant", APPLICATION_ID, perm],
+ [find_adb(), "-s", self.serial, "shell", "pm", "grant", self.package, perm],
check=False, capture_output=True, text=True, timeout=30,
)
def clear_app_data(self) -> None:
- _shell(self.serial, f"am force-stop {APPLICATION_ID}")
- output = _shell(self.serial, f"pm clear {APPLICATION_ID}")
+ _shell(self.serial, f"am force-stop {self.package}")
+ output = _shell(self.serial, f"pm clear {self.package}")
if "Success" not in output:
raise MeshLabError(f"[{self.alias}] pm clear failed: {output}")
def force_stop(self) -> None:
- _shell(self.serial, f"am force-stop {APPLICATION_ID}")
+ _shell(self.serial, f"am force-stop {self.package}")
def launch(self) -> None:
- _shell(self.serial, f"monkey -p {APPLICATION_ID} -c android.intent.category.LAUNCHER 1")
+ _shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1")
time.sleep(3)
def wake(self) -> None:
@@ -151,18 +174,19 @@ class Device:
)
if result.returncode != 0:
raise MeshLabError(f"[{self.alias}] push failed: {result.stderr}")
- target = f"{APP_FIXTURE_DIR}/{fname}"
+ fixture_dir = f"/data/data/{self.package}/cache/fixtures"
+ target = f"{fixture_dir}/{fname}"
_shell(
self.serial,
- f"run-as {APPLICATION_ID} mkdir -p {APP_FIXTURE_DIR} && "
- f"cat {tmp} | run-as {APPLICATION_ID} sh -c 'cat > {target}' && rm -f {tmp}",
+ f"run-as {self.package} mkdir -p {fixture_dir} && "
+ f"cat {tmp} | run-as {self.package} sh -c 'cat > {target}' && rm -f {tmp}",
)
return target
def clear_incoming(self) -> None:
_shell(
self.serial,
- f"run-as {APPLICATION_ID} rm -rf cache/files/incoming cache/images/incoming",
+ f"run-as {self.package} rm -rf cache/files/incoming cache/images/incoming",
)
# -- test hook commands -------------------------------------------------
@@ -170,11 +194,11 @@ class Device:
def cmd(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict:
"""Send a test-hook command and poll for its JSON result."""
cmd_id = uuid.uuid4().hex[:12]
- _shell(self.serial, f"run-as {APPLICATION_ID} rm -f {RESULTS_DIR}/{cmd_id}.json")
+ _shell(self.serial, f"run-as {self.package} rm -f {RESULTS_DIR}/{cmd_id}.json")
args = [
- "am", "broadcast", "-a", TEST_HOOK_ACTION,
- "-n", TEST_HOOK_COMPONENT,
+ "am", "broadcast", "-a", self.hook_action,
+ "-n", self.hook_component,
"--es", "cmd", cmd,
"--es", "id", cmd_id,
"--el", "timeout_ms", str(timeout_ms),
@@ -199,7 +223,7 @@ class Device:
deadline = time.monotonic() + (timeout_ms + 60_000) / 1000
while time.monotonic() < deadline:
try:
- raw = _shell(self.serial, f"run-as {APPLICATION_ID} cat {RESULTS_DIR}/{cmd_id}.json")
+ raw = _shell(self.serial, f"run-as {self.package} cat {RESULTS_DIR}/{cmd_id}.json")
if raw.strip().startswith("{"):
return json.loads(raw)
except Exception:
@@ -217,6 +241,30 @@ class Device:
return _shell(self.serial, f"logcat -d -t {lines}")
+class WatchDevice(Device):
+ """Pixel Watch running the com.bitchat.watch debug build.
+
+ Same test-hook protocol as the phone; different package/hook, a smaller permission
+ set (Bluetooth + notifications only), and wake tweaks that skip phone-only keyguard
+ commands. File-transfer scenarios are not supported on the watch yet (M5 deferred).
+ """
+
+ def __init__(self, serial: str, alias: str = "watch"):
+ super().__init__(
+ serial,
+ alias,
+ package=WATCH_APPLICATION_ID,
+ hook_action=WATCH_TEST_HOOK_ACTION,
+ hook_component=WATCH_TEST_HOOK_COMPONENT,
+ permissions=WATCH_PERMISSIONS,
+ )
+
+ def wake(self) -> None:
+ _shell(self.serial, "svc power stayon true")
+ _shell(self.serial, "settings put system screen_off_timeout 600000")
+ _shell(self.serial, "input keyevent KEYCODE_WAKEUP")
+
+
# MARK: - fixtures
FIXTURE_SIZES = {
@@ -243,8 +291,17 @@ def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = N
# MARK: - setup
-def setup_pair(a: Device, b: Device, apk: Path | None, nickname_a: str, nickname_b: str) -> None:
- for device, nickname in ((a, nickname_a), (b, nickname_b)):
+def setup_pair(
+ a: Device,
+ b: Device,
+ apk_a: Path | None,
+ nickname_a: str,
+ nickname_b: str,
+ apk_b: Path | None = None,
+) -> None:
+ if apk_b is None:
+ apk_b = apk_a
+ for device, nickname, apk in ((a, nickname_a, apk_a), (b, nickname_b, apk_b)):
device.reset_bluetooth()
device.enable_bluetooth()
if apk is not None:
@@ -558,13 +615,19 @@ 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"]
+
def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
started = time.time()
evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]}
try:
+ supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
if name == "all":
- evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in SCENARIOS}
+ evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in supported}
+ elif name not in supported:
+ raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
else:
evidence["results"] = SCENARIOS[name](a, b)
evidence["status"] = "pass"
@@ -587,15 +650,18 @@ def build_parser() -> argparse.ArgumentParser:
setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover")
setup.add_argument("--serial-a", required=True)
- setup.add_argument("--serial-b", required=True)
+ setup.add_argument("--serial-b")
+ setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
setup.add_argument("--apk", type=Path, default=None)
+ setup.add_argument("--watch-apk", type=Path, default=None)
setup.add_argument("--nickname-a", default="alice")
setup.add_argument("--nickname-b", default="bob")
scenario = commands.add_parser("scenario", help="run a test scenario on two devices")
scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"])
scenario.add_argument("--serial-a", required=True)
- scenario.add_argument("--serial-b", required=True)
+ scenario.add_argument("--serial-b")
+ scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
scenario.add_argument("--out", type=Path, default=None, help="evidence output directory")
raw = commands.add_parser("cmd", help="send a raw test-hook command to one device")
@@ -606,19 +672,30 @@ def build_parser() -> argparse.ArgumentParser:
return parser
+def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]:
+ """Device A is always the phone; device B is a watch when --serial-watch is given."""
+ a = Device(args.serial_a, "alpha")
+ if getattr(args, "serial_watch", None):
+ return a, WatchDevice(args.serial_watch)
+ if not getattr(args, "serial_b", None):
+ raise MeshLabError("either --serial-b or --serial-watch is required")
+ return a, Device(args.serial_b, "beta")
+
+
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "setup":
+ a, b = _resolve_devices(args)
+ nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b
setup_pair(
- Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"),
- args.apk, args.nickname_a, args.nickname_b,
+ a, b, args.apk, args.nickname_a, nickname_b,
+ apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
)
print(json.dumps({"status": "ok", "step": "setup"}))
elif args.command == "scenario":
- evidence = run_scenario(
- args.name, Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), args.out
- )
+ a, b = _resolve_devices(args)
+ evidence = run_scenario(args.name, a, b, args.out)
print(json.dumps(evidence, indent=2, default=str))
return 0 if evidence["status"] == "pass" else 1
elif args.command == "cmd":
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
index f2da0a8e..1ac19adc 100644
--- a/wear/build.gradle.kts
+++ b/wear/build.gradle.kts
@@ -158,6 +158,7 @@ dependencies {
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.material3)
implementation(libs.androidx.wear.tooling.preview)
+ implementation(libs.androidx.compose.material.icons.extended)
// Lifecycle
implementation(libs.bundles.lifecycle)
diff --git a/wear/src/debug/AndroidManifest.xml b/wear/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..20c0f07f
--- /dev/null
+++ b/wear/src/debug/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt
new file mode 100644
index 00000000..10c0350d
--- /dev/null
+++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt
@@ -0,0 +1,341 @@
+package com.bitchat.watch.testhook
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.noise.NoiseSession
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.service.TransportBridgeService
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.watch.mesh.WearMeshService
+import com.bitchat.watch.service.WearMeshForegroundService
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeoutOrNull
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * Headless engine behind [WearTestHookReceiver]. Drives [WearMeshService] and observes
+ * [AppStateStore] flows. Command set mirrors the phone's TestHookDriver (minus file transfer,
+ * which is deferred on the watch).
+ */
+object WearTestHookDriver {
+
+ private const val TAG = WearTestHookReceiver.TAG
+
+ private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L
+ private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
+ private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L
+ private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L
+
+ suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject {
+ Log.d(TAG, "execute cmd=$cmd")
+ val result = when (cmd) {
+ "ping" -> ok(cmd).put("pong", true).put("package", context.packageName)
+ "start" -> start(context)
+ "stop" -> stop(context)
+ "whoami" -> whoami(context)
+ "set_nickname" -> setNickname(context, intent.requiredString("name"))
+ "scan" -> scan(context, intent)
+ "peers" -> peers(context)
+ "connect" -> connect(intent.requiredString("peer"), intent)
+ "handshake" -> handshake(context, intent.requiredString("peer"), intent)
+ "session" -> session(context, intent.requiredString("peer"))
+ "announce" -> announce(context)
+ "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"))
+ "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
+ "dm_recv" -> dmRecv(context, intent)
+ "msg_recv" -> msgRecv(context, intent)
+ "raw_send" -> rawSend(context, intent)
+ "state" -> state(context)
+ "clear_results" -> clearResults(context)
+ else -> err(cmd, "unknown command: $cmd")
+ }
+ return result.put("cmd", cmd)
+ }
+
+ // MARK: - Lifecycle
+
+ private fun start(context: Context): JSONObject {
+ val mesh = mesh(context)
+ try {
+ context.startForegroundService(Intent(context, WearMeshForegroundService::class.java))
+ } catch (e: Exception) {
+ // Background FGS starts are restricted (API 31+); mesh_lab launches the app first,
+ // but fall back to a service-less mesh start so the command still works.
+ Log.w(TAG, "foreground service start failed, starting mesh directly: ${e.message}")
+ }
+ mesh.startServices()
+ return ok("start").put("peer_id", mesh.myPeerID)
+ }
+
+ private fun stop(context: Context): JSONObject {
+ try {
+ WearMeshService.peek()?.stopServices()
+ } catch (e: Exception) {
+ Log.w(TAG, "stopServices failed: ${e.message}")
+ }
+ context.stopService(Intent(context, WearMeshForegroundService::class.java))
+ return ok("stop")
+ }
+
+ // MARK: - Identity
+
+ private fun whoami(context: Context): JSONObject {
+ val mesh = mesh(context)
+ return ok("whoami")
+ .put("peer_id", mesh.myPeerID)
+ .put("identity_fingerprint", mesh.getIdentityFingerprint())
+ .put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex())
+ .put("nickname", mesh.nickname)
+ }
+
+ private fun setNickname(context: Context, name: String): JSONObject {
+ mesh(context).setNickname(name)
+ AppStateStore.setNickname(name)
+ return ok("set_nickname").put("nickname", name)
+ }
+
+ // MARK: - Discovery / connection
+
+ private suspend fun scan(context: Context, intent: Intent): JSONObject {
+ val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS)
+ val minPeers = intent.getIntExtra("min_peers", 1)
+ val mesh = mesh(context)
+ val found = withTimeoutOrNull(timeoutMs) {
+ AppStateStore.peers.first { it.size >= minPeers }
+ }
+ val peerIds = found ?: AppStateStore.peers.value
+ return ok("scan")
+ .put("reached_min_peers", found != null)
+ .put("peers", peerInfosJson(mesh, peerIds))
+ }
+
+ private fun peers(context: Context): JSONObject {
+ val mesh = mesh(context)
+ return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value))
+ }
+
+ private suspend fun connect(peerID: String, intent: Intent): JSONObject {
+ val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS)
+ val mesh = WearMeshService.peek() ?: return err("connect", "mesh service not running")
+ val address = mesh.getDeviceAddressForPeer(peerID)
+ ?: return err("connect", "no device address known for peer $peerID (scan first)")
+ val accepted = mesh.connectToPeer(peerID)
+ if (!accepted) return err("connect", "connectToAddress($address) rejected")
+ val direct = withTimeoutOrNull(timeoutMs) {
+ AppStateStore.directPeers.first { it.contains(peerID) }
+ }
+ return ok("connect")
+ .put("peer", peerID)
+ .put("address", address)
+ .put("direct", direct != null)
+ }
+
+ // MARK: - Noise
+
+ private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject {
+ val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS)
+ val mesh = mesh(context)
+ val deadline = System.currentTimeMillis() + timeoutMs
+ if (!mesh.hasEstablishedSession(peerID)) {
+ mesh.initiateNoiseHandshake(peerID)
+ }
+ var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized
+ while (System.currentTimeMillis() < deadline) {
+ lastState = mesh.getSessionState(peerID)
+ when (lastState) {
+ is NoiseSession.NoiseSessionState.Established -> {
+ return ok("handshake")
+ .put("peer", peerID)
+ .put("state", lastState.toString())
+ .put("fingerprint", mesh.getPeerFingerprint(peerID))
+ }
+ is NoiseSession.NoiseSessionState.Failed -> {
+ return err("handshake", "session failed: $lastState").put("peer", peerID)
+ }
+ else -> delay(100)
+ }
+ }
+ return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID)
+ }
+
+ private fun session(context: Context, peerID: String): JSONObject {
+ val mesh = mesh(context)
+ return ok("session")
+ .put("peer", peerID)
+ .put("state", mesh.getSessionState(peerID).toString())
+ .put("established", mesh.hasEstablishedSession(peerID))
+ .put("fingerprint", mesh.getPeerFingerprint(peerID))
+ }
+
+ // MARK: - Messaging
+
+ private fun announce(context: Context): JSONObject {
+ mesh(context).sendBroadcastAnnounce()
+ return ok("announce")
+ }
+
+ private fun broadcastMsg(context: Context, content: String): JSONObject {
+ mesh(context).sendChannelMessage(content, emptyList(), null)
+ return ok("broadcast_msg").put("content", content)
+ }
+
+ private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
+ val mesh = mesh(context)
+ val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
+ val id = msgID ?: "testhook-${System.currentTimeMillis()}"
+ mesh.sendPrivateMessageWithId(content, peerID, nickname, id)
+ return ok("dm_send").put("peer", peerID).put("msg_id", id)
+ }
+
+ private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
+ val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
+ val fromPeer = intent.getStringExtra("peer")
+ val contains = intent.getStringExtra("contains")
+ val startTime = System.currentTimeMillis()
+ val mesh = mesh(context)
+ val match = withTimeoutOrNull(timeoutMs) {
+ AppStateStore.privateMessages.first { conversations ->
+ conversations.values.flatten().any { msg ->
+ msg.timestamp.time >= startTime &&
+ msg.senderPeerID != mesh.myPeerID &&
+ (fromPeer == null || msg.senderPeerID == fromPeer) &&
+ (contains == null || msg.content.contains(contains))
+ }
+ }
+ } ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
+ val msg = match.values.flatten().first { msg ->
+ msg.timestamp.time >= startTime &&
+ msg.senderPeerID != mesh.myPeerID &&
+ (fromPeer == null || msg.senderPeerID == fromPeer) &&
+ (contains == null || msg.content.contains(contains))
+ }
+ return ok("dm_recv")
+ .put("from", msg.senderPeerID)
+ .put("sender", msg.sender)
+ .put("content", msg.content)
+ .put("msg_id", msg.id)
+ }
+
+ private suspend fun msgRecv(context: Context, intent: Intent): JSONObject {
+ val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
+ val contains = intent.getStringExtra("contains")
+ val startTime = System.currentTimeMillis()
+ val mesh = mesh(context)
+ val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
+ msg.timestamp.time >= startTime &&
+ msg.senderPeerID != mesh.myPeerID &&
+ (contains == null || msg.content.contains(contains))
+ }
+ val found = withTimeoutOrNull(timeoutMs) {
+ AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches)
+ } ?: return err("msg_recv", "timeout after ${timeoutMs}ms")
+ return ok("msg_recv")
+ .put("from", found.senderPeerID)
+ .put("sender", found.sender)
+ .put("content", found.content)
+ .put("msg_id", found.id)
+ }
+
+ // MARK: - Raw packet injection
+
+ private fun rawSend(context: Context, intent: Intent): JSONObject {
+ val payloadHex = intent.requiredString("payload_hex")
+ val typeStr = intent.requiredString("type")
+ val peerID = intent.getStringExtra("peer")
+ val ttl = intent.getIntExtra("ttl", 7)
+ val type = typeStr.toUIntOrNull(16)?.toUByte()
+ ?: return err("raw_send", "invalid type hex: $typeStr")
+ val payload = hexToBytes(payloadHex)
+ ?: return err("raw_send", "invalid payload_hex")
+ val mesh = mesh(context)
+ val packet = BitchatPacket(
+ type = type,
+ ttl = ttl.toUByte(),
+ senderID = mesh.myPeerID,
+ payload = payload
+ )
+ if (peerID != null) {
+ TransportBridgeService.sendToPeerFromLocal(peerID, packet)
+ } else {
+ TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
+ }
+ return ok("raw_send")
+ .put("type", typeStr)
+ .put("payload_bytes", payload.size)
+ .put("peer", peerID)
+ }
+
+ // MARK: - State
+
+ private fun state(context: Context): JSONObject {
+ val mesh = mesh(context)
+ val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
+ val sessions = JSONObject()
+ AppStateStore.peers.value.forEach { peerID ->
+ sessions.put(peerID, mesh.getSessionState(peerID).toString())
+ }
+ return ok("state")
+ .put("peer_id", mesh.myPeerID)
+ .put("nickname", mesh.nickname)
+ .put("peers", peersJson)
+ .put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList()))
+ .put("sessions", sessions)
+ .put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>))
+ .put("debug_status", mesh.getDebugStatus())
+ }
+
+ private fun clearResults(context: Context): JSONObject {
+ val dir = File(context.cacheDir, "testhook/results")
+ val count = dir.listFiles()?.count { it.delete() } ?: 0
+ return ok("clear_results").put("deleted", count)
+ }
+
+ // MARK: - Helpers
+
+ private fun mesh(context: Context): WearMeshService = WearMeshService.getOrCreate(context)
+
+ private fun peerInfosJson(mesh: WearMeshService, peerIds: List): JSONArray {
+ val nicknames = mesh.getPeerNicknames()
+ val rssi = mesh.getPeerRSSI()
+ val arr = JSONArray()
+ peerIds.forEach { id ->
+ val info = mesh.getPeerInfo(id)
+ arr.put(JSONObject()
+ .put("id", id)
+ .put("nickname", nicknames[id] ?: info?.nickname)
+ .put("rssi", rssi[id])
+ .put("direct", AppStateStore.directPeers.value.contains(id))
+ .put("connected", info?.isConnected)
+ .put("last_seen", info?.lastSeen)
+ .put("session", mesh.getSessionState(id).toString())
+ .put("fingerprint", mesh.getPeerFingerprint(id)))
+ }
+ return arr
+ }
+
+ private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd)
+ private fun err(cmd: String, message: String) =
+ JSONObject().put("status", "error").put("cmd", cmd).put("error", message)
+
+ private fun Intent.requiredString(name: String): String =
+ getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name")
+
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
+
+ private fun hexToBytes(hex: String): ByteArray? {
+ val clean = hex.replace(" ", "")
+ if (clean.length % 2 != 0) return null
+ return try {
+ ByteArray(clean.length / 2) { i ->
+ clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt
new file mode 100644
index 00000000..080185d0
--- /dev/null
+++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt
@@ -0,0 +1,61 @@
+package com.bitchat.watch.testhook
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * ADB-drivable test hook for the watch app (debug builds only). Mirrors the phone's protocol:
+ *
+ * adb shell am broadcast -a com.bitchat.watch.TEST_HOOK \
+ * --es cmd --es id [command extras...]
+ *
+ * Result is written to cache/testhook/results/.json (readable via run-as com.bitchat.watch)
+ * and logged under tag TestHook.
+ */
+class WearTestHookReceiver : BroadcastReceiver() {
+
+ companion object {
+ const val TAG = "TestHook"
+ const val ACTION = "com.bitchat.watch.TEST_HOOK"
+ private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION) return
+ val cmd = intent.getStringExtra("cmd") ?: "ping"
+ val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}"
+ val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS)
+
+ Log.i(TAG, "CMD id=$id cmd=$cmd")
+
+ val pendingResult = goAsync()
+ Thread {
+ val result = try {
+ runBlocking {
+ withTimeout(overallTimeout) {
+ WearTestHookDriver.execute(context.applicationContext, cmd, intent)
+ }
+ }
+ } catch (e: Exception) {
+ JSONObject()
+ .put("status", "error")
+ .put("cmd", cmd)
+ .put("error", "${e.javaClass.simpleName}: ${e.message}")
+ }
+ try {
+ val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() }
+ File(dir, "$id.json").writeText(result.toString())
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to write result file for $id: ${e.message}")
+ }
+ Log.i(TAG, "RESULT id=$id $result")
+ }.start()
+ pendingResult.finish()
+ }
+}
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 231bc058..06fdd4e4 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -20,6 +20,7 @@
+
+
PermissionRequestScreen(onGranted = { refreshState() })
+ !bluetoothEnabled -> BluetoothEnableScreen(onEnabled = { refreshState() })
+ !nicknameChosen -> NicknameSetupScreen(
+ initialNickname = WearMeshService.getOrCreate(applicationContext).nickname
+ ) { name ->
+ WearMeshService.getOrCreate(applicationContext).setNickname(name)
+ getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
+ .edit().putBoolean("nickname_chosen", true).apply()
+ nicknameChosen = true
+ }
+ else -> WearNavHost()
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshState()
+ }
+
+ private fun refreshState() {
+ hasPermissions = requiredPermissions().all {
+ ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
+ }
+ val adapter = getSystemService(BluetoothManager::class.java)?.adapter
+ bluetoothEnabled = adapter?.isEnabled == true
+ if (hasPermissions && bluetoothEnabled) {
+ startMeshService()
+ }
+ }
+
+ private fun startMeshService() {
+ val mesh = WearMeshService.getOrCreate(applicationContext)
+ mesh.onPrivateMessage = { message ->
+ message.senderPeerID?.let { WearChatState.onPrivateMessageArrived(it) }
+ }
+ startForegroundService(Intent(this, WearMeshForegroundService::class.java))
+ }
+
+ companion object {
+ fun requiredPermissions(): List = buildList {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ add(Manifest.permission.BLUETOOTH_SCAN)
+ add(Manifest.permission.BLUETOOTH_CONNECT)
+ add(Manifest.permission.BLUETOOTH_ADVERTISE)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@Composable
-fun PlaceholderScreen() {
+fun WearNavHost() {
+ var screen by remember { mutableStateOf(WearScreen.Chat) }
+ val backStack = remember { mutableStateListOf() }
+
+ fun navigate(to: WearScreen) {
+ backStack.add(screen)
+ screen = to
+ }
+
+ fun goBack(): Boolean {
+ val previous = backStack.removeLastOrNull()
+ return if (previous != null) {
+ screen = previous
+ true
+ } else false
+ }
+
+ BackHandler(enabled = backStack.isNotEmpty()) { goBack() }
+
+ when (val current = screen) {
+ is WearScreen.Chat -> ChatScreen(onOpenPeople = { navigate(WearScreen.People) })
+ is WearScreen.People -> PeopleScreen(onOpenDm = { navigate(WearScreen.Dm(it)) })
+ is WearScreen.Dm -> DmScreen(peerID = current.peerID)
+ }
+}
+
+@Composable
+fun PermissionRequestScreen(onGranted: () -> Unit) {
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { onGranted() }
+
Column(
modifier = Modifier
.fillMaxSize()
- .padding(24.dp),
+ .padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "bitchat",
- style = MaterialTheme.typography.titleLarge,
+ style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
- text = "mesh initializing",
+ text = "needs bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 8.dp)
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
)
+ Button(onClick = {
+ launcher.launch(MainActivity.requiredPermissions().toTypedArray())
+ }) {
+ Text("grant access")
+ }
+ }
+}
+
+@Composable
+fun BluetoothEnableScreen(onEnabled: () -> Unit) {
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { onEnabled() }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "bluetooth is off",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Button(
+ onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
+ modifier = Modifier.padding(top = 10.dp)
+ ) {
+ Text("turn on")
+ }
}
}
diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
new file mode 100644
index 00000000..1cf1bc47
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
@@ -0,0 +1,319 @@
+package com.bitchat.watch.mesh
+
+import android.bluetooth.BluetoothDevice
+import android.content.Context
+import android.util.Log
+import com.bitchat.android.crypto.EncryptionService
+import com.bitchat.android.mesh.BluetoothConnectionManager
+import com.bitchat.android.mesh.BluetoothConnectionManagerDelegate
+import com.bitchat.android.mesh.MeshCore
+import com.bitchat.android.mesh.MeshTransport
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.android.sync.GossipSyncManager
+import com.bitchat.android.util.AppConstants
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Watch mesh service: composes the shared BLE transport (BluetoothConnectionManager) with the
+ * shared mesh coordinator (MeshCore), mirroring how the phone's Wi-Fi Aware service is built.
+ * Bluetooth mesh only — no internet, no other transports.
+ */
+class WearMeshService private constructor(private val context: Context) {
+
+ companion object {
+ private const val TAG = "WearMeshService"
+ private val MAX_TTL: UByte = AppConstants.MESSAGE_TTL_HOPS
+ private val PEER_DISCONNECT_GRACE_MS: Long = AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS
+
+ @Volatile
+ private var instance: WearMeshService? = null
+
+ fun getOrCreate(context: Context): WearMeshService {
+ return instance ?: synchronized(this) {
+ instance ?: WearMeshService(context.applicationContext).also { instance = it }
+ }
+ }
+
+ fun peek(): WearMeshService? = instance
+ }
+
+ val encryptionService = EncryptionService(context)
+ val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ private val bleTransport = BleTransport()
+ private val meshCore: MeshCore
+ private val connectionManager: BluetoothConnectionManager
+
+ @Volatile
+ var nickname: String = loadNickname()
+ private set
+
+ @Volatile
+ private var isActive = false
+
+ /** UI hook fired for every incoming private message (after storing). */
+ var onPrivateMessage: ((com.bitchat.android.model.BitchatMessage) -> Unit)? = null
+
+ init {
+ meshCore = MeshCore(
+ context = context.applicationContext,
+ scope = serviceScope,
+ transport = bleTransport,
+ encryptionService = encryptionService,
+ myPeerID = myPeerID,
+ maxTtl = MAX_TTL,
+ sharedGossipManager = null,
+ gossipConfigProvider = object : GossipSyncManager.ConfigProvider {
+ override fun seenCapacity(): Int = 500
+ override fun gcsMaxBytes(): Int = 400
+ override fun gcsTargetFpr(): Double = 0.01
+ },
+ hooks = MeshCore.Hooks(
+ onMessageReceived = { message -> handleMessageReceived(message) },
+ onAnnounceProcessed = { routed, _ ->
+ routed.peerID?.let { pid ->
+ markDirectFromRelay(pid, routed.relayAddress)
+ try {
+ meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
+ } catch (_: Exception) { }
+ }
+ },
+ announcementNicknameProvider = { nickname },
+ leavePayloadProvider = { nickname.toByteArray(Charsets.UTF_8) }
+ )
+ )
+ connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager)
+ bleTransport.connectionManager = connectionManager
+ wireBluetoothDelegate()
+ }
+
+ private inner class BleTransport : MeshTransport {
+ lateinit var connectionManager: BluetoothConnectionManager
+
+ override val id: String = "BLE"
+
+ override fun broadcastPacket(routed: RoutedPacket): Boolean =
+ connectionManager.broadcastPacket(routed)
+
+ override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean =
+ connectionManager.sendPacketToPeer(peerID, packet)
+
+ override fun sendPacketToLink(
+ relayAddress: String,
+ ingressLinkID: String,
+ packet: BitchatPacket
+ ): Boolean = connectionManager.sendPacketToLink(relayAddress, ingressLinkID, packet)
+
+ override fun cancelTransfer(transferId: String): Boolean =
+ connectionManager.cancelTransfer(transferId)
+
+ override fun getDeviceAddressForPeer(peerID: String): String? =
+ connectionManager.addressPeerMap.entries.firstOrNull { it.value == peerID }?.key
+
+ override fun getDeviceAddressToPeerMapping(): Map =
+ connectionManager.addressPeerMap.toMap()
+
+ override fun getTransportDebugInfo(): String = connectionManager.getDebugInfo()
+ }
+
+ private fun wireBluetoothDelegate() {
+ connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
+ override fun onPacketReceived(
+ packet: BitchatPacket,
+ peerID: String,
+ device: BluetoothDevice?,
+ ingressLinkID: String
+ ) {
+ try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
+ packet = packet,
+ fromPeerID = peerID,
+ fromNickname = null,
+ fromDeviceAddress = device?.address,
+ myPeerID = myPeerID
+ )
+ } catch (_: Exception) { }
+ meshCore.processIncoming(packet, peerID, device?.address, ingressLinkID)
+ }
+
+ override fun onDeviceConnected(device: BluetoothDevice) {
+ Log.i(TAG, "Device connected: ${device.address}")
+ serviceScope.launch {
+ delay(200)
+ meshCore.sendBroadcastAnnounce()
+ }
+ }
+
+ override fun onDeviceDisconnected(
+ device: BluetoothDevice,
+ linkID: String?,
+ peerID: String?
+ ) {
+ Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)")
+ try { meshCore.refreshPeerList() } catch (_: Exception) { }
+ if (peerID != null) {
+ meshCore.setDirectConnection(peerID, false)
+ val deviceAddress = device.address
+ serviceScope.launch {
+ delay(PEER_DISCONNECT_GRACE_MS)
+ try {
+ val linkBack =
+ connectionManager.addressPeerMap.containsKey(deviceAddress) ||
+ connectionManager.addressPeerMap.containsValue(peerID)
+ if (!linkBack) {
+ Log.i(TAG, "Peer $peerID did not return after disconnect; removing")
+ meshCore.removePeer(peerID)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}")
+ }
+ }
+ }
+ }
+
+ override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
+ connectionManager.addressPeerMap[deviceAddress]?.let { peerID ->
+ meshCore.updatePeerRSSI(peerID, rssi)
+ }
+ }
+ }
+ }
+
+ private fun markDirectFromRelay(peerID: String, relayAddress: String?) {
+ if (relayAddress == null) return
+ try {
+ if (connectionManager.addressPeerMap[relayAddress] == peerID ||
+ connectionManager.addressPeerMap.containsValue(peerID)
+ ) {
+ meshCore.setDirectConnection(peerID, true)
+ }
+ } catch (_: Exception) { }
+ }
+
+ private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
+ try {
+ when {
+ message.isPrivate -> {
+ val peer = message.senderPeerID ?: return
+ AppStateStore.addPrivateMessage(peer, message)
+ try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
+ }
+ message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message)
+ else -> AppStateStore.addPublicMessage(message)
+ }
+ } catch (_: Exception) { }
+ }
+
+ fun startServices() {
+ if (isActive) {
+ Log.w(TAG, "Mesh already active, ignoring duplicate start")
+ return
+ }
+ val started = connectionManager.startServices()
+ if (started) {
+ isActive = true
+ meshCore.startCore()
+ serviceScope.launch {
+ delay(500)
+ meshCore.sendBroadcastAnnounce()
+ }
+ Log.i(TAG, "Mesh services started (peerID: $myPeerID)")
+ } else {
+ Log.e(TAG, "Failed to start Bluetooth services (permissions? BT off?)")
+ }
+ }
+
+ fun stopServices() {
+ if (!isActive) return
+ isActive = false
+ meshCore.stopCore()
+ connectionManager.stopServices()
+ Log.i(TAG, "Mesh services stopped")
+ }
+
+ fun isRunning(): Boolean = isActive
+
+ fun setNickname(name: String) {
+ val trimmed = name.trim().take(32)
+ if (trimmed.isEmpty() || trimmed == nickname) return
+ nickname = trimmed
+ saveNickname(trimmed)
+ if (isActive) {
+ serviceScope.launch { meshCore.sendBroadcastAnnounce() }
+ }
+ }
+
+ fun sendMessage(content: String, mentions: List = emptyList()) {
+ meshCore.sendMessage(content, mentions, null)
+ }
+
+ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String) {
+ meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname)
+ }
+
+ fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID)
+
+ fun hasEstablishedSession(peerID: String): Boolean = meshCore.hasEstablishedSession(peerID)
+
+ fun getSessionState(peerID: String) = meshCore.getSessionState(peerID)
+
+ fun getPeerInfo(peerID: String) = meshCore.getPeerInfo(peerID)
+
+ fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
+
+ fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey()
+
+ fun sendBroadcastAnnounce() = meshCore.sendBroadcastAnnounce()
+
+ fun sendChannelMessage(content: String, mentions: List = emptyList(), channel: String? = null) {
+ meshCore.sendMessage(content, mentions, channel)
+ }
+
+ fun sendPrivateMessageWithId(
+ content: String,
+ recipientPeerID: String,
+ recipientNickname: String,
+ messageID: String?
+ ) {
+ meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ }
+
+ fun getDeviceAddressForPeer(peerID: String): String? = meshCore.getDeviceAddressForPeer(peerID)
+
+ fun getDeviceAddressToPeerMapping(): Map = meshCore.getDeviceAddressToPeerMapping()
+
+ fun connectToPeer(peerID: String): Boolean {
+ val address = getDeviceAddressForPeer(peerID) ?: return false
+ return connectionManager.connectToAddress(address)
+ }
+
+ fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID)
+
+ fun getPeerNicknames(): Map = meshCore.getPeerNicknames()
+
+ fun getPeerRSSI(): Map = meshCore.getPeerRSSI()
+
+ fun getPeerNickname(peerID: String): String? = meshCore.getPeerNickname(peerID)
+
+ fun getDebugStatus(): String = meshCore.getDebugStatus(
+ transportInfo = connectionManager.getDebugInfo(),
+ deviceMap = connectionManager.addressPeerMap.toMap(),
+ title = "Wear BLE Mesh Debug Status"
+ )
+
+ private fun prefs() = context.getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
+
+ private fun loadNickname(): String =
+ prefs().getString("nickname", null) ?: "watch-${myPeerID.take(4)}"
+
+ private fun saveNickname(name: String) {
+ prefs().edit().putString("nickname", name).apply()
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt
new file mode 100644
index 00000000..855fea41
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt
@@ -0,0 +1,78 @@
+package com.bitchat.watch.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import com.bitchat.watch.MainActivity
+import com.bitchat.watch.R
+import com.bitchat.watch.mesh.WearMeshService
+
+/**
+ * Keeps the BLE mesh (scan + advertise + GATT) alive while the app is backgrounded or the watch
+ * goes ambient. Bluetooth mesh only; no internet connectivity is used or declared.
+ */
+class WearMeshForegroundService : Service() {
+
+ companion object {
+ const val CHANNEL_ID = "bitchat_mesh"
+ const val NOTIFICATION_ID = 1
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ createChannel()
+ startForeground()
+ WearMeshService.getOrCreate(applicationContext).startServices()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ WearMeshService.peek()?.stopServices()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun createChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.mesh_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.mesh_channel_description)
+ setShowBadge(false)
+ }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ private fun startForeground() {
+ val launchIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.mesh_notification_text))
+ .setContentIntent(launchIntent)
+ .setOngoing(true)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .build()
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
+ )
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
new file mode 100644
index 00000000..8d3c1b56
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
@@ -0,0 +1,300 @@
+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.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
+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.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.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.theme.ChatVisualTokens
+import com.bitchat.watch.ui.theme.LocalBitchatPalette
+import com.bitchat.watch.ui.theme.colorForPeer
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun ChatScreen(onOpenPeople: () -> Unit) {
+ val messages by AppStateStore.publicMessages.collectAsState()
+ val peers by AppStateStore.peers.collectAsState()
+ val mesh = WearMeshService.peek()
+ val myPeerID = mesh?.myPeerID ?: ""
+ val listState = rememberScalingLazyListState()
+ val palette = LocalBitchatPalette.current
+ val haptics = LocalHapticFeedback.current
+
+ var previousCount by remember { mutableStateOf(messages.size) }
+ LaunchedEffect(messages.size) {
+ if (messages.size > previousCount) {
+ val last = messages.lastOrNull()
+ if (last != null && last.senderPeerID != myPeerID) {
+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ }
+ previousCount = messages.size
+ }
+
+ ScreenScaffold(scrollState = listState) {
+ ScalingLazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ item {
+ ChatHeader(
+ peerCount = peers.size,
+ onOpenPeople = onOpenPeople
+ )
+ }
+ if (messages.isEmpty()) {
+ item {
+ Text(
+ text = "no messages yet\nsay hi to the mesh",
+ style = ChatVisualTokens.SystemActionStyle,
+ color = palette.textTertiary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ )
+ }
+ }
+ items(messages, key = { it.id }) { message ->
+ MessageItem(message = message, myPeerID = myPeerID)
+ }
+ item {
+ ChatComposer(
+ onSend = { text ->
+ mesh?.let { sendPublicMessage(it, text) }
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChatHeader(peerCount: Int, onOpenPeople: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "bitchat",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = " · $peerCount online >",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.clickable { onOpenPeople() }
+ )
+ }
+}
+
+@Composable
+fun MessageItem(message: BitchatMessage, myPeerID: String) {
+ val palette = LocalBitchatPalette.current
+ val isSelf = message.senderPeerID == myPeerID
+ val senderColor = when {
+ isSelf -> palette.accentOrange
+ else -> colorForPeer(message.sender + (message.senderPeerID ?: ""), palette)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 3.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = if (isSelf) "you" else message.sender,
+ style = ChatVisualTokens.SenderStyle,
+ color = senderColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false)
+ )
+ Text(
+ text = " ${formatTime(message.timestamp)}",
+ style = ChatVisualTokens.SystemActionStyle,
+ fontSize = 9.sp,
+ 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) {
+ 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()
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ BasicTextField(
+ value = text,
+ onValueChange = { text = it },
+ 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
+ )
+ }
+ 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
+ )
+ }
+ }
+}
+
+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
new file mode 100644
index 00000000..6a299055
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
@@ -0,0 +1,153 @@
+package com.bitchat.watch.ui
+
+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
+import androidx.compose.runtime.collectAsState
+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.hapticfeedback.HapticFeedbackType
+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.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.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) {
+ 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 haptics = LocalHapticFeedback.current
+
+ val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
+ var sessionEstablished by remember {
+ mutableStateOf(mesh?.hasEstablishedSession(peerID) == true)
+ }
+
+ DisposableEffect(peerID) {
+ WearChatState.openDm(peerID)
+ onDispose { WearChatState.closeDm() }
+ }
+
+ LaunchedEffect(peerID) {
+ if (mesh?.hasEstablishedSession(peerID) != true) {
+ try { mesh?.initiateNoiseHandshake(peerID) } catch (_: Exception) { }
+ }
+ while (true) {
+ sessionEstablished = mesh?.hasEstablishedSession(peerID) == true
+ kotlinx.coroutines.delay(2_000)
+ }
+ }
+
+ var previousCount by remember { mutableStateOf(messages.size) }
+ LaunchedEffect(messages.size) {
+ if (messages.size > previousCount) {
+ val last = messages.lastOrNull()
+ if (last != null && last.senderPeerID != myPeerID) {
+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ }
+ previousCount = messages.size
+ }
+
+ ScreenScaffold(scrollState = listState) {
+ ScalingLazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = nickname,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = colorForPeer(nickname + peerID, palette)
+ )
+ Text(
+ text = if (sessionEstablished) " · noise ✓" else " · handshaking…",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (sessionEstablished) MaterialTheme.colorScheme.primary
+ else palette.textTertiary
+ )
+ }
+ }
+ if (messages.isEmpty()) {
+ 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)
+ }
+ item {
+ ChatComposer(
+ onSend = { text ->
+ mesh?.let { sendPrivateMessage(it, peerID, nickname, text) }
+ }
+ )
+ }
+ }
+ }
+}
+
+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/NicknameSetupScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt
new file mode 100644
index 00000000..e6493f96
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt
@@ -0,0 +1,100 @@
+package com.bitchat.watch.ui
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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.runtime.Composable
+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.graphics.SolidColor
+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.unit.dp
+import androidx.wear.compose.material3.Button
+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
+
+@Composable
+fun NicknameSetupScreen(
+ initialNickname: String,
+ onConfirm: (String) -> Unit
+) {
+ val palette = LocalBitchatPalette.current
+ var name by remember { mutableStateOf(initialNickname) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "bitchat",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "pick a nickname",
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.textTertiary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
+ )
+ BasicTextField(
+ value = name,
+ onValueChange = { name = it.take(24) },
+ singleLine = true,
+ textStyle = ChatVisualTokens.MessageBodyStyle.copy(
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = {
+ if (name.isNotBlank()) onConfirm(name.trim())
+ }),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(palette.inputSurface)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ decorationBox = { innerTextField ->
+ Box(contentAlignment = Alignment.Center) {
+ if (name.isEmpty()) {
+ Text(
+ text = "nickname",
+ style = ChatVisualTokens.MessageBodyStyle,
+ color = palette.textTertiary
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+ Button(
+ onClick = { if (name.isNotBlank()) onConfirm(name.trim()) },
+ enabled = name.isNotBlank(),
+ modifier = Modifier.padding(top = 10.dp)
+ ) {
+ Text("join the mesh")
+ }
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt
new file mode 100644
index 00000000..81183be9
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt
@@ -0,0 +1,98 @@
+package com.bitchat.watch.ui
+
+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.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.items
+import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
+import androidx.wear.compose.material3.ListHeader
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.ScreenScaffold
+import androidx.wear.compose.material3.Text
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.watch.mesh.WearMeshService
+import com.bitchat.watch.ui.theme.LocalBitchatPalette
+import com.bitchat.watch.ui.theme.colorForPeer
+
+/**
+ * Internal debug screen (M2): raw peer list with RSSI. Kept for troubleshooting; the real
+ * people screen arrives in M4.
+ */
+@Composable
+fun PeerDebugScreen() {
+ val peers by AppStateStore.peers.collectAsState()
+ val mesh = WearMeshService.peek()
+ val listState = rememberScalingLazyListState()
+ val palette = LocalBitchatPalette.current
+ val nicknames = mesh?.getPeerNicknames() ?: emptyMap()
+ val rssi = mesh?.getPeerRSSI() ?: emptyMap()
+
+ ScreenScaffold(scrollState = listState) {
+ ScalingLazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ item {
+ ListHeader {
+ Text(
+ text = "peers (${peers.size})",
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ if (peers.isEmpty()) {
+ item {
+ Text(
+ text = "scanning for bitchat devices…",
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.textTertiary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ )
+ }
+ }
+ items(peers) { peerID ->
+ val nick = nicknames[peerID] ?: peerID.take(8)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = nick,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorForPeer(nick + peerID, palette),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ rssi[peerID]?.let {
+ Text(
+ text = "${it}dBm",
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.textTertiary,
+ modifier = Modifier.padding(start = 6.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt
new file mode 100644
index 00000000..52c35332
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt
@@ -0,0 +1,134 @@
+package com.bitchat.watch.ui
+
+import androidx.compose.foundation.layout.Arrangement
+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.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.items
+import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
+import androidx.wear.compose.material3.Card
+import androidx.wear.compose.material3.ListHeader
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.ScreenScaffold
+import androidx.wear.compose.material3.Text
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.watch.mesh.WearMeshService
+import com.bitchat.watch.ui.theme.ChatVisualTokens
+import com.bitchat.watch.ui.theme.LocalBitchatPalette
+import com.bitchat.watch.ui.theme.colorForPeer
+
+@Composable
+fun PeopleScreen(onOpenDm: (String) -> Unit) {
+ val peers by AppStateStore.peers.collectAsState()
+ val unread by WearChatState.unreadDms.collectAsState()
+ val mesh = WearMeshService.peek()
+ val listState = rememberScalingLazyListState()
+ val palette = LocalBitchatPalette.current
+ val nicknames = mesh?.getPeerNicknames() ?: emptyMap()
+ val rssi = mesh?.getPeerRSSI() ?: emptyMap()
+
+ ScreenScaffold(scrollState = listState) {
+ ScalingLazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ item {
+ ListHeader {
+ Text(
+ text = "people (${peers.size})",
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ if (peers.isEmpty()) {
+ item {
+ Text(
+ text = "no one nearby yet\nkeep the app open to mesh",
+ style = ChatVisualTokens.SystemActionStyle,
+ color = palette.textTertiary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ )
+ }
+ }
+ items(peers, key = { it }) { peerID ->
+ val nick = nicknames[peerID] ?: peerID.take(8)
+ val unreadCount = unread[peerID] ?: 0
+ val encrypted = mesh?.hasEstablishedSession(peerID) == true
+ PersonRow(
+ nickname = nick,
+ peerID = peerID,
+ rssi = rssi[peerID],
+ encrypted = encrypted,
+ unreadCount = unreadCount,
+ onClick = { onOpenDm(peerID) }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PersonRow(
+ nickname: String,
+ peerID: String,
+ rssi: Int?,
+ encrypted: Boolean,
+ unreadCount: Int,
+ onClick: () -> Unit
+) {
+ val palette = LocalBitchatPalette.current
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 10.dp, vertical = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = nickname,
+ style = ChatVisualTokens.SenderStyle,
+ color = colorForPeer(nickname + peerID, palette),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = buildString {
+ append(if (encrypted) "noise ✓" else "tap to chat")
+ rssi?.let { append(" · ${it}dBm") }
+ },
+ style = ChatVisualTokens.SystemActionStyle,
+ color = if (encrypted) MaterialTheme.colorScheme.primary else palette.textTertiary
+ )
+ }
+ if (unreadCount > 0) {
+ Text(
+ text = "$unreadCount new",
+ style = ChatVisualTokens.SystemActionStyle,
+ color = palette.accentOrange,
+ modifier = Modifier.padding(start = 6.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt
new file mode 100644
index 00000000..bc6de4b7
--- /dev/null
+++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt
@@ -0,0 +1,32 @@
+package com.bitchat.watch.ui
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Process-wide UI state for the watch app: unread DM counters and the currently open DM thread.
+ */
+object WearChatState {
+ private val _unreadDms = MutableStateFlow