working poc
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user