working poc
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user