working poc

This commit is contained in:
2026-07-25 18:41:15 +05:00
parent e1954a3e82
commit 5e3416a895
21 changed files with 1604 additions and 73 deletions
+3 -3
View File
@@ -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)
+61 -3
View File
@@ -2,6 +2,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Control channel (WebSocket server on the phone; the Pi connects in) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Telecom / call control -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- Foreground service keeps the control server + WiFi reachable 24/7 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,18 +29,57 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Callpipe">
android:theme="@style/Theme.Callpipe"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name"
android:theme="@style/Theme.Callpipe">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Required to be eligible as the default phone app -->
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- InCallService: intercepts and controls cellular calls -->
<service
android:name=".telecom.DialerCallService"
android:permission="android.permission.BIND_INCALL_SERVICE"
android:exported="true">
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="true" />
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_RINGING"
android:value="true" />
<intent-filter>
<action android:name="android.telecom.InCallService" />
</intent-filter>
</service>
<!-- Hub: relay TCP server (:9010) + agent HTTPS/WSS server (:8443) -->
<service
android:name=".hub.HubService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="callpipe cellular-call bridge: relays call audio between a paired Bluetooth device and browser agents on the LAN" />
</service>
</application>
</manifest>
</manifest>
+110
View File
@@ -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 });
};
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CallPipe Agent</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, sans-serif; background:#0b0f14; color:#e5e9ef;
display:flex; min-height:100vh; align-items:center; justify-content:center; }
.card { width:min(420px,92vw); background:#141a21; border-radius:16px; padding:24px;
box-shadow:0 10px 40px rgba(0,0,0,.4); }
h1 { margin:0 0 4px; font-size:22px; }
.sub { color:#8b98a8; font-size:13px; margin-bottom:16px; }
.row { display:flex; align-items:center; gap:8px; margin:8px 0; }
.dot { width:10px; height:10px; border-radius:50%; background:#64748b; }
.dot.on { background:#4ade80; }
.state { font-size:15px; font-weight:600; margin:14px 0 4px; }
.num { color:#8b98a8; font-family:ui-monospace,monospace; min-height:18px; }
button { border:0; border-radius:10px; padding:12px 16px; font-size:15px; font-weight:600;
cursor:pointer; width:100%; margin-top:10px; color:#04140a; background:#4ade80; }
button.secondary { background:#243040; color:#e5e9ef; }
button.danger { background:#dc2626; color:#fff; }
button:disabled { opacity:.4; cursor:default; }
.hide { display:none; }
#log { margin-top:14px; font-family:ui-monospace,monospace; font-size:11px; color:#64748b;
white-space:pre-wrap; max-height:120px; overflow:auto; }
</style>
</head>
<body>
<div class="card">
<h1>CallPipe Agent</h1>
<div class="sub">browser softphone</div>
<div class="row"><span class="dot" id="dotWs"></span><span id="wsText">Not connected</span></div>
<div class="row"><span class="dot" id="dotRelay"></span><span id="relayText">Relay unknown</span></div>
<div class="state" id="stateText">Idle</div>
<div class="num" id="numText"></div>
<button id="btnStart">Connect &amp; enable mic</button>
<button id="btnAnswer" class="hide">Answer</button>
<button id="btnHangup" class="danger hide">Hang up</button>
<button id="btnDial" class="secondary hide">Dial…</button>
<div id="log"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+164 -20
View File
@@ -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)
}
}
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<Inet4Address>()
.firstOrNull { it.isSiteLocalAddress }
?.hostAddress
} catch (_: Exception) { null }
@@ -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<AgentChannel>())
@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)
}
}
@@ -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))
}
}
}
@@ -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> = _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)
}
}
@@ -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<Int, ByteArray>? {
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()
}
}
@@ -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
}
}
@@ -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<Snapshot> = _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<SimInfo> = 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"
}
@@ -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"
}
}
@@ -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,
)
}
}
@@ -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
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.24 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>
+3 -1
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Callpipe" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Callpipe" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/black</item>
</style>
</resources>
+48
View File
@@ -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://<phone-ip>: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.
+4
View File
@@ -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" }
+42
View File
@@ -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=<dev-ip> python /opt/callpipe/callpipe-relay.py
# with tools/phone_stub.py running on <dev-ip>, place a call routed to BT
```
+238
View File
@@ -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
+16
View File
@@ -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