Skip to main content

ringo_core/
phone.rs

1use tokio::sync::mpsc::Sender;
2
3pub trait Phone: Send {
4    fn register(&self, aor: &str, regint: u32);
5    /// Drop the registration: stop refreshing it and send REGISTER with
6    /// Expires: 0. The provider then has nowhere to deliver calls to and will
7    /// usually take them itself — voicemail, or whatever the account is set up
8    /// to do. Undone with [`Phone::register`]. Default: no-op (mocks).
9    fn unregister(&self) {}
10
11    fn dial(&self, number: &str);
12    fn hangup(&self);
13    fn hangup_all(&self);
14    fn accept(&self);
15    fn hold(&self);
16    fn resume(&self);
17    fn mute(&self);
18    /// Mute or unmute the *incoming* audio of the active call — what a chat
19    /// client calls being deafened. Local only: the remote end is not told and
20    /// keeps sending, so nothing has to be renegotiated to hear again.
21    ///
22    /// Independent of [`Phone::mute`], which is the microphone. Pairing the two
23    /// into one "deafen" gesture is the caller's business. Default: no-op
24    /// (mocks).
25    fn set_speaker_muted(&self, muted: bool) {
26        let _ = muted;
27    }
28
29    /// Stop the alert tone that is playing right now, without touching the
30    /// call it belongs to. The caller keeps hearing ringback and the call can
31    /// still be answered — this silences your side only, and only until the
32    /// next alert. Default: no-op (mocks).
33    fn silence_alert(&self) {}
34    fn send_dtmf(&self, digit: char);
35    fn switch_line(&self, line: usize);
36    /// Make the call with SIP `call_id` the UA's current call (baresip
37    /// `call_set_current`), so subsequent hold/resume/hangup/mute target it.
38    /// No-op if the id isn't found. Default: no-op (mocks).
39    fn select_call(&self, call_id: &str) {
40        let _ = call_id;
41    }
42    fn transfer(&self, uri: &str);
43    fn attended_transfer_start(&self, uri: &str);
44    fn attended_transfer_exec(&self);
45    fn attended_transfer_abort(&self);
46    fn add_header(&self, key: &str, value: &str);
47    fn rm_header(&self, key: &str);
48    /// Switch the audio source at runtime, e.g. `ausine,440` to send a tone,
49    /// `aufile,/path.wav` to play a file, or `aubridge,default` to go silent.
50    /// Applies to the active call.
51    fn set_audio_source(&self, spec: &str);
52
53    /// RTP media stats (jitter/loss/RTT + estimated MOS) for the active call, or
54    /// the last finished call's snapshot. `None` if unavailable (no call yet, or
55    /// before the first RTCP report). Default: `None` (mocks).
56    fn media_stats(&self) -> Option<crate::event::MediaStats> {
57        None
58    }
59
60    /// The negotiated audio codec on the active call. `None` if there's no call
61    /// or it isn't negotiated yet. Default: `None` (mocks).
62    fn audio_codec(&self) -> Option<crate::event::CodecInfo> {
63        None
64    }
65
66    /// DTMF digits received on the active/last call so far, in order (e.g.
67    /// `"1234#"`). Default: empty (mocks).
68    fn received_dtmf(&self) -> String {
69        String::new()
70    }
71
72    /// All SIP headers received on the INVITE of the call with `call_id`, in
73    /// order, consuming them from the store. Empty if none / unknown call.
74    /// Default: empty (mocks).
75    fn inbound_headers(&self, call_id: &str) -> Vec<(String, String)> {
76        let _ = call_id;
77        Vec::new()
78    }
79
80    /// Arm a custom SIP response for the next inbound INVITE(s): answer with
81    /// `scode`/`reason` and the extra `headers` (each a full header line like
82    /// `Contact: <sip:…>`, no trailing CRLF) instead of accepting the call.
83    /// Sticky until [`Phone::disarm_invite_response`]. Arm it *before* the call
84    /// arrives for deterministic behaviour.
85    fn arm_invite_response(&self, scode: u16, reason: &str, headers: Vec<String>);
86
87    /// Clear any armed response — subsequent inbound INVITEs are accepted again.
88    fn disarm_invite_response(&self);
89
90    /// Deflect inbound calls to `contact` with a `302 Moved Temporarily` (plus an
91    /// RFC 5806 `Diversion` header when `diversion` is set). Thin wrapper over
92    /// [`Phone::arm_invite_response`].
93    fn deflect_incoming(&self, contact: &str, diversion: Option<&str>) {
94        let mut headers = vec![format!("Contact: <{contact}>")];
95        if let Some(div) = diversion {
96            headers.push(format!("Diversion: <{div}>"));
97        }
98        self.arm_invite_response(302, "Moved Temporarily", headers);
99    }
100}
101
102// ─── Test mock ────────────────────────────────────────────────────────────────
103//
104// A simple `Phone` impl that records every command as a `(String, String)` pair
105// into a channel. Used by the TUI tests to verify that user interactions
106// produce the expected phone commands. The real implementation lives in
107// `baresip/phone.rs` and calls libbaresip C functions via FFI.
108
109pub struct MockPhone {
110    cmd_tx: Sender<(String, String)>,
111}
112
113impl MockPhone {
114    pub fn new(cmd_tx: Sender<(String, String)>) -> Self {
115        Self { cmd_tx }
116    }
117
118    fn send(&self, cmd: &str, params: &str) {
119        if let Err(e) = self.cmd_tx.try_send((cmd.to_string(), params.to_string())) {
120            crate::rlog!(Warn, "cmd dropped: {} ({})", cmd, e);
121        }
122    }
123}
124
125impl Phone for MockPhone {
126    fn register(&self, _aor: &str, regint: u32) {
127        self.send("uareg", &format!("{} 0", regint));
128    }
129    fn unregister(&self) {
130        self.send("unregister", "");
131    }
132    fn dial(&self, number: &str) {
133        self.send("dial", number);
134    }
135    fn hangup(&self) {
136        self.send("hangup", "");
137    }
138    fn hangup_all(&self) {
139        self.send("hangupall", "");
140    }
141    fn accept(&self) {
142        self.send("accept", "");
143    }
144    fn hold(&self) {
145        self.send("hold", "");
146    }
147    fn resume(&self) {
148        self.send("resume", "");
149    }
150    fn mute(&self) {
151        self.send("mute", "");
152    }
153    fn silence_alert(&self) {
154        self.send("silence", "");
155    }
156    fn set_speaker_muted(&self, muted: bool) {
157        self.send("speaker", if muted { "off" } else { "on" });
158    }
159    fn send_dtmf(&self, digit: char) {
160        self.send("sndcode", &digit.to_string());
161    }
162    fn switch_line(&self, line: usize) {
163        self.send("line", &line.to_string());
164    }
165    fn transfer(&self, uri: &str) {
166        self.send("transfer", uri);
167    }
168    fn attended_transfer_start(&self, uri: &str) {
169        self.send("atransferstart", uri);
170    }
171    fn attended_transfer_exec(&self) {
172        self.send("atransferexec", "");
173    }
174    fn attended_transfer_abort(&self) {
175        self.send("atransferabort", "");
176    }
177    fn add_header(&self, key: &str, value: &str) {
178        self.send(
179            "uaaddheader",
180            &format!("{}={} 0", key, uri_header_escape(value)),
181        );
182    }
183    fn rm_header(&self, key: &str) {
184        self.send("uarmheader", &format!("{} 0", key));
185    }
186    fn set_audio_source(&self, spec: &str) {
187        self.send("ausrc", spec);
188    }
189    fn arm_invite_response(&self, scode: u16, reason: &str, headers: Vec<String>) {
190        self.send(
191            "armresponse",
192            &format!("{scode} {reason} [{}]", headers.join("; ")),
193        );
194    }
195    fn disarm_invite_response(&self) {
196        self.send("disarmresponse", "");
197    }
198}
199
200/// Percent-encode a SIP header value for the `uaaddheader` baresip command.
201///
202/// Why: baresip's command parser splits params on the first space, so a raw
203/// space silently truncates the value. baresip then runs the value through
204/// `uri_header_unescape`, which only accepts the RFC 3261 `hvalue` charset
205/// (alnum, unreserved marks, and `[ ] / ? : + $`). Anything outside that set
206/// must be `%HH`-encoded so it survives the round trip.
207fn uri_header_escape(s: &str) -> String {
208    let mut out = String::with_capacity(s.len());
209    for &b in s.as_bytes() {
210        if is_hvalue(b) {
211            out.push(b as char);
212        } else {
213            out.push_str(&format!("%{:02X}", b));
214        }
215    }
216    out
217}
218
219fn is_hvalue(b: u8) -> bool {
220    b.is_ascii_alphanumeric()
221        || matches!(
222            b,
223            b'-' | b'_'
224                | b'.'
225                | b'!'
226                | b'~'
227                | b'*'
228                | b'\''
229                | b'('
230                | b')'
231                | b'['
232                | b']'
233                | b'/'
234                | b'?'
235                | b':'
236                | b'+'
237                | b'$'
238        )
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn passes_through_hvalue_chars() {
247        assert_eq!(uri_header_escape("Foo-Bar_1.0"), "Foo-Bar_1.0");
248        assert_eq!(uri_header_escape("a[b]/c?d:e+f$"), "a[b]/c?d:e+f$");
249    }
250
251    #[test]
252    fn escapes_spaces_and_specials() {
253        assert_eq!(uri_header_escape("Foo Bar"), "Foo%20Bar");
254        assert_eq!(uri_header_escape("a=b;c,d"), "a%3Db%3Bc%2Cd");
255        assert_eq!(uri_header_escape("100%"), "100%25");
256    }
257
258    #[test]
259    fn escapes_non_ascii() {
260        assert_eq!(uri_header_escape("ä"), "%C3%A4");
261    }
262
263    fn make_phone() -> (MockPhone, tokio::sync::mpsc::Receiver<(String, String)>) {
264        let (tx, rx) = tokio::sync::mpsc::channel(8);
265        (MockPhone::new(tx), rx)
266    }
267
268    #[test]
269    fn add_header_emits_uaaddheader_with_ua_index_zero() {
270        let (phone, mut rx) = make_phone();
271        phone.add_header("X-Foo", "bar");
272        let (cmd, params) = rx.try_recv().expect("one message");
273        assert_eq!(cmd, "uaaddheader");
274        assert_eq!(params, "X-Foo=bar 0");
275    }
276
277    #[test]
278    fn add_header_encodes_unsafe_chars_in_value() {
279        let (phone, mut rx) = make_phone();
280        phone.add_header("History-Info", "<sip:1@x.com>;index=1");
281        let (_, params) = rx.try_recv().expect("one message");
282        assert_eq!(params, "History-Info=%3Csip:1%40x.com%3E%3Bindex%3D1 0");
283    }
284
285    #[test]
286    fn add_header_preserves_order_across_multiple_calls() {
287        let (phone, mut rx) = make_phone();
288        phone.add_header("History-Info", "<sip:1@x.com>;index=1");
289        phone.add_header("History-Info", "<sip:2@x.com>;index=2");
290        phone.add_header("X-Other", "hi");
291
292        let msgs: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
293        assert_eq!(msgs.len(), 3);
294        assert_eq!(msgs[0].1, "History-Info=%3Csip:1%40x.com%3E%3Bindex%3D1 0");
295        assert_eq!(msgs[1].1, "History-Info=%3Csip:2%40x.com%3E%3Bindex%3D2 0");
296        assert_eq!(msgs[2].1, "X-Other=hi 0");
297    }
298}