diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c44c803..035475c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,9 +6,7 @@ plugins {
android {
namespace = "sh.sar.callpipe"
compileSdk {
- version = release(36) {
- minorApiLevel = 1
- }
+ version = release(37)
}
defaultConfig {
@@ -48,6 +46,8 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.nanohttpd.websocket)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7a53664..811f9c3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.Callpipe"
+ tools:targetApi="34">
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/assets/web/app.js b/app/src/main/assets/web/app.js
new file mode 100644
index 0000000..cb01474
--- /dev/null
+++ b/app/src/main/assets/web/app.js
@@ -0,0 +1,110 @@
+// CallPipe agent softphone.
+// Audio: 16 kHz mono PCM S16LE both ways over a binary WebSocket, matching the
+// relay/phone pipeline. One ScriptProcessor both captures the mic (-> uplink)
+// and plays received audio (<- downlink). Signaling is text JSON on the same WS.
+
+const RATE = 16000;
+const MAX_PLAY = RATE; // cap playback backlog at ~1s to bound latency
+
+let ws = null;
+let ac = null;
+let node = null;
+let micStream = null;
+let playBuf = []; // queued Float32 samples to play
+let callActive = false;
+
+const $ = (id) => document.getElementById(id);
+function log(m) {
+ const el = $("log");
+ el.textContent = (new Date().toLocaleTimeString() + " " + m + "\n" + el.textContent).slice(0, 2000);
+}
+
+function setDot(id, on) { $(id).classList.toggle("on", !!on); }
+function show(id, on) { $(id).classList.toggle("hide", !on); }
+
+// ---- WebSocket ----
+function connect() {
+ ws = new WebSocket(`wss://${location.host}/agent`);
+ ws.binaryType = "arraybuffer";
+ ws.onopen = () => { setDot("dotWs", true); $("wsText").textContent = "Connected"; log("ws open"); };
+ ws.onclose = () => {
+ setDot("dotWs", false); $("wsText").textContent = "Reconnecting…";
+ log("ws closed"); setTimeout(connect, 2000);
+ };
+ ws.onerror = () => log("ws error");
+ ws.onmessage = (e) => {
+ if (typeof e.data === "string") { onSignal(JSON.parse(e.data)); return; }
+ const i16 = new Int16Array(e.data);
+ for (let i = 0; i < i16.length; i++) playBuf.push(i16[i] / 32768);
+ if (playBuf.length > MAX_PLAY) playBuf.splice(0, playBuf.length - MAX_PLAY);
+ };
+}
+
+function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
+
+// ---- Signaling ----
+function onSignal(msg) {
+ if (msg.event === "state") {
+ const s = msg.state || "IDLE";
+ callActive = (s !== "IDLE" && s !== "DISCONNECTED");
+ $("stateText").textContent = prettyState(s);
+ $("numText").textContent = msg.number ? ((msg.direction || "") + " " + msg.number).trim() : "";
+ show("btnAnswer", s === "RINGING");
+ show("btnHangup", callActive);
+ show("btnDial", !callActive && ws && ws.readyState === 1);
+ } else if (msg.event === "relay") {
+ setDot("dotRelay", msg.connected);
+ $("relayText").textContent = msg.connected ? "Relay connected" : "Relay not connected";
+ } else if (msg.event === "error") {
+ log("error: " + msg.msg);
+ } else if (msg.event === "hello") {
+ log("hello from " + msg.app);
+ }
+}
+
+function prettyState(s) {
+ return { IDLE:"Idle", CONNECTING:"Connecting…", DIALING:"Ringing…",
+ RINGING:"Incoming call", ACTIVE:"Connected", HOLDING:"On hold",
+ DISCONNECTING:"Ending…", DISCONNECTED:"Call ended" }[s] || s;
+}
+
+// ---- Audio ----
+async function initAudio() {
+ ac = new AudioContext({ sampleRate: RATE });
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
+ });
+ const src = ac.createMediaStreamSource(micStream);
+ node = ac.createScriptProcessor(2048, 1, 1);
+ node.onaudioprocess = (e) => {
+ // capture mic -> uplink
+ const inp = e.inputBuffer.getChannelData(0);
+ if (callActive && ws && ws.readyState === 1) {
+ const out = new Int16Array(inp.length);
+ for (let i = 0; i < inp.length; i++) {
+ let s = Math.max(-1, Math.min(1, inp[i]));
+ out[i] = s < 0 ? s * 32768 : s * 32767;
+ }
+ ws.send(out.buffer);
+ }
+ // playback downlink -> speakers
+ const o = e.outputBuffer.getChannelData(0);
+ for (let i = 0; i < o.length; i++) o[i] = playBuf.length ? playBuf.shift() : 0;
+ };
+ src.connect(node);
+ node.connect(ac.destination);
+ log("audio ready @ " + ac.sampleRate + "Hz");
+}
+
+// ---- Buttons ----
+$("btnStart").onclick = async () => {
+ $("btnStart").disabled = true;
+ try { await initAudio(); connect(); show("btnStart", false); }
+ catch (err) { log("mic failed: " + err); $("btnStart").disabled = false; }
+};
+$("btnAnswer").onclick = () => send({ cmd: "answer" });
+$("btnHangup").onclick = () => send({ cmd: "hangup" });
+$("btnDial").onclick = () => {
+ const n = prompt("Number to dial:");
+ if (n) send({ cmd: "dial", number: n });
+};
diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html
new file mode 100644
index 0000000..7a3d4ec
--- /dev/null
+++ b/app/src/main/assets/web/index.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+CallPipe Agent
+
+
+
+
+
CallPipe Agent
+
browser softphone
+
+
Not connected
+
Relay unknown
+
+
Idle
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/sh/sar/callpipe/MainActivity.kt b/app/src/main/java/sh/sar/callpipe/MainActivity.kt
index ac6f46c..da52ee0 100644
--- a/app/src/main/java/sh/sar/callpipe/MainActivity.kt
+++ b/app/src/main/java/sh/sar/callpipe/MainActivity.kt
@@ -1,47 +1,191 @@
package sh.sar.callpipe
+import android.Manifest
+import android.app.role.RoleManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
+import android.telecom.TelecomManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import sh.sar.callpipe.hub.HubService
+import sh.sar.callpipe.hub.HubStatus
+import sh.sar.callpipe.telecom.CallController
import sh.sar.callpipe.ui.theme.CallpipeTheme
+import java.net.Inet4Address
+import java.net.NetworkInterface
class MainActivity : ComponentActivity() {
+
+ private val roleLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ requestNeededPermissions()
+ requestDialerRole()
+ HubService.start(this)
+
setContent {
CallpipeTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
+ Surface(modifier = Modifier.fillMaxSize()) {
+ HubScreen(
+ onAnswer = { CallController.answer(); CallController.routeBluetooth() },
+ onHangup = { CallController.hangup() },
)
}
}
}
}
-}
-@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
- CallpipeTheme {
- Greeting("Android")
+ private fun requestNeededPermissions() {
+ val perms = mutableListOf(
+ Manifest.permission.READ_PHONE_STATE,
+ Manifest.permission.READ_PHONE_NUMBERS,
+ Manifest.permission.CALL_PHONE,
+ Manifest.permission.ANSWER_PHONE_CALLS,
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ perms.add(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ val needed = perms.filter {
+ ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (needed.isNotEmpty()) requestPermissions(needed.toTypedArray(), REQ_PERMS)
}
-}
\ No newline at end of file
+
+ private fun requestDialerRole() {
+ val tm = getSystemService(Context.TELECOM_SERVICE) as TelecomManager
+ if (packageName == tm.defaultDialerPackage) return
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val rm = getSystemService(Context.ROLE_SERVICE) as RoleManager
+ if (!rm.isRoleAvailable(RoleManager.ROLE_DIALER) || rm.isRoleHeld(RoleManager.ROLE_DIALER)) return
+ rm.createRequestRoleIntent(RoleManager.ROLE_DIALER)
+ } else {
+ @Suppress("DEPRECATION")
+ Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER)
+ .putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName)
+ }
+ roleLauncher.launch(intent)
+ }
+
+ companion object {
+ private const val REQ_PERMS = 100
+ }
+}
+
+@Composable
+private fun HubScreen(onAnswer: () -> Unit, onHangup: () -> Unit) {
+ val hub by HubStatus.info.collectAsStateWithLifecycle()
+ val call by CallController.state.collectAsStateWithLifecycle()
+ val ip = remembered()
+
+ Scaffold { pad ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(pad).padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("callpipe hub", fontSize = 26.sp, fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground)
+ Text("phone = the bridge · agents ⇄ relay", fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.55f))
+
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ StatusRow(hub.webRunning, if (hub.webRunning) "Web server up" else "Web server down")
+ hub.error?.let { Text("error: $it", color = Color(0xFFF87171), fontSize = 12.sp) }
+ StatusRow(hub.relayConnected, if (hub.relayConnected) "Relay connected" else "Relay not connected")
+ KeyVal("Agents connected", hub.agents.toString())
+ KeyVal("Agents open", "https://test.shihaam.me:${hub.webPort}")
+ KeyVal("(map that host to)", ip)
+ }
+ }
+
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ StatusRow(call.active, "Call: ${call.state}")
+ Text(call.number?.let { "${call.direction ?: ""} $it".trim() } ?: "no call",
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f))
+ if (call.state == CallController.STATE_RINGING) {
+ Button(onClick = onAnswer, modifier = Modifier.fillMaxWidth()) { Text("Answer") }
+ }
+ if (call.active) {
+ Button(onClick = onHangup, modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFFDC2626), contentColor = Color.White)) {
+ Text("Hang up")
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ Text("Answering happens from the agent browser; audio rides Bluetooth to the relay.",
+ fontSize = 12.sp, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f))
+ }
+ }
+}
+
+@Composable
+private fun StatusRow(ok: Boolean, label: String) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Surface(modifier = Modifier.size(10.dp).clip(CircleShape),
+ color = if (ok) Color(0xFF4ADE80) else Color(0xFF64748B)) {}
+ Spacer(Modifier.size(8.dp))
+ Text(label, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium)
+ }
+}
+
+@Composable
+private fun KeyVal(key: String, value: String) {
+ Column {
+ Text(key, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
+ Text(value, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface)
+ }
+}
+
+private fun remembered(): String = localIpv4() ?: "?.?.?.?"
+
+private fun localIpv4(): String? = try {
+ NetworkInterface.getNetworkInterfaces().toList()
+ .filter { it.isUp && !it.isLoopback }
+ .flatMap { it.inetAddresses.toList() }
+ .filterIsInstance()
+ .firstOrNull { it.isSiteLocalAddress }
+ ?.hostAddress
+} catch (_: Exception) { null }
diff --git a/app/src/main/java/sh/sar/callpipe/hub/Hub.kt b/app/src/main/java/sh/sar/callpipe/hub/Hub.kt
new file mode 100644
index 0000000..9939b1e
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/hub/Hub.kt
@@ -0,0 +1,135 @@
+package sh.sar.callpipe.hub
+
+import android.content.Context
+import android.util.Log
+import org.json.JSONObject
+import sh.sar.callpipe.relay.RelayServer
+import sh.sar.callpipe.telecom.CallController
+import java.util.Collections
+
+/**
+ * The hub. The phone is the center of the star: it holds the cellular call
+ * (Telecom), talks to the relay (Bluetooth audio, over TCP) and to the agents
+ * (browsers, over WSS). This object splices audio between the relay and the
+ * agents and translates agent commands / call state.
+ *
+ * downlink: relay --onDownlinkAudio--> [Hub] --> all agents (binary)
+ * uplink: agent --onUplinkAudio-----> [Hub] --> relay.sendAudio
+ * signaling: agent JSON <-> CallController; call state --> agents
+ */
+object Hub {
+
+ private const val TAG = "Hub"
+
+ /** An agent browser connection (implemented by the WSS socket). */
+ interface AgentChannel {
+ fun sendAudio(pcm: ByteArray)
+ fun sendEvent(json: String)
+ }
+
+ private lateinit var appContext: Context
+ private var relay: RelayServer? = null
+ private val agents = Collections.synchronizedSet(mutableSetOf())
+
+ @Volatile private var lastState: String = CallController.STATE_IDLE
+
+ fun init(context: Context, relay: RelayServer) {
+ this.appContext = context.applicationContext
+ this.relay = relay
+ }
+
+ // ── Relay side (RelayServer.Listener is wired in HubService) ──
+
+ fun onDownlinkAudio(pcm: ByteArray) {
+ val snapshot = synchronized(agents) { agents.toList() }
+ for (a in snapshot) a.sendAudio(pcm)
+ }
+
+ fun onRelayConnected() {
+ HubStatus.setRelay(true)
+ broadcastEvent(JSONObject().put("event", "relay").put("connected", true))
+ }
+
+ fun onRelayDisconnected() {
+ HubStatus.setRelay(false)
+ broadcastEvent(JSONObject().put("event", "relay").put("connected", false))
+ }
+
+ fun onRelayEvent(json: String) {
+ Log.i(TAG, "relay: $json")
+ }
+
+ // ── Agent side (called by the WSS socket) ──
+
+ fun addAgent(ch: AgentChannel) {
+ agents.add(ch)
+ HubStatus.setAgents(agents.size)
+ // greet + current relay + call state
+ ch.sendEvent(JSONObject().put("event", "hello").put("app", "callpipe").toString())
+ ch.sendEvent(JSONObject().put("event", "relay")
+ .put("connected", HubStatus.info.value.relayConnected).toString())
+ ch.sendEvent(stateEvent(CallController.state.value))
+ }
+
+ fun removeAgent(ch: AgentChannel) {
+ agents.remove(ch)
+ HubStatus.setAgents(agents.size)
+ }
+
+ fun onUplinkAudio(pcm: ByteArray) {
+ relay?.sendAudio(pcm)
+ }
+
+ fun onAgentCommand(json: String) {
+ val obj = try { JSONObject(json) } catch (_: Exception) { return }
+ when (obj.optString("cmd")) {
+ "answer" -> {
+ CallController.answer()
+ CallController.routeBluetooth()
+ }
+ "hangup" -> CallController.hangup()
+ "reject" -> CallController.reject()
+ "dial" -> {
+ val number = obj.optString("number")
+ val sim = if (obj.has("sim") && !obj.isNull("sim")) obj.optInt("sim") else null
+ val err = CallController.dial(appContext, number, sim)
+ if (err != null) broadcastEvent(JSONObject().put("event", "error").put("msg", err))
+ else CallController.routeBluetooth()
+ }
+ }
+ }
+
+ // ── Call state (fed from HubService's CallController.state collector) ──
+
+ fun onCallState(snap: CallController.Snapshot) {
+ broadcastEvent(JSONObject(stateEvent(snap)))
+ if (snap.state != lastState) {
+ when (snap.state) {
+ CallController.STATE_ACTIVE -> {
+ CallController.routeBluetooth()
+ relay?.sendStart()
+ }
+ CallController.STATE_IDLE, CallController.STATE_DISCONNECTED -> {
+ relay?.sendStop()
+ }
+ }
+ lastState = snap.state
+ }
+ }
+
+ // ── helpers ──
+
+ private fun stateEvent(snap: CallController.Snapshot): String = JSONObject()
+ .put("event", "state")
+ .put("state", snap.state)
+ .put("number", snap.number ?: JSONObject.NULL)
+ .put("direction", snap.direction ?: JSONObject.NULL)
+ .toString()
+
+ private fun broadcastEvent(obj: JSONObject) = broadcastEvent(obj.toString())
+
+ private fun broadcastEvent(json: String) {
+ val snapshot = synchronized(agents) { agents.toList() }
+ for (a in snapshot) a.sendEvent(json)
+ }
+}
diff --git a/app/src/main/java/sh/sar/callpipe/hub/HubService.kt b/app/src/main/java/sh/sar/callpipe/hub/HubService.kt
new file mode 100644
index 0000000..71f8d22
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/hub/HubService.kt
@@ -0,0 +1,145 @@
+package sh.sar.callpipe.hub
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import sh.sar.callpipe.MainActivity
+import sh.sar.callpipe.R
+import sh.sar.callpipe.relay.RelayServer
+import sh.sar.callpipe.telecom.CallController
+import sh.sar.callpipe.web.WebServer
+
+/**
+ * Foreground service hosting the hub: the relay TCP server (:9010) and the agent
+ * HTTPS/WSS server (:8443), kept reachable 24/7 with WiFi + wake locks. Relays
+ * call-state changes into [Hub].
+ */
+class HubService : Service() {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var relay: RelayServer? = null
+ private var web: WebServer? = null
+ private var wifiLock: WifiManager.WifiLock? = null
+ private var wakeLock: PowerManager.WakeLock? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ startForegroundNotification()
+ acquireLocks()
+ startHub()
+ return START_STICKY
+ }
+
+ private fun startHub() {
+ if (relay != null) return
+ val relayServer = RelayServer(RelayServer.DEFAULT_PORT, relayListener)
+ relay = relayServer
+ Hub.init(applicationContext, relayServer)
+ relayServer.start()
+
+ try {
+ val server = WebServer(applicationContext, WebServer.DEFAULT_PORT)
+ server.start(0, false)
+ web = server
+ HubStatus.setWeb(true, WebServer.DEFAULT_PORT)
+ Log.i(TAG, "web server on :${WebServer.DEFAULT_PORT}, relay on :${RelayServer.DEFAULT_PORT}")
+ } catch (e: Exception) {
+ Log.e(TAG, "web server failed: ${e.message}")
+ HubStatus.setWeb(false, WebServer.DEFAULT_PORT, error = e.message ?: "web start failed")
+ }
+
+ // Feed call-state changes into the hub (ring agents, start/stop relay, etc.)
+ scope.launch {
+ CallController.state.collect { Hub.onCallState(it) }
+ }
+ }
+
+ private val relayListener = object : RelayServer.Listener {
+ override fun onRelayConnected() = Hub.onRelayConnected()
+ override fun onRelayDisconnected() = Hub.onRelayDisconnected()
+ override fun onDownlinkAudio(pcm: ByteArray) = Hub.onDownlinkAudio(pcm)
+ override fun onRelayEvent(json: String) = Hub.onRelayEvent(json)
+ }
+
+ private fun acquireLocks() {
+ try {
+ val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ @Suppress("DEPRECATION")
+ wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "callpipe:wifi").apply {
+ setReferenceCounted(false); acquire()
+ }
+ } catch (e: Exception) { Log.w(TAG, "wifi lock: ${e.message}") }
+ try {
+ val pm = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "callpipe:cpu").apply {
+ setReferenceCounted(false); acquire()
+ }
+ } catch (e: Exception) { Log.w(TAG, "wake lock: ${e.message}") }
+ }
+
+ private fun startForegroundNotification() {
+ val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.createNotificationChannel(
+ NotificationChannel(CHANNEL, "Call bridge", NotificationManager.IMPORTANCE_LOW)
+ .apply { description = "callpipe hub (relay + agents)" }
+ )
+ val tap = PendingIntent.getActivity(
+ this, 0, Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ val notif: Notification = Notification.Builder(this, CHANNEL)
+ .setContentTitle("callpipe hub")
+ .setContentText("relay :${RelayServer.DEFAULT_PORT} · agents :${WebServer.DEFAULT_PORT}")
+ .setSmallIcon(R.drawable.ic_stat_call)
+ .setOngoing(true)
+ .setContentIntent(tap)
+ .build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ } else {
+ startForeground(NOTIF_ID, notif)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ scope.cancel()
+ try { web?.stop() } catch (_: Exception) {}
+ try { relay?.stop() } catch (_: Exception) {}
+ web = null; relay = null
+ try { wifiLock?.release() } catch (_: Exception) {}
+ try { wakeLock?.release() } catch (_: Exception) {}
+ HubStatus.setWeb(false, WebServer.DEFAULT_PORT)
+ Log.i(TAG, "hub stopped")
+ }
+
+ companion object {
+ private const val TAG = "HubService"
+ private const val CHANNEL = "callpipe_hub"
+ private const val NOTIF_ID = 1
+
+ fun start(context: Context) {
+ context.startForegroundService(Intent(context, HubService::class.java))
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, HubService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/sh/sar/callpipe/hub/HubStatus.kt b/app/src/main/java/sh/sar/callpipe/hub/HubStatus.kt
new file mode 100644
index 0000000..f722bc6
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/hub/HubStatus.kt
@@ -0,0 +1,32 @@
+package sh.sar.callpipe.hub
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Observable hub status for the UI. */
+object HubStatus {
+
+ data class Info(
+ val webRunning: Boolean = false,
+ val webPort: Int = 0,
+ val relayConnected: Boolean = false,
+ val agents: Int = 0,
+ val error: String? = null,
+ )
+
+ private val _info = MutableStateFlow(Info())
+ val info: StateFlow = _info.asStateFlow()
+
+ fun setWeb(running: Boolean, port: Int, error: String? = null) {
+ _info.value = _info.value.copy(webRunning = running, webPort = port, error = error)
+ }
+
+ fun setRelay(connected: Boolean) {
+ _info.value = _info.value.copy(relayConnected = connected)
+ }
+
+ fun setAgents(n: Int) {
+ _info.value = _info.value.copy(agents = n)
+ }
+}
diff --git a/app/src/main/java/sh/sar/callpipe/net/Frames.kt b/app/src/main/java/sh/sar/callpipe/net/Frames.kt
new file mode 100644
index 0000000..68e23f2
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/net/Frames.kt
@@ -0,0 +1,39 @@
+package sh.sar.callpipe.net
+
+import java.io.DataInputStream
+import java.io.DataOutputStream
+
+/**
+ * Wire framing for the relay TCP link (matches relay/callpipe-relay.py):
+ * [4-byte big-endian length N][1-byte type][payload] where N = 1 + payload.size
+ * type 'A' (0x41) = PCM S16LE mono 16 kHz, 20 ms (640 B) frames
+ * type 'C' (0x43) = control, payload is UTF-8 JSON
+ */
+object Frames {
+ const val TYPE_AUDIO = 0x41
+ const val TYPE_CTRL = 0x43
+
+ const val RATE = 16000
+ const val FRAME_MS = 20
+ const val FRAME_BYTES = RATE * 2 * FRAME_MS / 1000 // 640
+
+ /** Reads one frame; returns (type, payload) or null on EOF / malformed. */
+ fun read(input: DataInputStream): Pair? {
+ val len = try { input.readInt() } catch (_: Exception) { return null }
+ if (len <= 0 || len > 65536) return null
+ return try {
+ val type = input.readUnsignedByte()
+ val payload = ByteArray(len - 1)
+ input.readFully(payload)
+ type to payload
+ } catch (_: Exception) { null }
+ }
+
+ /** Writes one frame. Caller must serialize access to [output]. */
+ fun write(output: DataOutputStream, type: Int, payload: ByteArray) {
+ output.writeInt(payload.size + 1)
+ output.writeByte(type)
+ output.write(payload)
+ output.flush()
+ }
+}
diff --git a/app/src/main/java/sh/sar/callpipe/relay/RelayServer.kt b/app/src/main/java/sh/sar/callpipe/relay/RelayServer.kt
new file mode 100644
index 0000000..82ffbd1
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/relay/RelayServer.kt
@@ -0,0 +1,119 @@
+package sh.sar.callpipe.relay
+
+import android.util.Log
+import sh.sar.callpipe.net.Frames
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.Socket
+
+/**
+ * TCP server the CallPipe Relay (Pi) connects into. Carries call audio both ways
+ * plus start/stop control. Exactly one relay is expected; a new connection
+ * replaces any previous one.
+ *
+ * Downlink (customer voice) arrives as audio frames -> [Listener.onDownlinkAudio].
+ * Uplink (agent voice) is pushed out via [sendAudio]. [sendStart]/[sendStop] tell
+ * the relay when a call is up and routed to Bluetooth.
+ */
+class RelayServer(
+ private val port: Int,
+ private val listener: Listener,
+) {
+ interface Listener {
+ fun onRelayConnected()
+ fun onRelayDisconnected()
+ fun onDownlinkAudio(pcm: ByteArray)
+ fun onRelayEvent(json: String)
+ }
+
+ @Volatile private var serverSocket: ServerSocket? = null
+ @Volatile private var conn: Socket? = null
+ @Volatile private var out: DataOutputStream? = null
+ private val writeLock = Any()
+ @Volatile private var running = false
+
+ fun start() {
+ if (running) return
+ running = true
+ Thread({ acceptLoop() }, "relay-accept").start()
+ }
+
+ private fun acceptLoop() {
+ try {
+ val ss = ServerSocket()
+ ss.reuseAddress = true
+ ss.bind(InetSocketAddress(port))
+ serverSocket = ss
+ Log.i(TAG, "listening on :$port")
+ while (running) {
+ val c = ss.accept()
+ Log.i(TAG, "relay connected from ${c.inetAddress?.hostAddress}")
+ closeConn() // drop any previous relay
+ handle(c)
+ }
+ } catch (e: Exception) {
+ if (running) Log.e(TAG, "accept loop error: ${e.message}")
+ }
+ }
+
+ private fun handle(c: Socket) {
+ conn = c
+ try {
+ c.tcpNoDelay = true
+ val input = DataInputStream(c.getInputStream().buffered())
+ synchronized(writeLock) { out = DataOutputStream(c.getOutputStream().buffered()) }
+ listener.onRelayConnected()
+ while (running) {
+ val frame = Frames.read(input) ?: break
+ val (type, payload) = frame
+ when (type) {
+ Frames.TYPE_AUDIO -> listener.onDownlinkAudio(payload)
+ Frames.TYPE_CTRL -> listener.onRelayEvent(String(payload, Charsets.UTF_8))
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "relay conn error: ${e.message}")
+ } finally {
+ closeConn()
+ listener.onRelayDisconnected()
+ }
+ }
+
+ val isConnected: Boolean get() = conn?.isConnected == true && conn?.isClosed == false
+
+ fun sendAudio(pcm: ByteArray) = send(Frames.TYPE_AUDIO, pcm)
+ fun sendStart() = sendControl("""{"cmd":"start"}""")
+ fun sendStop() = sendControl("""{"cmd":"stop"}""")
+ fun sendControl(json: String) = send(Frames.TYPE_CTRL, json.toByteArray(Charsets.UTF_8))
+
+ private fun send(type: Int, payload: ByteArray) {
+ synchronized(writeLock) {
+ val o = out ?: return
+ try {
+ Frames.write(o, type, payload)
+ } catch (e: Exception) {
+ Log.w(TAG, "send failed: ${e.message}")
+ }
+ }
+ }
+
+ private fun closeConn() {
+ synchronized(writeLock) { out = null }
+ try { conn?.close() } catch (_: Exception) {}
+ conn = null
+ }
+
+ fun stop() {
+ running = false
+ closeConn()
+ try { serverSocket?.close() } catch (_: Exception) {}
+ serverSocket = null
+ }
+
+ companion object {
+ private const val TAG = "RelayServer"
+ const val DEFAULT_PORT = 9010
+ }
+}
diff --git a/app/src/main/java/sh/sar/callpipe/telecom/CallController.kt b/app/src/main/java/sh/sar/callpipe/telecom/CallController.kt
new file mode 100644
index 0000000..137f1ab
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/telecom/CallController.kt
@@ -0,0 +1,217 @@
+package sh.sar.callpipe.telecom
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.telecom.Call
+import android.telecom.CallAudioState
+import android.telecom.InCallService
+import android.telecom.TelecomManager
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Single source of truth for the phone's cellular call.
+ *
+ * In the BT-bridge architecture the phone does NO audio — audio rides Bluetooth
+ * HFP to the Raspberry Pi. This object only *controls* the call (place / answer /
+ * hang up) and publishes its state, so the control channel and the UI can both
+ * observe it. Commands arrive either from the local UI or from the Pi over the
+ * WebSocket control channel.
+ */
+object CallController {
+
+ private const val TAG = "CallController"
+
+ data class SimInfo(val index: Int, val label: String)
+
+ /** Immutable snapshot of the current call for observers. */
+ data class Snapshot(
+ val state: String = STATE_IDLE,
+ val number: String? = null,
+ /** "IN" | "OUT" | null */
+ val direction: String? = null,
+ ) {
+ val active: Boolean get() = state != STATE_IDLE && state != STATE_DISCONNECTED
+ }
+
+ private val _state = MutableStateFlow(Snapshot())
+ val state: StateFlow = _state.asStateFlow()
+
+ @Volatile private var service: InCallService? = null
+ @Volatile private var activeCall: Call? = null
+
+ // ── InCallService plumbing ───────────────────────────
+
+ fun attachService(s: InCallService) { service = s }
+
+ fun onCallAdded(call: Call) {
+ activeCall = call
+ publish(call)
+ }
+
+ fun onCallRemoved(call: Call) {
+ if (activeCall == call) activeCall = null
+ // If no call remains, fall back to IDLE; otherwise reflect whatever's left.
+ val remaining = service?.calls?.firstOrNull { it != call }
+ if (remaining != null) {
+ activeCall = remaining
+ publish(remaining)
+ } else {
+ _state.value = Snapshot(STATE_IDLE)
+ }
+ }
+
+ fun onStateChanged(call: Call) {
+ if (activeCall == null) activeCall = call
+ publish(call)
+ }
+
+ private fun publish(call: Call) {
+ val details = call.details
+ val number = details?.handle?.schemeSpecificPart
+ _state.value = Snapshot(
+ state = stateName(call.state),
+ number = number,
+ direction = directionOf(call),
+ )
+ }
+
+ // ── Commands ─────────────────────────────────────────
+
+ /**
+ * Place an outgoing cellular call. [simIndex] selects the SIM (0-based, in the
+ * order returned by [listSims]); null uses the system default.
+ * Returns null on success, or an error message.
+ */
+ @SuppressLint("MissingPermission")
+ fun dial(context: Context, number: String, simIndex: Int? = null): String? {
+ val n = number.trim()
+ if (n.isEmpty()) return "empty number"
+ return try {
+ val tm = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
+ val uri = Uri.fromParts("tel", n, null)
+ val extras = Bundle()
+ if (simIndex != null) {
+ val accounts = tm.callCapablePhoneAccounts
+ if (simIndex in accounts.indices) {
+ extras.putParcelable(
+ TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, accounts[simIndex]
+ )
+ } else {
+ return "sim $simIndex out of range (${accounts.size} available)"
+ }
+ }
+ tm.placeCall(uri, extras)
+ Log.i(TAG, "dial $n sim=$simIndex")
+ null
+ } catch (e: SecurityException) {
+ Log.e(TAG, "dial denied: ${e.message}")
+ "permission denied (CALL_PHONE / default dialer?)"
+ } catch (e: Exception) {
+ Log.e(TAG, "dial failed: ${e.message}")
+ e.message ?: "dial failed"
+ }
+ }
+
+ fun answer(): Boolean {
+ val call = activeCall ?: return false
+ return try {
+ call.answer(call.details.videoState)
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "answer failed: ${e.message}"); false
+ }
+ }
+
+ fun hangup(): Boolean {
+ val call = activeCall ?: return false
+ return try {
+ when (call.state) {
+ Call.STATE_RINGING -> call.reject(false, "")
+ else -> call.disconnect()
+ }
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "hangup failed: ${e.message}"); false
+ }
+ }
+
+ fun reject(): Boolean {
+ val call = activeCall ?: return false
+ return try { call.reject(false, ""); true }
+ catch (e: Exception) { Log.e(TAG, "reject failed: ${e.message}"); false }
+ }
+
+ /**
+ * Route the active call's audio to the Bluetooth headset (the CallPipe Relay),
+ * so the modem's voice path goes out over HFP/SCO instead of the earpiece.
+ * This is what makes the relay able to see/inject the audio.
+ */
+ fun routeBluetooth(): Boolean = try {
+ service?.setAudioRoute(CallAudioState.ROUTE_BLUETOOTH); true
+ } catch (e: Exception) {
+ Log.w(TAG, "routeBluetooth failed: ${e.message}"); false
+ }
+
+ /** Is a Bluetooth device currently available as a call-audio route? */
+ val bluetoothAvailable: Boolean
+ get() = try {
+ (service?.callAudioState?.supportedRouteMask ?: 0) and CallAudioState.ROUTE_BLUETOOTH != 0
+ } catch (_: Exception) { false }
+
+ @SuppressLint("MissingPermission")
+ fun listSims(context: Context): List = try {
+ val tm = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
+ tm.callCapablePhoneAccounts.mapIndexed { i, handle ->
+ val label = try {
+ tm.getPhoneAccount(handle)?.label?.toString()
+ } catch (_: Exception) { null }
+ SimInfo(i, label?.ifBlank { null } ?: "SIM ${i + 1}")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "listSims failed: ${e.message}"); emptyList()
+ }
+
+ // ── Helpers ──────────────────────────────────────────
+
+ private fun directionOf(call: Call): String? {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return when (call.details?.callDirection) {
+ Call.Details.DIRECTION_INCOMING -> "IN"
+ Call.Details.DIRECTION_OUTGOING -> "OUT"
+ else -> null
+ }
+ }
+ return when (call.state) {
+ Call.STATE_RINGING -> "IN"
+ Call.STATE_DIALING, Call.STATE_CONNECTING -> "OUT"
+ else -> null
+ }
+ }
+
+ private fun stateName(s: Int): String = when (s) {
+ Call.STATE_NEW, Call.STATE_CONNECTING -> STATE_CONNECTING
+ Call.STATE_DIALING -> STATE_DIALING
+ Call.STATE_RINGING -> STATE_RINGING
+ Call.STATE_ACTIVE -> STATE_ACTIVE
+ Call.STATE_HOLDING -> STATE_HOLDING
+ Call.STATE_DISCONNECTING -> STATE_DISCONNECTING
+ Call.STATE_DISCONNECTED -> STATE_DISCONNECTED
+ Call.STATE_SELECT_PHONE_ACCOUNT -> STATE_CONNECTING
+ else -> "UNKNOWN($s)"
+ }
+
+ const val STATE_IDLE = "IDLE"
+ const val STATE_CONNECTING = "CONNECTING"
+ const val STATE_DIALING = "DIALING"
+ const val STATE_RINGING = "RINGING"
+ const val STATE_ACTIVE = "ACTIVE"
+ const val STATE_HOLDING = "HOLDING"
+ const val STATE_DISCONNECTING = "DISCONNECTING"
+ const val STATE_DISCONNECTED = "DISCONNECTED"
+}
diff --git a/app/src/main/java/sh/sar/callpipe/telecom/DialerCallService.kt b/app/src/main/java/sh/sar/callpipe/telecom/DialerCallService.kt
new file mode 100644
index 0000000..622a81e
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/telecom/DialerCallService.kt
@@ -0,0 +1,39 @@
+package sh.sar.callpipe.telecom
+
+import android.telecom.Call
+import android.telecom.InCallService
+import android.util.Log
+
+/**
+ * InCallService — Android routes every cellular call through this once the app is
+ * the default phone app. We forward all events to [CallController], which owns the
+ * call state and command surface.
+ */
+class DialerCallService : InCallService() {
+
+ private val callback = object : Call.Callback() {
+ override fun onStateChanged(call: Call, state: Int) {
+ Log.i(TAG, "state -> $state")
+ CallController.onStateChanged(call)
+ }
+ }
+
+ override fun onCallAdded(call: Call) {
+ super.onCallAdded(call)
+ Log.i(TAG, "call added: ${call.details?.handle?.schemeSpecificPart}")
+ CallController.attachService(this)
+ call.registerCallback(callback)
+ CallController.onCallAdded(call)
+ }
+
+ override fun onCallRemoved(call: Call) {
+ super.onCallRemoved(call)
+ Log.i(TAG, "call removed")
+ call.unregisterCallback(callback)
+ CallController.onCallRemoved(call)
+ }
+
+ companion object {
+ private const val TAG = "DialerCallService"
+ }
+}
diff --git a/app/src/main/java/sh/sar/callpipe/ui/theme/Theme.kt b/app/src/main/java/sh/sar/callpipe/ui/theme/Theme.kt
index 640e6ee..c37073a 100644
--- a/app/src/main/java/sh/sar/callpipe/ui/theme/Theme.kt
+++ b/app/src/main/java/sh/sar/callpipe/ui/theme/Theme.kt
@@ -1,58 +1,26 @@
package sh.sar.callpipe.ui.theme
-import android.app.Activity
-import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.graphics.Color
-private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
-)
-
-private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
-
- /* Other default colors to override
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F),
- */
+private val CallpipeColors = darkColorScheme(
+ primary = Color(0xFF4ADE80), // green — "connected / active"
+ secondary = Color(0xFF38BDF8),
+ background = Color(0xFF0B0F14),
+ surface = Color(0xFF141A21),
+ onPrimary = Color(0xFF04140A),
+ onBackground = Color(0xFFE5E9EF),
+ onSurface = Color(0xFFE5E9EF),
)
+/** App is a headless-ish control client — always dark, no dynamic color. */
@Composable
-fun CallpipeTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- // Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
- content: @Composable () -> Unit
-) {
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
-
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
- }
-
+fun CallpipeTheme(content: @Composable () -> Unit) {
MaterialTheme(
- colorScheme = colorScheme,
+ colorScheme = CallpipeColors,
typography = Typography,
- content = content
+ content = content,
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/sh/sar/callpipe/web/WebServer.kt b/app/src/main/java/sh/sar/callpipe/web/WebServer.kt
new file mode 100644
index 0000000..aca4857
--- /dev/null
+++ b/app/src/main/java/sh/sar/callpipe/web/WebServer.kt
@@ -0,0 +1,114 @@
+package sh.sar.callpipe.web
+
+import android.content.Context
+import android.util.Log
+import fi.iki.elonen.NanoHTTPD
+import fi.iki.elonen.NanoHTTPD.IHTTPSession
+import fi.iki.elonen.NanoHTTPD.Response
+import fi.iki.elonen.NanoWSD
+import fi.iki.elonen.NanoWSD.WebSocket
+import fi.iki.elonen.NanoWSD.WebSocketFrame
+import sh.sar.callpipe.hub.Hub
+import java.io.IOException
+import java.security.KeyStore
+import javax.net.ssl.KeyManagerFactory
+import javax.net.ssl.SSLContext
+
+/**
+ * Agent-facing server: HTTPS (so the browser gets a secure context for
+ * getUserMedia) serving the softphone page, and a WSS endpoint at /agent that
+ * carries the agent's mic/speaker PCM (binary frames) plus signaling (text JSON).
+ *
+ * TLS uses the bundled PKCS12 (assets/certs/callpipe.p12, CN=test.shihaam.me) —
+ * agents reach the phone at https://test.shihaam.me:8443 with a hosts entry
+ * mapping test.shihaam.me -> phone IP.
+ */
+class WebServer(
+ private val context: Context,
+ port: Int,
+) : NanoWSD(port) {
+
+ init {
+ makeSecure(buildSslFactory(context), null)
+ }
+
+ override fun serveHttp(session: IHTTPSession): Response {
+ val uri = session.uri
+ return when (uri) {
+ "/", "/index.html" -> asset("web/index.html", "text/html")
+ "/app.js" -> asset("web/app.js", "application/javascript")
+ else -> NanoHTTPD.newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found")
+ }
+ }
+
+ private fun asset(path: String, mime: String): Response = try {
+ val bytes = context.assets.open(path).use { it.readBytes() }
+ NanoHTTPD.newFixedLengthResponse(Response.Status.OK, mime, bytes.inputStream(), bytes.size.toLong())
+ } catch (e: IOException) {
+ NanoHTTPD.newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "missing: $path")
+ }
+
+ override fun openWebSocket(handshake: IHTTPSession): WebSocket =
+ if (handshake.uri == "/agent") AgentSocket(handshake)
+ else object : WebSocket(handshake) {
+ override fun onOpen() { try { close(WebSocketFrame.CloseCode.PolicyViolation, "bad path", false) } catch (_: Exception) {} }
+ override fun onClose(c: WebSocketFrame.CloseCode?, r: String?, b: Boolean) {}
+ override fun onMessage(m: WebSocketFrame) {}
+ override fun onPong(p: WebSocketFrame) {}
+ override fun onException(e: IOException) {}
+ }
+
+ /** One agent (browser) connection. */
+ inner class AgentSocket(handshake: IHTTPSession) : WebSocket(handshake), Hub.AgentChannel {
+ private val sendLock = Any()
+
+ override fun onOpen() {
+ Log.i(TAG, "agent connected")
+ Hub.addAgent(this)
+ }
+
+ override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, remote: Boolean) {
+ Log.i(TAG, "agent disconnected")
+ Hub.removeAgent(this)
+ }
+
+ override fun onMessage(message: WebSocketFrame) {
+ if (message.opCode == WebSocketFrame.OpCode.Binary) {
+ Hub.onUplinkAudio(message.binaryPayload)
+ } else {
+ Hub.onAgentCommand(message.textPayload ?: return)
+ }
+ }
+
+ override fun onPong(pong: WebSocketFrame) {}
+ override fun onException(exception: IOException) {
+ Log.w(TAG, "agent socket error: ${exception.message}")
+ }
+
+ // Hub.AgentChannel
+ override fun sendAudio(pcm: ByteArray) = safeSend { send(pcm) }
+ override fun sendEvent(json: String) = safeSend { send(json) }
+
+ private inline fun safeSend(block: () -> Unit) {
+ synchronized(sendLock) {
+ try { block() } catch (e: IOException) { /* dropped */ }
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WebServer"
+ const val DEFAULT_PORT = 8443
+
+ private fun buildSslFactory(context: Context): javax.net.ssl.SSLServerSocketFactory {
+ val pass = "callpipe".toCharArray()
+ val ks = KeyStore.getInstance("PKCS12")
+ context.assets.open("certs/callpipe.p12").use { ks.load(it, pass) }
+ val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
+ kmf.init(ks, pass)
+ val ssl = SSLContext.getInstance("TLS")
+ ssl.init(kmf.keyManagers, null, null)
+ return ssl.serverSocketFactory
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_stat_call.xml b/app/src/main/res/drawable/ic_stat_call.xml
new file mode 100644
index 0000000..15c939c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stat_call.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index f246ed7..fc1da16 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,7 @@
-
+
\ No newline at end of file
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
new file mode 100644
index 0000000..107f267
--- /dev/null
+++ b/docs/control-protocol.md
@@ -0,0 +1,48 @@
+# callpipe control protocol (Pi ⇄ phone)
+
+The phone app hosts a **WebSocket server** on the LAN (default port **8090**). The Pi
+(or any client, e.g. `wscat`) connects in and drives the cellular call. **No audio**
+crosses this channel — audio is Bluetooth HFP between the phone and the Pi. This is
+purely call *control* + *state*.
+
+Endpoint: `ws://:8090/` (any path is accepted)
+
+## Commands (client → phone)
+
+| JSON | Action |
+|------|--------|
+| `{"cmd":"dial","number":"+9607…","sim":0}` | Place outgoing call. `sim` optional (0-based index from the `sims` event); omit for default SIM. |
+| `{"cmd":"answer"}` | Answer the ringing call. |
+| `{"cmd":"hangup"}` | End the active call (or reject if still ringing). |
+| `{"cmd":"reject"}` | Reject the ringing call. |
+| `{"cmd":"state"}` | Ask for the current call snapshot. |
+| `{"cmd":"sims"}` | Ask for the SIM list. |
+| `{"cmd":"ping"}` | Liveness check → `pong`. |
+
+## Events (phone → client)
+
+| JSON | Meaning |
+|------|---------|
+| `{"event":"hello","app":"callpipe","proto":1}` | Sent on connect. |
+| `{"event":"sims","sims":[{"index":0,"label":"Ooredoo"},{"index":1,"label":"Dhiraagu"}]}` | SIM list (sent on connect + on request). |
+| `{"event":"state","state":"ACTIVE","number":"+9607…","direction":"OUT"}` | Call state. Pushed on every change + on connect. |
+| `{"event":"ack","cmd":"dial"}` | Command accepted. |
+| `{"event":"error","cmd":"dial","msg":"…"}` | Command failed. |
+| `{"event":"pong"}` | Reply to `ping`. |
+
+`state` values: `IDLE`, `CONNECTING`, `DIALING`, `RINGING`, `ACTIVE`, `HOLDING`,
+`DISCONNECTING`, `DISCONNECTED`. `direction`: `IN`, `OUT`, or `null`.
+
+## Quick test (before the Pi is ready)
+
+```sh
+# phone IP is 10.0.1.239 (adb wireless). Install app, open it once so the
+# foreground service + default-dialer role are granted.
+npm i -g wscat # or: nix-shell -p nodePackages.wscat
+wscat -c ws://10.0.1.239:8090/
+> {"cmd":"sims"}
+> {"cmd":"dial","number":"+9607xxxxxx"}
+> {"cmd":"hangup"}
+```
+
+You should see `hello`, `sims`, and live `state` events stream back.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 324b701..db6ed89 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,8 @@ lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
+nanohttpd = "2.3.1"
+lifecycleRuntimeCompose = "2.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,6 +26,8 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
+nanohttpd-websocket = { group = "org.nanohttpd", name = "nanohttpd-websocket", version.ref = "nanohttpd" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/relay/README.md b/relay/README.md
new file mode 100644
index 0000000..4e8400c
--- /dev/null
+++ b/relay/README.md
@@ -0,0 +1,42 @@
+# CallPipe Relay (Raspberry Pi)
+
+The Pi pairs to the phone as a **Bluetooth HFP hands-free unit**. During a call the
+phone routes call audio to it over SCO; `bluez-alsa` exposes that as ALSA PCMs.
+`callpipe-relay.py` is a dumb audio pipe: Bluetooth SCO (via `arecord`/`aplay`) ⇄
+the phone's audio TCP server over WiFi. Call control lives on the phone.
+
+See `../docs/relay-setup.md` for the one-time Bluetooth stack setup.
+
+## Install
+
+```sh
+# on the Pi (root):
+apt-get install -y bluez bluez-firmware bluez-alsa-utils alsa-utils python3 python-is-python3
+install -D callpipe-relay.py /opt/callpipe/callpipe-relay.py
+install -D callpipe-relay.service /etc/systemd/system/callpipe-relay.service
+# set the phone IP:
+systemctl edit callpipe-relay # or edit Environment=CALLPIPE_PHONE=... in the unit
+systemctl enable --now callpipe-relay
+```
+
+## Config (env)
+
+| var | default | meaning |
+|-----|---------|---------|
+| `CALLPIPE_PHONE` | `10.0.1.239` | phone (hub) IP |
+| `CALLPIPE_PORT` | `9010` | phone audio TCP port |
+| `CALLPIPE_BT` | auto | HFP device MAC (auto-detected from bluez-alsa) |
+
+## Wire protocol (TCP, both directions)
+
+`[4B big-endian len N][1B type][payload]` where `N = 1 + len(payload)`
+- `A` (0x41): PCM S16LE mono 16 kHz, 20 ms (640 B) frames
+- `C` (0x43): control JSON — phone sends `{"cmd":"start"|"stop"}`; relay sends
+ `{"event":"hello|started|stopped|error", ...}`
+
+## Manual test
+
+```sh
+CALLPIPE_PHONE= python /opt/callpipe/callpipe-relay.py
+# with tools/phone_stub.py running on , place a call routed to BT
+```
diff --git a/relay/callpipe-relay.py b/relay/callpipe-relay.py
new file mode 100644
index 0000000..1de60d1
--- /dev/null
+++ b/relay/callpipe-relay.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""
+callpipe relay daemon — runs on the Raspberry Pi ("CallPipe Relay").
+
+The Pi is paired to the phone as a Bluetooth HFP hands-free unit, so during a
+call the phone routes call audio to it over SCO. This daemon is a DUMB AUDIO
+PIPE between that Bluetooth SCO PCM (via bluez-alsa / arecord+aplay) and the
+phone's audio TCP server over WiFi:
+
+ customer <-cellular-> phone <-BT/SCO-> [arecord] -> TCP -> phone -> agent
+ agent -> phone -> TCP -> [aplay] -> BT/SCO -> phone <-cellular-> customer
+
+It does no call control — the phone (the hub) tells it when to start/stop via a
+control frame, because SCO only exists while a call is up and routed to BT.
+
+Wire framing on the TCP socket, both directions:
+ [4-byte big-endian length N][1-byte type][payload...] (N = 1 + len(payload))
+ type 'A' (0x41) = PCM S16LE mono 16 kHz (20 ms = 640 bytes/frame)
+ type 'C' (0x43) = control, payload is UTF-8 JSON
+
+Control (phone -> relay):
+ {"cmd":"start"[,"mac":"AA:BB:.."]} begin bridging (mac optional; auto-detected)
+ {"cmd":"stop"} stop bridging
+Control (relay -> phone):
+ {"event":"hello","mac":"..","rate":16000}
+ {"event":"started"} / {"event":"stopped"} / {"event":"error","msg":".."}
+
+Env / config:
+ CALLPIPE_PHONE phone IP (default 10.0.1.239)
+ CALLPIPE_PORT phone audio port (default 9010)
+ CALLPIPE_BT BT MAC override (default: auto-detect from bluealsa)
+"""
+
+import os
+import re
+import socket
+import struct
+import subprocess
+import sys
+import threading
+import time
+
+PHONE_HOST = os.environ.get("CALLPIPE_PHONE", "10.0.1.239")
+PHONE_PORT = int(os.environ.get("CALLPIPE_PORT", "9010"))
+BT_OVERRIDE = os.environ.get("CALLPIPE_BT")
+
+RATE = 16000
+FRAME_MS = 20
+FRAME_BYTES = RATE * 2 * FRAME_MS // 1000 # 640
+
+TYPE_AUDIO = 0x41 # 'A'
+TYPE_CTRL = 0x43 # 'C'
+
+import json
+
+
+def log(*a):
+ print("[relay]", *a, file=sys.stderr, flush=True)
+
+
+def detect_mac():
+ if BT_OVERRIDE:
+ return BT_OVERRIDE
+ try:
+ out = subprocess.check_output(["bluealsa-cli", "list-pcms"], text=True, timeout=5)
+ except Exception as e:
+ log("list-pcms failed:", e)
+ return None
+ for line in out.splitlines():
+ m = re.search(r"dev_([0-9A-Fa-f_]+)/hfphf", line)
+ if m:
+ return m.group(1).replace("_", ":")
+ return None
+
+
+def alsa_dev(mac):
+ return f"bluealsa:DEV={mac},PROFILE=sco"
+
+
+class Bridge:
+ """Bridges one accepted/connected phone socket <-> Bluetooth SCO PCM."""
+
+ def __init__(self, sock):
+ self.sock = sock
+ self.send_lock = threading.Lock()
+ self.arec = None
+ self.aplay = None
+ self.active = False
+ self.mac = None
+ self.cap_thread = None
+
+ # ---- framing ----
+ def send(self, ftype, payload=b""):
+ body = bytes([ftype]) + payload
+ with self.send_lock:
+ self.sock.sendall(struct.pack(">I", len(body)) + body)
+
+ def send_ctrl(self, obj):
+ try:
+ self.send(TYPE_CTRL, json.dumps(obj).encode())
+ except OSError:
+ pass
+
+ def _recv_exact(self, n):
+ buf = bytearray()
+ while len(buf) < n:
+ chunk = self.sock.recv(n - len(buf))
+ if not chunk:
+ return None
+ buf += chunk
+ return bytes(buf)
+
+ # ---- audio ----
+ def start_audio(self, mac):
+ if self.active:
+ return
+ self.mac = mac or detect_mac()
+ if not self.mac:
+ self.send_ctrl({"event": "error", "msg": "no HFP device connected"})
+ return
+ dev = alsa_dev(self.mac)
+ log("start audio on", dev)
+ try:
+ self.aplay = subprocess.Popen(
+ ["aplay", "-q", "-D", dev, "-f", "S16_LE", "-c", "1", "-r", str(RATE), "-t", "raw"],
+ stdin=subprocess.PIPE,
+ )
+ self.arec = subprocess.Popen(
+ ["arecord", "-q", "-D", dev, "-f", "S16_LE", "-c", "1", "-r", str(RATE), "-t", "raw"],
+ stdout=subprocess.PIPE,
+ )
+ except Exception as e:
+ self.send_ctrl({"event": "error", "msg": f"spawn failed: {e}"})
+ self.stop_audio()
+ return
+ self.active = True
+ self.cap_thread = threading.Thread(target=self._capture_loop, daemon=True)
+ self.cap_thread.start()
+ self.send_ctrl({"event": "started"})
+
+ def _capture_loop(self):
+ # Reads downlink (customer) PCM from arecord and ships it to the phone.
+ arec = self.arec
+ try:
+ while self.active and arec and arec.poll() is None:
+ data = arec.stdout.read(FRAME_BYTES)
+ if not data:
+ break
+ try:
+ self.send(TYPE_AUDIO, data)
+ except OSError:
+ break
+ except Exception as e:
+ log("capture loop error:", e)
+ log("capture loop ended")
+
+ def feed_playback(self, pcm):
+ # Writes uplink (agent) PCM to aplay -> Bluetooth mic -> customer.
+ if self.active and self.aplay and self.aplay.stdin:
+ try:
+ self.aplay.stdin.write(pcm)
+ except (BrokenPipeError, OSError):
+ pass
+
+ def stop_audio(self):
+ self.active = False
+ for p in (self.arec, self.aplay):
+ if p and p.poll() is None:
+ try:
+ p.terminate()
+ except Exception:
+ pass
+ self.arec = None
+ self.aplay = None
+
+ # ---- main receive loop ----
+ def run(self):
+ self.send_ctrl({"event": "hello", "mac": detect_mac(), "rate": RATE})
+ while True:
+ hdr = self._recv_exact(4)
+ if not hdr:
+ break
+ (n,) = struct.unpack(">I", hdr)
+ if n == 0 or n > 65536:
+ log("bad frame len", n)
+ break
+ body = self._recv_exact(n)
+ if body is None:
+ break
+ ftype = body[0]
+ payload = body[1:]
+ if ftype == TYPE_AUDIO:
+ self.feed_playback(payload)
+ elif ftype == TYPE_CTRL:
+ self._on_ctrl(payload)
+ self.stop_audio()
+
+ def _on_ctrl(self, payload):
+ try:
+ msg = json.loads(payload.decode())
+ except Exception:
+ return
+ cmd = msg.get("cmd")
+ if cmd == "start":
+ self.start_audio(msg.get("mac"))
+ elif cmd == "stop":
+ self.stop_audio()
+ self.send_ctrl({"event": "stopped"})
+
+
+def main():
+ log(f"connecting to phone {PHONE_HOST}:{PHONE_PORT}; frame={FRAME_BYTES}B @ {RATE}Hz")
+ backoff = 1
+ while True:
+ try:
+ sock = socket.create_connection((PHONE_HOST, PHONE_PORT), timeout=10)
+ sock.settimeout(None)
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ log("connected to phone")
+ backoff = 1
+ Bridge(sock).run()
+ log("phone disconnected")
+ except (ConnectionRefusedError, OSError) as e:
+ log("connect failed:", e)
+ finally:
+ try:
+ sock.close()
+ except Exception:
+ pass
+ time.sleep(backoff)
+ backoff = min(backoff * 2, 15)
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ pass
diff --git a/relay/callpipe-relay.service b/relay/callpipe-relay.service
new file mode 100644
index 0000000..4aaf82a
--- /dev/null
+++ b/relay/callpipe-relay.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=CallPipe relay (Bluetooth HFP SCO <-> phone audio TCP bridge)
+After=bluetooth.service bluealsa.service network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+# Point at the phone (the hub). Override with: systemctl edit callpipe-relay
+Environment=CALLPIPE_PHONE=10.0.1.239
+Environment=CALLPIPE_PORT=9010
+ExecStart=/usr/bin/python /opt/callpipe/callpipe-relay.py
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target