working poc

This commit is contained in:
2026-07-25 18:41:15 +05:00
parent e1954a3e82
commit 5e3416a895
21 changed files with 1604 additions and 73 deletions
+61 -3
View File
@@ -2,6 +2,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Control channel (WebSocket server on the phone; the Pi connects in) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Telecom / call control -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- Foreground service keeps the control server + WiFi reachable 24/7 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,18 +29,57 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Callpipe">
android:theme="@style/Theme.Callpipe"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name"
android:theme="@style/Theme.Callpipe">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Required to be eligible as the default phone app -->
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- InCallService: intercepts and controls cellular calls -->
<service
android:name=".telecom.DialerCallService"
android:permission="android.permission.BIND_INCALL_SERVICE"
android:exported="true">
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="true" />
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_RINGING"
android:value="true" />
<intent-filter>
<action android:name="android.telecom.InCallService" />
</intent-filter>
</service>
<!-- Hub: relay TCP server (:9010) + agent HTTPS/WSS server (:8443) -->
<service
android:name=".hub.HubService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="callpipe cellular-call bridge: relays call audio between a paired Bluetooth device and browser agents on the LAN" />
</service>
</application>
</manifest>
</manifest>
+110
View File
@@ -0,0 +1,110 @@
// CallPipe agent softphone.
// Audio: 16 kHz mono PCM S16LE both ways over a binary WebSocket, matching the
// relay/phone pipeline. One ScriptProcessor both captures the mic (-> uplink)
// and plays received audio (<- downlink). Signaling is text JSON on the same WS.
const RATE = 16000;
const MAX_PLAY = RATE; // cap playback backlog at ~1s to bound latency
let ws = null;
let ac = null;
let node = null;
let micStream = null;
let playBuf = []; // queued Float32 samples to play
let callActive = false;
const $ = (id) => document.getElementById(id);
function log(m) {
const el = $("log");
el.textContent = (new Date().toLocaleTimeString() + " " + m + "\n" + el.textContent).slice(0, 2000);
}
function setDot(id, on) { $(id).classList.toggle("on", !!on); }
function show(id, on) { $(id).classList.toggle("hide", !on); }
// ---- WebSocket ----
function connect() {
ws = new WebSocket(`wss://${location.host}/agent`);
ws.binaryType = "arraybuffer";
ws.onopen = () => { setDot("dotWs", true); $("wsText").textContent = "Connected"; log("ws open"); };
ws.onclose = () => {
setDot("dotWs", false); $("wsText").textContent = "Reconnecting…";
log("ws closed"); setTimeout(connect, 2000);
};
ws.onerror = () => log("ws error");
ws.onmessage = (e) => {
if (typeof e.data === "string") { onSignal(JSON.parse(e.data)); return; }
const i16 = new Int16Array(e.data);
for (let i = 0; i < i16.length; i++) playBuf.push(i16[i] / 32768);
if (playBuf.length > MAX_PLAY) playBuf.splice(0, playBuf.length - MAX_PLAY);
};
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
// ---- Signaling ----
function onSignal(msg) {
if (msg.event === "state") {
const s = msg.state || "IDLE";
callActive = (s !== "IDLE" && s !== "DISCONNECTED");
$("stateText").textContent = prettyState(s);
$("numText").textContent = msg.number ? ((msg.direction || "") + " " + msg.number).trim() : "";
show("btnAnswer", s === "RINGING");
show("btnHangup", callActive);
show("btnDial", !callActive && ws && ws.readyState === 1);
} else if (msg.event === "relay") {
setDot("dotRelay", msg.connected);
$("relayText").textContent = msg.connected ? "Relay connected" : "Relay not connected";
} else if (msg.event === "error") {
log("error: " + msg.msg);
} else if (msg.event === "hello") {
log("hello from " + msg.app);
}
}
function prettyState(s) {
return { IDLE:"Idle", CONNECTING:"Connecting…", DIALING:"Ringing…",
RINGING:"Incoming call", ACTIVE:"Connected", HOLDING:"On hold",
DISCONNECTING:"Ending…", DISCONNECTED:"Call ended" }[s] || s;
}
// ---- Audio ----
async function initAudio() {
ac = new AudioContext({ sampleRate: RATE });
micStream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
const src = ac.createMediaStreamSource(micStream);
node = ac.createScriptProcessor(2048, 1, 1);
node.onaudioprocess = (e) => {
// capture mic -> uplink
const inp = e.inputBuffer.getChannelData(0);
if (callActive && ws && ws.readyState === 1) {
const out = new Int16Array(inp.length);
for (let i = 0; i < inp.length; i++) {
let s = Math.max(-1, Math.min(1, inp[i]));
out[i] = s < 0 ? s * 32768 : s * 32767;
}
ws.send(out.buffer);
}
// playback downlink -> speakers
const o = e.outputBuffer.getChannelData(0);
for (let i = 0; i < o.length; i++) o[i] = playBuf.length ? playBuf.shift() : 0;
};
src.connect(node);
node.connect(ac.destination);
log("audio ready @ " + ac.sampleRate + "Hz");
}
// ---- Buttons ----
$("btnStart").onclick = async () => {
$("btnStart").disabled = true;
try { await initAudio(); connect(); show("btnStart", false); }
catch (err) { log("mic failed: " + err); $("btnStart").disabled = false; }
};
$("btnAnswer").onclick = () => send({ cmd: "answer" });
$("btnHangup").onclick = () => send({ cmd: "hangup" });
$("btnDial").onclick = () => {
const n = prompt("Number to dial:");
if (n) send({ cmd: "dial", number: n });
};
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CallPipe Agent</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, sans-serif; background:#0b0f14; color:#e5e9ef;
display:flex; min-height:100vh; align-items:center; justify-content:center; }
.card { width:min(420px,92vw); background:#141a21; border-radius:16px; padding:24px;
box-shadow:0 10px 40px rgba(0,0,0,.4); }
h1 { margin:0 0 4px; font-size:22px; }
.sub { color:#8b98a8; font-size:13px; margin-bottom:16px; }
.row { display:flex; align-items:center; gap:8px; margin:8px 0; }
.dot { width:10px; height:10px; border-radius:50%; background:#64748b; }
.dot.on { background:#4ade80; }
.state { font-size:15px; font-weight:600; margin:14px 0 4px; }
.num { color:#8b98a8; font-family:ui-monospace,monospace; min-height:18px; }
button { border:0; border-radius:10px; padding:12px 16px; font-size:15px; font-weight:600;
cursor:pointer; width:100%; margin-top:10px; color:#04140a; background:#4ade80; }
button.secondary { background:#243040; color:#e5e9ef; }
button.danger { background:#dc2626; color:#fff; }
button:disabled { opacity:.4; cursor:default; }
.hide { display:none; }
#log { margin-top:14px; font-family:ui-monospace,monospace; font-size:11px; color:#64748b;
white-space:pre-wrap; max-height:120px; overflow:auto; }
</style>
</head>
<body>
<div class="card">
<h1>CallPipe Agent</h1>
<div class="sub">browser softphone</div>
<div class="row"><span class="dot" id="dotWs"></span><span id="wsText">Not connected</span></div>
<div class="row"><span class="dot" id="dotRelay"></span><span id="relayText">Relay unknown</span></div>
<div class="state" id="stateText">Idle</div>
<div class="num" id="numText"></div>
<button id="btnStart">Connect &amp; enable mic</button>
<button id="btnAnswer" class="hide">Answer</button>
<button id="btnHangup" class="danger hide">Hang up</button>
<button id="btnDial" class="secondary hide">Dial…</button>
<div id="log"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+164 -20
View File
@@ -1,47 +1,191 @@
package sh.sar.callpipe
import android.Manifest
import android.app.role.RoleManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import sh.sar.callpipe.hub.HubService
import sh.sar.callpipe.hub.HubStatus
import sh.sar.callpipe.telecom.CallController
import sh.sar.callpipe.ui.theme.CallpipeTheme
import java.net.Inet4Address
import java.net.NetworkInterface
class MainActivity : ComponentActivity() {
private val roleLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNeededPermissions()
requestDialerRole()
HubService.start(this)
setContent {
CallpipeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
Surface(modifier = Modifier.fillMaxSize()) {
HubScreen(
onAnswer = { CallController.answer(); CallController.routeBluetooth() },
onHangup = { CallController.hangup() },
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
CallpipeTheme {
Greeting("Android")
private fun requestNeededPermissions() {
val perms = mutableListOf(
Manifest.permission.READ_PHONE_STATE,
Manifest.permission.READ_PHONE_NUMBERS,
Manifest.permission.CALL_PHONE,
Manifest.permission.ANSWER_PHONE_CALLS,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms.add(Manifest.permission.POST_NOTIFICATIONS)
}
val needed = perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isNotEmpty()) requestPermissions(needed.toTypedArray(), REQ_PERMS)
}
}
private fun requestDialerRole() {
val tm = getSystemService(Context.TELECOM_SERVICE) as TelecomManager
if (packageName == tm.defaultDialerPackage) return
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rm = getSystemService(Context.ROLE_SERVICE) as RoleManager
if (!rm.isRoleAvailable(RoleManager.ROLE_DIALER) || rm.isRoleHeld(RoleManager.ROLE_DIALER)) return
rm.createRequestRoleIntent(RoleManager.ROLE_DIALER)
} else {
@Suppress("DEPRECATION")
Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER)
.putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName)
}
roleLauncher.launch(intent)
}
companion object {
private const val REQ_PERMS = 100
}
}
@Composable
private fun HubScreen(onAnswer: () -> Unit, onHangup: () -> Unit) {
val hub by HubStatus.info.collectAsStateWithLifecycle()
val call by CallController.state.collectAsStateWithLifecycle()
val ip = remembered()
Scaffold { pad ->
Column(
modifier = Modifier.fillMaxSize().padding(pad).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("callpipe hub", fontSize = 26.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground)
Text("phone = the bridge · agents ⇄ relay", fontSize = 13.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.55f))
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
StatusRow(hub.webRunning, if (hub.webRunning) "Web server up" else "Web server down")
hub.error?.let { Text("error: $it", color = Color(0xFFF87171), fontSize = 12.sp) }
StatusRow(hub.relayConnected, if (hub.relayConnected) "Relay connected" else "Relay not connected")
KeyVal("Agents connected", hub.agents.toString())
KeyVal("Agents open", "https://test.shihaam.me:${hub.webPort}")
KeyVal("(map that host to)", ip)
}
}
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
StatusRow(call.active, "Call: ${call.state}")
Text(call.number?.let { "${call.direction ?: ""} $it".trim() } ?: "no call",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f))
if (call.state == CallController.STATE_RINGING) {
Button(onClick = onAnswer, modifier = Modifier.fillMaxWidth()) { Text("Answer") }
}
if (call.active) {
Button(onClick = onHangup, modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFDC2626), contentColor = Color.White)) {
Text("Hang up")
}
}
}
}
Spacer(Modifier.height(4.dp))
Text("Answering happens from the agent browser; audio rides Bluetooth to the relay.",
fontSize = 12.sp, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f))
}
}
}
@Composable
private fun StatusRow(ok: Boolean, label: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(modifier = Modifier.size(10.dp).clip(CircleShape),
color = if (ok) Color(0xFF4ADE80) else Color(0xFF64748B)) {}
Spacer(Modifier.size(8.dp))
Text(label, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun KeyVal(key: String, value: String) {
Column {
Text(key, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
Text(value, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface)
}
}
private fun remembered(): String = localIpv4() ?: "?.?.?.?"
private fun localIpv4(): String? = try {
NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { it.isSiteLocalAddress }
?.hostAddress
} catch (_: Exception) { null }
@@ -0,0 +1,135 @@
package sh.sar.callpipe.hub
import android.content.Context
import android.util.Log
import org.json.JSONObject
import sh.sar.callpipe.relay.RelayServer
import sh.sar.callpipe.telecom.CallController
import java.util.Collections
/**
* The hub. The phone is the center of the star: it holds the cellular call
* (Telecom), talks to the relay (Bluetooth audio, over TCP) and to the agents
* (browsers, over WSS). This object splices audio between the relay and the
* agents and translates agent commands / call state.
*
* downlink: relay --onDownlinkAudio--> [Hub] --> all agents (binary)
* uplink: agent --onUplinkAudio-----> [Hub] --> relay.sendAudio
* signaling: agent JSON <-> CallController; call state --> agents
*/
object Hub {
private const val TAG = "Hub"
/** An agent browser connection (implemented by the WSS socket). */
interface AgentChannel {
fun sendAudio(pcm: ByteArray)
fun sendEvent(json: String)
}
private lateinit var appContext: Context
private var relay: RelayServer? = null
private val agents = Collections.synchronizedSet(mutableSetOf<AgentChannel>())
@Volatile private var lastState: String = CallController.STATE_IDLE
fun init(context: Context, relay: RelayServer) {
this.appContext = context.applicationContext
this.relay = relay
}
// ── Relay side (RelayServer.Listener is wired in HubService) ──
fun onDownlinkAudio(pcm: ByteArray) {
val snapshot = synchronized(agents) { agents.toList() }
for (a in snapshot) a.sendAudio(pcm)
}
fun onRelayConnected() {
HubStatus.setRelay(true)
broadcastEvent(JSONObject().put("event", "relay").put("connected", true))
}
fun onRelayDisconnected() {
HubStatus.setRelay(false)
broadcastEvent(JSONObject().put("event", "relay").put("connected", false))
}
fun onRelayEvent(json: String) {
Log.i(TAG, "relay: $json")
}
// ── Agent side (called by the WSS socket) ──
fun addAgent(ch: AgentChannel) {
agents.add(ch)
HubStatus.setAgents(agents.size)
// greet + current relay + call state
ch.sendEvent(JSONObject().put("event", "hello").put("app", "callpipe").toString())
ch.sendEvent(JSONObject().put("event", "relay")
.put("connected", HubStatus.info.value.relayConnected).toString())
ch.sendEvent(stateEvent(CallController.state.value))
}
fun removeAgent(ch: AgentChannel) {
agents.remove(ch)
HubStatus.setAgents(agents.size)
}
fun onUplinkAudio(pcm: ByteArray) {
relay?.sendAudio(pcm)
}
fun onAgentCommand(json: String) {
val obj = try { JSONObject(json) } catch (_: Exception) { return }
when (obj.optString("cmd")) {
"answer" -> {
CallController.answer()
CallController.routeBluetooth()
}
"hangup" -> CallController.hangup()
"reject" -> CallController.reject()
"dial" -> {
val number = obj.optString("number")
val sim = if (obj.has("sim") && !obj.isNull("sim")) obj.optInt("sim") else null
val err = CallController.dial(appContext, number, sim)
if (err != null) broadcastEvent(JSONObject().put("event", "error").put("msg", err))
else CallController.routeBluetooth()
}
}
}
// ── Call state (fed from HubService's CallController.state collector) ──
fun onCallState(snap: CallController.Snapshot) {
broadcastEvent(JSONObject(stateEvent(snap)))
if (snap.state != lastState) {
when (snap.state) {
CallController.STATE_ACTIVE -> {
CallController.routeBluetooth()
relay?.sendStart()
}
CallController.STATE_IDLE, CallController.STATE_DISCONNECTED -> {
relay?.sendStop()
}
}
lastState = snap.state
}
}
// ── helpers ──
private fun stateEvent(snap: CallController.Snapshot): String = JSONObject()
.put("event", "state")
.put("state", snap.state)
.put("number", snap.number ?: JSONObject.NULL)
.put("direction", snap.direction ?: JSONObject.NULL)
.toString()
private fun broadcastEvent(obj: JSONObject) = broadcastEvent(obj.toString())
private fun broadcastEvent(json: String) {
val snapshot = synchronized(agents) { agents.toList() }
for (a in snapshot) a.sendEvent(json)
}
}
@@ -0,0 +1,145 @@
package sh.sar.callpipe.hub
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import sh.sar.callpipe.MainActivity
import sh.sar.callpipe.R
import sh.sar.callpipe.relay.RelayServer
import sh.sar.callpipe.telecom.CallController
import sh.sar.callpipe.web.WebServer
/**
* Foreground service hosting the hub: the relay TCP server (:9010) and the agent
* HTTPS/WSS server (:8443), kept reachable 24/7 with WiFi + wake locks. Relays
* call-state changes into [Hub].
*/
class HubService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var relay: RelayServer? = null
private var web: WebServer? = null
private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundNotification()
acquireLocks()
startHub()
return START_STICKY
}
private fun startHub() {
if (relay != null) return
val relayServer = RelayServer(RelayServer.DEFAULT_PORT, relayListener)
relay = relayServer
Hub.init(applicationContext, relayServer)
relayServer.start()
try {
val server = WebServer(applicationContext, WebServer.DEFAULT_PORT)
server.start(0, false)
web = server
HubStatus.setWeb(true, WebServer.DEFAULT_PORT)
Log.i(TAG, "web server on :${WebServer.DEFAULT_PORT}, relay on :${RelayServer.DEFAULT_PORT}")
} catch (e: Exception) {
Log.e(TAG, "web server failed: ${e.message}")
HubStatus.setWeb(false, WebServer.DEFAULT_PORT, error = e.message ?: "web start failed")
}
// Feed call-state changes into the hub (ring agents, start/stop relay, etc.)
scope.launch {
CallController.state.collect { Hub.onCallState(it) }
}
}
private val relayListener = object : RelayServer.Listener {
override fun onRelayConnected() = Hub.onRelayConnected()
override fun onRelayDisconnected() = Hub.onRelayDisconnected()
override fun onDownlinkAudio(pcm: ByteArray) = Hub.onDownlinkAudio(pcm)
override fun onRelayEvent(json: String) = Hub.onRelayEvent(json)
}
private fun acquireLocks() {
try {
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION")
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "callpipe:wifi").apply {
setReferenceCounted(false); acquire()
}
} catch (e: Exception) { Log.w(TAG, "wifi lock: ${e.message}") }
try {
val pm = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "callpipe:cpu").apply {
setReferenceCounted(false); acquire()
}
} catch (e: Exception) { Log.w(TAG, "wake lock: ${e.message}") }
}
private fun startForegroundNotification() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(CHANNEL, "Call bridge", NotificationManager.IMPORTANCE_LOW)
.apply { description = "callpipe hub (relay + agents)" }
)
val tap = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notif: Notification = Notification.Builder(this, CHANNEL)
.setContentTitle("callpipe hub")
.setContentText("relay :${RelayServer.DEFAULT_PORT} · agents :${WebServer.DEFAULT_PORT}")
.setSmallIcon(R.drawable.ic_stat_call)
.setOngoing(true)
.setContentIntent(tap)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIF_ID, notif)
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
try { web?.stop() } catch (_: Exception) {}
try { relay?.stop() } catch (_: Exception) {}
web = null; relay = null
try { wifiLock?.release() } catch (_: Exception) {}
try { wakeLock?.release() } catch (_: Exception) {}
HubStatus.setWeb(false, WebServer.DEFAULT_PORT)
Log.i(TAG, "hub stopped")
}
companion object {
private const val TAG = "HubService"
private const val CHANNEL = "callpipe_hub"
private const val NOTIF_ID = 1
fun start(context: Context) {
context.startForegroundService(Intent(context, HubService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, HubService::class.java))
}
}
}
@@ -0,0 +1,32 @@
package sh.sar.callpipe.hub
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Observable hub status for the UI. */
object HubStatus {
data class Info(
val webRunning: Boolean = false,
val webPort: Int = 0,
val relayConnected: Boolean = false,
val agents: Int = 0,
val error: String? = null,
)
private val _info = MutableStateFlow(Info())
val info: StateFlow<Info> = _info.asStateFlow()
fun setWeb(running: Boolean, port: Int, error: String? = null) {
_info.value = _info.value.copy(webRunning = running, webPort = port, error = error)
}
fun setRelay(connected: Boolean) {
_info.value = _info.value.copy(relayConnected = connected)
}
fun setAgents(n: Int) {
_info.value = _info.value.copy(agents = n)
}
}
@@ -0,0 +1,39 @@
package sh.sar.callpipe.net
import java.io.DataInputStream
import java.io.DataOutputStream
/**
* Wire framing for the relay TCP link (matches relay/callpipe-relay.py):
* [4-byte big-endian length N][1-byte type][payload] where N = 1 + payload.size
* type 'A' (0x41) = PCM S16LE mono 16 kHz, 20 ms (640 B) frames
* type 'C' (0x43) = control, payload is UTF-8 JSON
*/
object Frames {
const val TYPE_AUDIO = 0x41
const val TYPE_CTRL = 0x43
const val RATE = 16000
const val FRAME_MS = 20
const val FRAME_BYTES = RATE * 2 * FRAME_MS / 1000 // 640
/** Reads one frame; returns (type, payload) or null on EOF / malformed. */
fun read(input: DataInputStream): Pair<Int, ByteArray>? {
val len = try { input.readInt() } catch (_: Exception) { return null }
if (len <= 0 || len > 65536) return null
return try {
val type = input.readUnsignedByte()
val payload = ByteArray(len - 1)
input.readFully(payload)
type to payload
} catch (_: Exception) { null }
}
/** Writes one frame. Caller must serialize access to [output]. */
fun write(output: DataOutputStream, type: Int, payload: ByteArray) {
output.writeInt(payload.size + 1)
output.writeByte(type)
output.write(payload)
output.flush()
}
}
@@ -0,0 +1,119 @@
package sh.sar.callpipe.relay
import android.util.Log
import sh.sar.callpipe.net.Frames
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
/**
* TCP server the CallPipe Relay (Pi) connects into. Carries call audio both ways
* plus start/stop control. Exactly one relay is expected; a new connection
* replaces any previous one.
*
* Downlink (customer voice) arrives as audio frames -> [Listener.onDownlinkAudio].
* Uplink (agent voice) is pushed out via [sendAudio]. [sendStart]/[sendStop] tell
* the relay when a call is up and routed to Bluetooth.
*/
class RelayServer(
private val port: Int,
private val listener: Listener,
) {
interface Listener {
fun onRelayConnected()
fun onRelayDisconnected()
fun onDownlinkAudio(pcm: ByteArray)
fun onRelayEvent(json: String)
}
@Volatile private var serverSocket: ServerSocket? = null
@Volatile private var conn: Socket? = null
@Volatile private var out: DataOutputStream? = null
private val writeLock = Any()
@Volatile private var running = false
fun start() {
if (running) return
running = true
Thread({ acceptLoop() }, "relay-accept").start()
}
private fun acceptLoop() {
try {
val ss = ServerSocket()
ss.reuseAddress = true
ss.bind(InetSocketAddress(port))
serverSocket = ss
Log.i(TAG, "listening on :$port")
while (running) {
val c = ss.accept()
Log.i(TAG, "relay connected from ${c.inetAddress?.hostAddress}")
closeConn() // drop any previous relay
handle(c)
}
} catch (e: Exception) {
if (running) Log.e(TAG, "accept loop error: ${e.message}")
}
}
private fun handle(c: Socket) {
conn = c
try {
c.tcpNoDelay = true
val input = DataInputStream(c.getInputStream().buffered())
synchronized(writeLock) { out = DataOutputStream(c.getOutputStream().buffered()) }
listener.onRelayConnected()
while (running) {
val frame = Frames.read(input) ?: break
val (type, payload) = frame
when (type) {
Frames.TYPE_AUDIO -> listener.onDownlinkAudio(payload)
Frames.TYPE_CTRL -> listener.onRelayEvent(String(payload, Charsets.UTF_8))
}
}
} catch (e: Exception) {
Log.w(TAG, "relay conn error: ${e.message}")
} finally {
closeConn()
listener.onRelayDisconnected()
}
}
val isConnected: Boolean get() = conn?.isConnected == true && conn?.isClosed == false
fun sendAudio(pcm: ByteArray) = send(Frames.TYPE_AUDIO, pcm)
fun sendStart() = sendControl("""{"cmd":"start"}""")
fun sendStop() = sendControl("""{"cmd":"stop"}""")
fun sendControl(json: String) = send(Frames.TYPE_CTRL, json.toByteArray(Charsets.UTF_8))
private fun send(type: Int, payload: ByteArray) {
synchronized(writeLock) {
val o = out ?: return
try {
Frames.write(o, type, payload)
} catch (e: Exception) {
Log.w(TAG, "send failed: ${e.message}")
}
}
}
private fun closeConn() {
synchronized(writeLock) { out = null }
try { conn?.close() } catch (_: Exception) {}
conn = null
}
fun stop() {
running = false
closeConn()
try { serverSocket?.close() } catch (_: Exception) {}
serverSocket = null
}
companion object {
private const val TAG = "RelayServer"
const val DEFAULT_PORT = 9010
}
}
@@ -0,0 +1,217 @@
package sh.sar.callpipe.telecom
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.InCallService
import android.telecom.TelecomManager
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Single source of truth for the phone's cellular call.
*
* In the BT-bridge architecture the phone does NO audio — audio rides Bluetooth
* HFP to the Raspberry Pi. This object only *controls* the call (place / answer /
* hang up) and publishes its state, so the control channel and the UI can both
* observe it. Commands arrive either from the local UI or from the Pi over the
* WebSocket control channel.
*/
object CallController {
private const val TAG = "CallController"
data class SimInfo(val index: Int, val label: String)
/** Immutable snapshot of the current call for observers. */
data class Snapshot(
val state: String = STATE_IDLE,
val number: String? = null,
/** "IN" | "OUT" | null */
val direction: String? = null,
) {
val active: Boolean get() = state != STATE_IDLE && state != STATE_DISCONNECTED
}
private val _state = MutableStateFlow(Snapshot())
val state: StateFlow<Snapshot> = _state.asStateFlow()
@Volatile private var service: InCallService? = null
@Volatile private var activeCall: Call? = null
// ── InCallService plumbing ───────────────────────────
fun attachService(s: InCallService) { service = s }
fun onCallAdded(call: Call) {
activeCall = call
publish(call)
}
fun onCallRemoved(call: Call) {
if (activeCall == call) activeCall = null
// If no call remains, fall back to IDLE; otherwise reflect whatever's left.
val remaining = service?.calls?.firstOrNull { it != call }
if (remaining != null) {
activeCall = remaining
publish(remaining)
} else {
_state.value = Snapshot(STATE_IDLE)
}
}
fun onStateChanged(call: Call) {
if (activeCall == null) activeCall = call
publish(call)
}
private fun publish(call: Call) {
val details = call.details
val number = details?.handle?.schemeSpecificPart
_state.value = Snapshot(
state = stateName(call.state),
number = number,
direction = directionOf(call),
)
}
// ── Commands ─────────────────────────────────────────
/**
* Place an outgoing cellular call. [simIndex] selects the SIM (0-based, in the
* order returned by [listSims]); null uses the system default.
* Returns null on success, or an error message.
*/
@SuppressLint("MissingPermission")
fun dial(context: Context, number: String, simIndex: Int? = null): String? {
val n = number.trim()
if (n.isEmpty()) return "empty number"
return try {
val tm = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
val uri = Uri.fromParts("tel", n, null)
val extras = Bundle()
if (simIndex != null) {
val accounts = tm.callCapablePhoneAccounts
if (simIndex in accounts.indices) {
extras.putParcelable(
TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, accounts[simIndex]
)
} else {
return "sim $simIndex out of range (${accounts.size} available)"
}
}
tm.placeCall(uri, extras)
Log.i(TAG, "dial $n sim=$simIndex")
null
} catch (e: SecurityException) {
Log.e(TAG, "dial denied: ${e.message}")
"permission denied (CALL_PHONE / default dialer?)"
} catch (e: Exception) {
Log.e(TAG, "dial failed: ${e.message}")
e.message ?: "dial failed"
}
}
fun answer(): Boolean {
val call = activeCall ?: return false
return try {
call.answer(call.details.videoState)
true
} catch (e: Exception) {
Log.e(TAG, "answer failed: ${e.message}"); false
}
}
fun hangup(): Boolean {
val call = activeCall ?: return false
return try {
when (call.state) {
Call.STATE_RINGING -> call.reject(false, "")
else -> call.disconnect()
}
true
} catch (e: Exception) {
Log.e(TAG, "hangup failed: ${e.message}"); false
}
}
fun reject(): Boolean {
val call = activeCall ?: return false
return try { call.reject(false, ""); true }
catch (e: Exception) { Log.e(TAG, "reject failed: ${e.message}"); false }
}
/**
* Route the active call's audio to the Bluetooth headset (the CallPipe Relay),
* so the modem's voice path goes out over HFP/SCO instead of the earpiece.
* This is what makes the relay able to see/inject the audio.
*/
fun routeBluetooth(): Boolean = try {
service?.setAudioRoute(CallAudioState.ROUTE_BLUETOOTH); true
} catch (e: Exception) {
Log.w(TAG, "routeBluetooth failed: ${e.message}"); false
}
/** Is a Bluetooth device currently available as a call-audio route? */
val bluetoothAvailable: Boolean
get() = try {
(service?.callAudioState?.supportedRouteMask ?: 0) and CallAudioState.ROUTE_BLUETOOTH != 0
} catch (_: Exception) { false }
@SuppressLint("MissingPermission")
fun listSims(context: Context): List<SimInfo> = try {
val tm = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
tm.callCapablePhoneAccounts.mapIndexed { i, handle ->
val label = try {
tm.getPhoneAccount(handle)?.label?.toString()
} catch (_: Exception) { null }
SimInfo(i, label?.ifBlank { null } ?: "SIM ${i + 1}")
}
} catch (e: Exception) {
Log.w(TAG, "listSims failed: ${e.message}"); emptyList()
}
// ── Helpers ──────────────────────────────────────────
private fun directionOf(call: Call): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return when (call.details?.callDirection) {
Call.Details.DIRECTION_INCOMING -> "IN"
Call.Details.DIRECTION_OUTGOING -> "OUT"
else -> null
}
}
return when (call.state) {
Call.STATE_RINGING -> "IN"
Call.STATE_DIALING, Call.STATE_CONNECTING -> "OUT"
else -> null
}
}
private fun stateName(s: Int): String = when (s) {
Call.STATE_NEW, Call.STATE_CONNECTING -> STATE_CONNECTING
Call.STATE_DIALING -> STATE_DIALING
Call.STATE_RINGING -> STATE_RINGING
Call.STATE_ACTIVE -> STATE_ACTIVE
Call.STATE_HOLDING -> STATE_HOLDING
Call.STATE_DISCONNECTING -> STATE_DISCONNECTING
Call.STATE_DISCONNECTED -> STATE_DISCONNECTED
Call.STATE_SELECT_PHONE_ACCOUNT -> STATE_CONNECTING
else -> "UNKNOWN($s)"
}
const val STATE_IDLE = "IDLE"
const val STATE_CONNECTING = "CONNECTING"
const val STATE_DIALING = "DIALING"
const val STATE_RINGING = "RINGING"
const val STATE_ACTIVE = "ACTIVE"
const val STATE_HOLDING = "HOLDING"
const val STATE_DISCONNECTING = "DISCONNECTING"
const val STATE_DISCONNECTED = "DISCONNECTED"
}
@@ -0,0 +1,39 @@
package sh.sar.callpipe.telecom
import android.telecom.Call
import android.telecom.InCallService
import android.util.Log
/**
* InCallService — Android routes every cellular call through this once the app is
* the default phone app. We forward all events to [CallController], which owns the
* call state and command surface.
*/
class DialerCallService : InCallService() {
private val callback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) {
Log.i(TAG, "state -> $state")
CallController.onStateChanged(call)
}
}
override fun onCallAdded(call: Call) {
super.onCallAdded(call)
Log.i(TAG, "call added: ${call.details?.handle?.schemeSpecificPart}")
CallController.attachService(this)
call.registerCallback(callback)
CallController.onCallAdded(call)
}
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
Log.i(TAG, "call removed")
call.unregisterCallback(callback)
CallController.onCallRemoved(call)
}
companion object {
private const val TAG = "DialerCallService"
}
}
@@ -1,58 +1,26 @@
package sh.sar.callpipe.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
private val CallpipeColors = darkColorScheme(
primary = Color(0xFF4ADE80), // green — "connected / active"
secondary = Color(0xFF38BDF8),
background = Color(0xFF0B0F14),
surface = Color(0xFF141A21),
onPrimary = Color(0xFF04140A),
onBackground = Color(0xFFE5E9EF),
onSurface = Color(0xFFE5E9EF),
)
/** App is a headless-ish control client — always dark, no dynamic color. */
@Composable
fun CallpipeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
fun CallpipeTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = colorScheme,
colorScheme = CallpipeColors,
typography = Typography,
content = content
content = content,
)
}
}
@@ -0,0 +1,114 @@
package sh.sar.callpipe.web
import android.content.Context
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession
import fi.iki.elonen.NanoHTTPD.Response
import fi.iki.elonen.NanoWSD
import fi.iki.elonen.NanoWSD.WebSocket
import fi.iki.elonen.NanoWSD.WebSocketFrame
import sh.sar.callpipe.hub.Hub
import java.io.IOException
import java.security.KeyStore
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
/**
* Agent-facing server: HTTPS (so the browser gets a secure context for
* getUserMedia) serving the softphone page, and a WSS endpoint at /agent that
* carries the agent's mic/speaker PCM (binary frames) plus signaling (text JSON).
*
* TLS uses the bundled PKCS12 (assets/certs/callpipe.p12, CN=test.shihaam.me) —
* agents reach the phone at https://test.shihaam.me:8443 with a hosts entry
* mapping test.shihaam.me -> phone IP.
*/
class WebServer(
private val context: Context,
port: Int,
) : NanoWSD(port) {
init {
makeSecure(buildSslFactory(context), null)
}
override fun serveHttp(session: IHTTPSession): Response {
val uri = session.uri
return when (uri) {
"/", "/index.html" -> asset("web/index.html", "text/html")
"/app.js" -> asset("web/app.js", "application/javascript")
else -> NanoHTTPD.newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found")
}
}
private fun asset(path: String, mime: String): Response = try {
val bytes = context.assets.open(path).use { it.readBytes() }
NanoHTTPD.newFixedLengthResponse(Response.Status.OK, mime, bytes.inputStream(), bytes.size.toLong())
} catch (e: IOException) {
NanoHTTPD.newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "missing: $path")
}
override fun openWebSocket(handshake: IHTTPSession): WebSocket =
if (handshake.uri == "/agent") AgentSocket(handshake)
else object : WebSocket(handshake) {
override fun onOpen() { try { close(WebSocketFrame.CloseCode.PolicyViolation, "bad path", false) } catch (_: Exception) {} }
override fun onClose(c: WebSocketFrame.CloseCode?, r: String?, b: Boolean) {}
override fun onMessage(m: WebSocketFrame) {}
override fun onPong(p: WebSocketFrame) {}
override fun onException(e: IOException) {}
}
/** One agent (browser) connection. */
inner class AgentSocket(handshake: IHTTPSession) : WebSocket(handshake), Hub.AgentChannel {
private val sendLock = Any()
override fun onOpen() {
Log.i(TAG, "agent connected")
Hub.addAgent(this)
}
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, remote: Boolean) {
Log.i(TAG, "agent disconnected")
Hub.removeAgent(this)
}
override fun onMessage(message: WebSocketFrame) {
if (message.opCode == WebSocketFrame.OpCode.Binary) {
Hub.onUplinkAudio(message.binaryPayload)
} else {
Hub.onAgentCommand(message.textPayload ?: return)
}
}
override fun onPong(pong: WebSocketFrame) {}
override fun onException(exception: IOException) {
Log.w(TAG, "agent socket error: ${exception.message}")
}
// Hub.AgentChannel
override fun sendAudio(pcm: ByteArray) = safeSend { send(pcm) }
override fun sendEvent(json: String) = safeSend { send(json) }
private inline fun safeSend(block: () -> Unit) {
synchronized(sendLock) {
try { block() } catch (e: IOException) { /* dropped */ }
}
}
}
companion object {
private const val TAG = "WebServer"
const val DEFAULT_PORT = 8443
private fun buildSslFactory(context: Context): javax.net.ssl.SSLServerSocketFactory {
val pass = "callpipe".toCharArray()
val ks = KeyStore.getInstance("PKCS12")
context.assets.open("certs/callpipe.p12").use { ks.load(it, pass) }
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(ks, pass)
val ssl = SSLContext.getInstance("TLS")
ssl.init(kmf.keyManagers, null, null)
return ssl.serverSocketFactory
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.24 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>
+3 -1
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Callpipe" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Callpipe" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/black</item>
</style>
</resources>