mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
711c73bbe7
commit
43a88808a5
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
|
||||
12
wear/src/debug/AndroidManifest.xml
Normal file
12
wear/src/debug/AndroidManifest.xml
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
319
wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
Normal file
319
wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
300
wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
Normal file
300
wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt
Normal 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)
|
||||
153
wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
Normal file
153
wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
100
wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt
Normal file
100
wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
98
wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt
Normal file
98
wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt
Normal file
134
wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt
Normal file
32
wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user