wear: M2/M3/M4/M6 code - BLE mesh service, chat/DM/people UI, test hook, mesh_lab watch support (hardware verification pending)

This commit is contained in:
callebtc 2026-07-28 16:43:49 +02:00
parent 711c73bbe7
commit 43a88808a5
18 changed files with 1914 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="com.bitchat.watch.testhook.WearTestHookReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.bitchat.watch.TEST_HOOK" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

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

View File

@ -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 <command> --es id <cmd-id> [command extras...]
*
* Result is written to cache/testhook/results/<id>.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()
}
}

View File

@ -20,6 +20,7 @@
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".BitchatWatchApplication"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
@ -30,6 +31,11 @@
android:name="com.google.android.wearable.standalone"
android:value="true" />
<service
android:name=".service.WearMeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -0,0 +1,11 @@
package com.bitchat.watch
import android.app.Application
import com.bitchat.android.mesh.PowerManager
class BitchatWatchApplication : Application() {
override fun onCreate() {
super.onCreate()
PowerManager.getInstance(applicationContext)
}
}

View File

@ -1,52 +1,204 @@
package com.bitchat.watch
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.service.WearMeshForegroundService
import com.bitchat.watch.ui.ChatScreen
import com.bitchat.watch.ui.DmScreen
import com.bitchat.watch.ui.NicknameSetupScreen
import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.theme.BitchatWearTheme
sealed interface WearScreen {
data object Chat : WearScreen
data object People : WearScreen
data class Dm(val peerID: String) : WearScreen
}
class MainActivity : ComponentActivity() {
private var hasPermissions by mutableStateOf(false)
private var bluetoothEnabled by mutableStateOf(false)
private var nicknameChosen by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
.getBoolean("nickname_chosen", false)
refreshState()
setContent {
BitchatWearTheme {
PlaceholderScreen()
when {
!hasPermissions -> 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<String> = 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>(WearScreen.Chat) }
val backStack = remember { mutableStateListOf<WearScreen>() }
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")
}
}
}

View File

@ -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<String, String> =
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<String> = 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<String> = 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<String, String> = 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<String, String> = meshCore.getPeerNicknames()
fun getPeerRSSI(): Map<String, Int> = 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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Map<String, Int>>(emptyMap())
val unreadDms: StateFlow<Map<String, Int>> = _unreadDms.asStateFlow()
@Volatile
var openDmPeer: String? = null
fun onPrivateMessageArrived(peerID: String) {
if (openDmPeer == peerID) return
_unreadDms.value = _unreadDms.value + (peerID to ((_unreadDms.value[peerID] ?: 0) + 1))
}
fun openDm(peerID: String) {
openDmPeer = peerID
_unreadDms.value = _unreadDms.value - peerID
}
fun closeDm() {
openDmPeer = null
}
fun unreadCount(peerID: String): Int = _unreadDms.value[peerID] ?: 0
}

View File

@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">bitchat</string>
<string name="mesh_channel_name">Mesh network</string>
<string name="mesh_channel_description">Keeps the Bluetooth mesh running in the background</string>
<string name="mesh_notification_text">Bluetooth mesh active</string>
</resources>