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