working poc
This commit is contained in:
@@ -6,9 +6,7 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "sh.sar.callpipe"
|
namespace = "sh.sar.callpipe"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
version = release(36) {
|
version = release(37)
|
||||||
minorApiLevel = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
@@ -48,6 +46,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.nanohttpd.websocket)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|||||||
@@ -2,6 +2,25 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -10,18 +29,57 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Callpipe">
|
android:theme="@style/Theme.Callpipe"
|
||||||
|
tools:targetApi="34">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.Callpipe">
|
android:theme="@style/Theme.Callpipe">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</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>
|
</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
|
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.os.Bundle
|
||||||
|
import android.telecom.TelecomManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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 sh.sar.callpipe.ui.theme.CallpipeTheme
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val roleLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestNeededPermissions()
|
||||||
|
requestDialerRole()
|
||||||
|
HubService.start(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
CallpipeTheme {
|
CallpipeTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
Greeting(
|
HubScreen(
|
||||||
name = "Android",
|
onAnswer = { CallController.answer(); CallController.routeBluetooth() },
|
||||||
modifier = Modifier.padding(innerPadding)
|
onHangup = { CallController.hangup() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
private fun requestNeededPermissions() {
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
val perms = mutableListOf(
|
||||||
Text(
|
Manifest.permission.READ_PHONE_STATE,
|
||||||
text = "Hello $name!",
|
Manifest.permission.READ_PHONE_NUMBERS,
|
||||||
modifier = modifier
|
Manifest.permission.CALL_PHONE,
|
||||||
)
|
Manifest.permission.ANSWER_PHONE_CALLS,
|
||||||
}
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
@Preview(showBackground = true)
|
perms.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
@Composable
|
}
|
||||||
fun GreetingPreview() {
|
val needed = perms.filter {
|
||||||
CallpipeTheme {
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
Greeting("Android")
|
}
|
||||||
|
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
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val CallpipeColors = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = Color(0xFF4ADE80), // green — "connected / active"
|
||||||
secondary = PurpleGrey80,
|
secondary = Color(0xFF38BDF8),
|
||||||
tertiary = Pink80
|
background = Color(0xFF0B0F14),
|
||||||
)
|
surface = Color(0xFF141A21),
|
||||||
|
onPrimary = Color(0xFF04140A),
|
||||||
private val LightColorScheme = lightColorScheme(
|
onBackground = Color(0xFFE5E9EF),
|
||||||
primary = Purple40,
|
onSurface = Color(0xFFE5E9EF),
|
||||||
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),
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** App is a headless-ish control client — always dark, no dynamic color. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CallpipeTheme(
|
fun CallpipeTheme(content: @Composable () -> Unit) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = CallpipeColors,
|
||||||
typography = Typography,
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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>
|
</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"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
nanohttpd = "2.3.1"
|
||||||
|
lifecycleRuntimeCompose = "2.11.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
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