From 5e3416a895f9cc92d0df02afca6ba6329ac6d2bd Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Sat, 25 Jul 2026 18:41:15 +0500 Subject: [PATCH] working poc --- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 64 ++++- app/src/main/assets/web/app.js | 110 ++++++++ app/src/main/assets/web/index.html | 51 ++++ .../main/java/sh/sar/callpipe/MainActivity.kt | 184 ++++++++++++-- app/src/main/java/sh/sar/callpipe/hub/Hub.kt | 135 ++++++++++ .../java/sh/sar/callpipe/hub/HubService.kt | 145 +++++++++++ .../java/sh/sar/callpipe/hub/HubStatus.kt | 32 +++ .../main/java/sh/sar/callpipe/net/Frames.kt | 39 +++ .../java/sh/sar/callpipe/relay/RelayServer.kt | 119 +++++++++ .../sh/sar/callpipe/telecom/CallController.kt | 217 ++++++++++++++++ .../sar/callpipe/telecom/DialerCallService.kt | 39 +++ .../java/sh/sar/callpipe/ui/theme/Theme.kt | 60 ++--- .../java/sh/sar/callpipe/web/WebServer.kt | 114 +++++++++ app/src/main/res/drawable/ic_stat_call.xml | 10 + app/src/main/res/values/themes.xml | 4 +- docs/control-protocol.md | 48 ++++ gradle/libs.versions.toml | 4 + relay/README.md | 42 ++++ relay/callpipe-relay.py | 238 ++++++++++++++++++ relay/callpipe-relay.service | 16 ++ 21 files changed, 1604 insertions(+), 73 deletions(-) create mode 100644 app/src/main/assets/web/app.js create mode 100644 app/src/main/assets/web/index.html create mode 100644 app/src/main/java/sh/sar/callpipe/hub/Hub.kt create mode 100644 app/src/main/java/sh/sar/callpipe/hub/HubService.kt create mode 100644 app/src/main/java/sh/sar/callpipe/hub/HubStatus.kt create mode 100644 app/src/main/java/sh/sar/callpipe/net/Frames.kt create mode 100644 app/src/main/java/sh/sar/callpipe/relay/RelayServer.kt create mode 100644 app/src/main/java/sh/sar/callpipe/telecom/CallController.kt create mode 100644 app/src/main/java/sh/sar/callpipe/telecom/DialerCallService.kt create mode 100644 app/src/main/java/sh/sar/callpipe/web/WebServer.kt create mode 100644 app/src/main/res/drawable/ic_stat_call.xml create mode 100644 docs/control-protocol.md create mode 100644 relay/README.md create mode 100644 relay/callpipe-relay.py create mode 100644 relay/callpipe-relay.service 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