diff --git a/README.md b/README.md index 3ad6543..f7a6f62 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Userspace IMS/VoLTE daemon for mainline Linux phones. > [!WARNING] -> imsd currently cannot make emergency calls +> Emergency calling is unverified Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but can't be used for actual call audio. imsd implements the whole IMS client in @@ -22,20 +22,6 @@ full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via `Accept`, and carry media the same way — driven end-to-end from a stock dialer over the frozen D-Bus ABI. Current state: -| Piece | State | -|---|---| -| SIP message layer + TCP framing (`Imsd:Sip`) | done, tested | -| SDP offer/answer for AMR-WB (`Imsd:Sdp`) | done, tested | -| USIM AKA + AKAv1-MD5 digest, SIM parsers (`Imsd:Aka`) | done, tested | -| IPsec SA commands + warm-SA reader (`Imsd:Ipsec`) | done, tested | -| SIP request/response builders (`Imsd:Messages`) | done, byte-pinned | -| Outgoing + incoming call state machine (`Imsd:Engine`) | done, tested | -| Registration (fresh + warm resume + keepalive refresh, MMTEL feature tags) | done | -| RTP + AMR-WB media plane into PipeWire (`imsd-media`) | done | -| D-Bus service `net.catcrafts.IMS1` (register, Dial, Accept, HangUp, live media) | done | -| Plasma Dialer session backend (`imsd-dialerd`, org.kde.telephony.*) | done | -| DTMF (`SendDtmf`, RFC 4733) | not yet implemented | - The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the `ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev @@ -165,6 +151,7 @@ PCSCF=2001:db8::105 | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`) | +| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet | | `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `PRECOND` | `0` | `1` offers SDP QoS preconditions | | `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses | diff --git a/implementations/main.cpp b/implementations/main.cpp index 1c56fe6..dee97cf 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -41,7 +41,7 @@ import Imsd; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "0.2.7"; +constexpr const char* Version = "0.3.0"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; @@ -571,6 +571,18 @@ public: precond_ = EnvOr("PRECOND", "0") == "1"; ealgOffer_ = EnvOr("EALG", "aes-cbc"); mediaBin_ = ResolveMediaBin(); + // EMERGENCY_NUMBERS: comma-separated additions to the builtin + // 112/911 (a lab core's advertised short code, carrier extras). + std::string extra = EnvOr("EMERGENCY_NUMBERS", ""); + for (std::size_t pos = 0; pos <= extra.size();) { + std::size_t comma = extra.find(',', pos); + std::size_t end = comma == std::string::npos ? extra.size() : comma; + std::string n = extra.substr(pos, end - pos); + std::erase(n, ' '); + if (!n.empty()) emergencyExtra_.push_back(std::move(n)); + if (comma == std::string::npos) break; + pos = comma + 1; + } } void SetLocal(std::string l) { local_ = std::move(l); } @@ -672,6 +684,7 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; + std::vector emergencyExtra_; int pcscfPort_ = 5060; int rtpPort_ = 50004; bool precond_ = false; @@ -1098,20 +1111,34 @@ private: void HandleCmd(const Cmd& c) { if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Dial) { + bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); if (call_) { - Log(std::format("dial({}) refused: call in progress", c.number)); - CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; - PostAdded(bad); - PostState(c.uni, "terminated", "error"); - PostDeleted(c.uni); - return; + if (!emergency) { + Log(std::format("dial({}) refused: call in progress", c.number)); + CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; + PostAdded(bad); + PostState(c.uni, "terminated", "error"); + PostDeleted(c.uni); + return; + } + // an emergency dial preempts whatever exists (TS 22.101): + // release it; if it lingers awaiting a CANCEL's 487, abandon + // it — the late final is ignored by Call-ID mismatch + Log(std::format("emergency dial: preempting call {}", call_->Uni())); + Execute(call_->OnHangup()); + MaybeDropCall(); + if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); } } // a dead client flow fails the INVITE instantly — give the // reconnect path one immediate shot first (user action beats // the backoff timer) if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); } RefreshPani(); - call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_); + if (emergency) + Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " + "fallback plain INVITE — stage 1, no emergency registration/PDN; " + "carrier-side behavior UNVERIFIED", c.number)); + call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency); CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; PostAdded(info); bool ok = Execute(call_->Start()); diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index ea31cf4..163e1b0 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -13,10 +13,12 @@ state and returns a list of Actions for the daemon shell to carry out — send this on the client flow, answer the request on the channel it came in on, start/stop the media leg, emit this D-Bus state change, arm this deadline. That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx -finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, and the -terminating (UAS) side: 100/180 on an inbound INVITE, 200-with-answer on -Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against -recorded network dialogs with no I/O (tests/Engine). +finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the +emergency attempt chain (urn:service:sos first for a classified number, one +fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade +the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE, +200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — +unit-testable against recorded network dialogs with no I/O (tests/Engine). State/reason strings are the frozen D-Bus vocabulary the dialer maps on (dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/ @@ -94,13 +96,12 @@ export namespace imsd::engine { class CallMachine { public: - CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond) + // `emergency`: the shell classified the dialled string as an + // emergency number — the first attempt targets urn:service:sos. + CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond, bool emergency = false) : ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)), - rtpPort_(rtpPort), precond_(precond) { - d_.ruri = imsd::msg::RuriFor(number_, ctx_.id.domain); - d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local); - d_.itag = rng_.Token(10); - d_.invBranch = rng_.Token(20); + rtpPort_(rtpPort), precond_(precond), attemptSos_(emergency) { + ResetDialog(); } CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort) @@ -135,8 +136,12 @@ export namespace imsd::engine { const std::string& Reason() const { return reason_; } bool Terminated() const { return state_ == CallState::Terminated; } - // Build + queue the INVITE and arm the ring timeout. + // Build + queue the INVITE for the current attempt and arm the ring + // timeout. std::vector Start() { + if (attemptSos_) sosTried_ = true; + else + plainTried_ = true; imsd::sdp::Offer offer; offer.local = ctx_.local; offer.rtpPort = rtpPort_; @@ -239,13 +244,17 @@ export namespace imsd::engine { return a; // 1xx other than 100 Abandon(std::string_view detail) { + return Finish("local-hangup", detail); + } + // The media subprocess exited. code 3 = the downlink RTP dried up // (far-end hangup); anything else = the leg died. Either way, if the // call was up we release it network-side and tear down. @@ -375,6 +401,10 @@ export namespace imsd::engine { a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_))); cancelled_ = true; a.push_back(Deadline(Cancel487Timeout)); + } else if (auto sos = FallbackTo(0, {})) { + // the 487 never came; an abandoned sos attempt still gets + // its digits fallback rather than dying silently + Append(a, Retry(*sos, "no final after CANCEL")); } else { Append(a, Finish("error", "setup deadline expired")); } @@ -403,9 +433,67 @@ export namespace imsd::engine { imsd::msg::Dialog d_; int nextCseq_ = 2; bool cancelled_ = false; + // The emergency attempt chain (outgoing only). attemptSos_ is what + // the CURRENT attempt targets; the tried flags bound the chain to + // one step in each direction, never a loop. + bool attemptSos_ = false; + bool sosTried_ = false; + bool plainTried_ = false; + bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline std::set pracked_; std::string answerSdp_; + // Fresh dialog identity for the current attempt. A fallback INVITE + // after a final response is a new transaction AND a clean new dialog + // attempt (fresh Call-ID) — the shell's dispatch then ignores late + // responses to the abandoned attempt by Call-ID mismatch. + void ResetDialog() { + d_ = {}; + d_.ruri = attemptSos_ ? std::string(imsd::msg::SosUrn) + : imsd::msg::RuriFor(number_, ctx_.id.domain); + d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local); + d_.itag = rng_.Token(10); + d_.invBranch = rng_.Token(20); + nextCseq_ = 2; + pracked_.clear(); + answerSdp_.clear(); + cancelled_ = false; + userCancelled_ = false; + } + + // The emergency fallback chain — where the next attempt (if any) + // points. `msg` may be empty (deadline paths). + // - a sos INVITE that fails for any reason other than the user + // hanging up is re-attempted as a plain INVITE of the dialled + // digits: the pre-emergency-support behavior, so classification + // can only improve on the status quo, never block a call the old + // code would have placed. + // - a plain INVITE answered 380 Alternative Service whose body + // names emergency (TS 24.229 5.1.6: the network classified a + // number we didn't) is re-attempted as urn:service:sos. A 380 + // without that indication terminates as an error: promoting an + // arbitrary redirect would put a non-emergency call through to a + // PSAP. + std::optional FallbackTo(int code, std::string_view msg) const { + if (userCancelled_) return std::nullopt; + if (attemptSos_) return plainTried_ ? std::nullopt : std::optional(false); + if (code == 380 && !sosTried_ && Body(msg).contains("emergency")) return true; + return std::nullopt; + } + + // Switch the attempt and send its INVITE. Same uni: the dialer keeps + // seeing one continuous call, back in "dialing". + std::vector Retry(bool sos, std::string_view why) { + attemptSos_ = sos; + ResetDialog(); + state_ = CallState::Dialing; + std::vector a; + a.push_back(LogAct(std::format("call {}: {} — retrying as {}", uni_, why, sos ? "emergency (urn:service:sos)" : "plain INVITE of the digits"))); + a.push_back(StateAct("outgoing")); + Append(a, Start()); + return a; + } + static std::string_view Body(std::string_view msg) { std::size_t at = msg.find("\r\n\r\n"); return at == std::string_view::npos ? std::string_view{} diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index 38cfbc4..1d8bce1 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -133,6 +133,27 @@ export namespace imsd::msg { return s; } + // The service URN an emergency INVITE targets (RFC 5031); Request-URI and + // To both carry it (TS 24.229 5.1.6). No sos subtype: a dialled string + // does not say police/ambulance/fire. + inline constexpr std::string_view SosUrn = "urn:service:sos"; + + // Emergency-number classification. 112 and 911 are emergency numbers on + // every network (TS 22.101 §10.1.1), so they are compiled in; `extra` + // adds carrier- or lab-specific numbers (the EMERGENCY_NUMBERS list — + // e.g. a private test core's advertised short code). Exact match after + // stripping "1 1 2"-style separators; prefixed/suffixed strings ("0112", + // "1120") are ordinary numbers. SIM EF_ECC is not read yet. + inline bool IsEmergency(std::string_view target, std::span extra = {}) { + std::string t; + for (char ch : target) + if (ch != ' ' && ch != '-' && ch != '.') t.push_back(ch); + if (t == "112" || t == "911") return true; + for (const std::string& e : extra) + if (!e.empty() && t == e) return true; + return false; + } + // Map a dialled string to a Request-URI (imscall.ruri_for): E.164/national // numbers become user=phone SIP URIs at the home domain; a bare short code // gets phone-context. diff --git a/packaging/APKBUILD b/packaging/APKBUILD index a65902f..162ba4e 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -5,7 +5,7 @@ # (or replaced here by a bootstrap invocation). Until then this documents # the packaging shape. pkgname=imsd -pkgver=0.2.7 +pkgver=0.3.0 pkgrel=0 pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" url="https://forgejo.catcrafts.net/Catcrafts/imsd" diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index c440f53..8c4c09b 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -6,7 +6,7 @@ # apk-managed on the phone while crafter-build itself isn't packaged for # Alpine yet. The source tarball is produced by packaging/make-bin-tarball.sh. pkgname=imsd -pkgver=0.2.7 +pkgver=0.3.0 pkgrel=0 pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" url="https://forgejo.catcrafts.net/Catcrafts/imsd" diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 72234ee..0e6ce1d 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -100,6 +100,24 @@ namespace { return nullptr; } + // A 380 Alternative Service final; `body` empty = no emergency + // indication (a bare redirect). + std::string Alt380(std::string_view callid, std::string_view body) { + return std::format( + "SIP/2.0 380 Alternative Service\r\n" + "Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n" + "From: ;tag=it\r\n" + "To: ;tag=remote7\r\n" + "Call-ID: {}\r\nCSeq: 1 INVITE\r\n" + "Content-Type: application/3gpp-ims+xml\r\n" + "Content-Length: {}\r\n\r\n{}", + callid, body.size(), body); + } + constexpr std::string_view EmergencyXml = + "" + "" + "emergency call detected"; + // An inbound (terminating) INVITE the way KPN's P-CSCF delivers one: // two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer // at non-default payload types (104/105) with telephone-event. @@ -525,6 +543,150 @@ int main() { Check(m.Terminated(), "terminated"); } + // ================= emergency (stage 1) ================================ + + // ---- emergency dial: the INVITE targets the sos URN over the normal + // registration; a 200 is answered like any call + { + imsd::util::Rng rng(41); + CallMachine m(c, rng, "ims-call-15", "112", 50004, false, /*emergency=*/true); + auto start = m.Start(); + const Action* inv = Sent(start, "INVITE "); + Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency R-URI is the sos URN"); + Check(inv && inv->text.contains("To: \r\n"), "emergency To carries the URN"); + Check(inv && inv->text.contains("Route: \r\n"), "emergency INVITE rides the registration route"); + Check(inv && inv->text.contains("m=audio"), "emergency INVITE carries an SDP offer"); + Check(Has(start, Action::Type::SetDeadline), "emergency dial arms the timeout"); + auto a200 = m.OnInviteResponse(R200(m.CallId())); + Check(Sent(a200, "ACK ") != nullptr, "sos 200 is ACKed"); + Check(Has(a200, Action::Type::StartMedia), "sos answer starts media"); + Check(m.State() == CallState::Active, "sos call active"); + } + + // ---- sos rejected -> one fallback: a fresh-dialog plain INVITE of the + // digits, whose answer is then taken normally + { + imsd::util::Rng rng(43); + CallMachine m(c, rng, "ims-call-16", "112", 50004, false, true); + m.Start(); + std::string firstId = m.CallId(); + auto a = m.OnInviteResponse(Final(m.CallId(), 500, "Server Internal Error")); + Check(Sent(a, "ACK ") != nullptr, "sos final is ACKed"); + const Action* inv2 = Sent(a, "INVITE "); + Check(inv2 && inv2->text.starts_with("INVITE sip:112;phone-context="), "fallback INVITE dials the digits"); + Check(m.CallId() != firstId, "fallback is a fresh dialog"); + Check(inv2 && inv2->text.contains(std::format("Call-ID: {}\r\n", m.CallId())), "fallback INVITE carries the new Call-ID"); + Check(inv2 && inv2->text.contains("CSeq: 1 INVITE\r\n"), "fallback INVITE restarts CSeq"); + Check(Has(a, Action::Type::SetDeadline), "fallback re-arms the timeout"); + Check(!m.Terminated(), "machine survives into the fallback"); + auto a2 = m.OnInviteResponse(R200(m.CallId())); + Check(Has(a2, Action::Type::StartMedia), "fallback answer starts media"); + Check(m.State() == CallState::Active, "active on the fallback leg"); + } + + // ---- chain exhausted: the fallback leg's final terminates, no 3rd try + { + imsd::util::Rng rng(45); + CallMachine m(c, rng, "ims-call-17", "112", 50004, false, true); + m.Start(); + m.OnInviteResponse(Final(m.CallId(), 500, "x")); // sos fails + auto a = m.OnInviteResponse(Final(m.CallId(), 486, "Busy Here")); + Check(Sent(a, "INVITE ") == nullptr, "no third attempt"); + const Action* t = Find(a, Action::Type::State); + Check(t && t->reason == "refused-or-busy", "fallback final keeps its own reason"); + Check(m.Terminated(), "terminated when the chain is exhausted"); + } + + // ---- user hangup during the sos attempt kills the chain: 487 ends it + { + imsd::util::Rng rng(47); + CallMachine m(c, rng, "ims-call-18", "112", 50004, false, true); + m.Start(); + auto ah = m.OnHangup(); + Check(Sent(ah, "CANCEL ") != nullptr, "user hangup CANCELs the sos attempt"); + auto a = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated")); + Check(Sent(a, "INVITE ") == nullptr, "user cancel suppresses the fallback"); + const Action* t = Find(a, Action::Type::State); + Check(t && t->reason == "local-hangup", "user-cancelled sos -> local-hangup"); + Check(m.Terminated(), "terminated"); + } + + // ---- setup timeout on the sos attempt: CANCEL, then the 487 falls back + { + imsd::util::Rng rng(49); + CallMachine m(c, rng, "ims-call-19", "112", 50004, false, true); + m.Start(); + auto a1 = m.OnDeadline(); + Check(Sent(a1, "CANCEL ") != nullptr, "sos timeout CANCELs"); + auto a2 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated")); + Check(Sent(a2, "INVITE sip:112;phone-context=") != nullptr, "timeout-cancelled sos falls back to the digits"); + Check(!m.Terminated(), "chain continues after the timeout"); + } + + // ---- timeout CANCEL whose 487 never comes: second deadline falls back + { + imsd::util::Rng rng(51); + CallMachine m(c, rng, "ims-call-20", "112", 50004, false, true); + m.Start(); + m.OnDeadline(); // CANCEL + auto a = m.OnDeadline(); // no 487 arrived + Check(Sent(a, "INVITE sip:112;phone-context=") != nullptr, "silent sos attempt still gets its fallback"); + Check(!m.Terminated(), "not terminated while the chain has a step left"); + } + + // ---- answered during a DEADLINE-initiated CANCEL race: taken, not BYEd + { + imsd::util::Rng rng(53); + CallMachine m(c, rng, "ims-call-21", "112", 50004, false, true); + m.Start(); + m.OnDeadline(); // CANCEL (not the user) + auto ar = m.OnInviteResponse(R200(m.CallId())); + Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed"); + Check(Sent(ar, "BYE ") == nullptr, "deadline race keeps the answered call"); + Check(Has(ar, Action::Type::StartMedia), "deadline race starts media"); + Check(m.State() == CallState::Active, "active after the deadline race"); + } + + // ---- network 380 with the emergency indication upgrades a plain call + // to sos; if that also fails the chain ends (plain already tried) + { + imsd::util::Rng rng(55); + CallMachine m(c, rng, "ims-call-22", "0612345678", 50004, false); // NOT classified + m.Start(); + auto a = m.OnInviteResponse(Alt380(m.CallId(), EmergencyXml)); + Check(Sent(a, "ACK ") != nullptr, "380 is ACKed"); + const Action* inv = Sent(a, "INVITE "); + Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency 380 upgrades to the sos URN"); + Check(!m.Terminated(), "call survives into the sos attempt"); + auto a2 = m.OnInviteResponse(Final(m.CallId(), 500, "x")); + Check(Sent(a2, "INVITE ") == nullptr, "no loop back to the already-tried plain leg"); + Check(m.Terminated(), "terminated after the upgraded attempt fails"); + } + + // ---- a bare 380 (no emergency indication) is an error, never sos + { + imsd::util::Rng rng(57); + CallMachine m(c, rng, "ims-call-23", "0612345678", 50004, false); + m.Start(); + auto a = m.OnInviteResponse(Alt380(m.CallId(), "")); + Check(Sent(a, "INVITE ") == nullptr, "bare 380 places no emergency call"); + const Action* t = Find(a, Action::Type::State); + Check(t && t->reason == "error", "bare 380 -> error"); + Check(m.Terminated(), "terminated on bare 380"); + } + + // ---- Abandon (emergency preemption): terminate bookkeeping only + { + imsd::util::Rng rng(59); + CallMachine m(c, rng, "ims-call-24", "1233", 50004, false); + m.Start(); + m.OnHangup(); // CANCEL sent, awaiting 487 + auto a = m.Abandon("preempted by emergency dial"); + Check(Sent(a, "BYE ") == nullptr && Sent(a, "CANCEL ") == nullptr, "Abandon sends nothing further"); + Check(Has(a, Action::Type::Deleted), "Abandon deletes the call"); + Check(m.Terminated(), "abandoned machine is terminated"); + } + if (Failures == 0) std::println("Engine: all tests passed"); return Failures; } diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp index 0a06640..7d308f1 100644 --- a/tests/Messages/main.cpp +++ b/tests/Messages/main.cpp @@ -222,6 +222,44 @@ int main() { Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer"); } + // ---- emergency INVITE: byte-identical to the plain INVITE except the + // Request-URI and To carry the service URN (RFC 5031 / TS 24.229 5.1.6) + { + Dialog d; + d.ruri = std::string(SosUrn); + d.callid = "CALLID789@2001:db8::db43"; + d.itag = "ITAG123456"; + d.invBranch = "INVBRANCH0000000000"; + std::string want(Invite); + auto swap = [&](std::string_view from, std::string_view to) { + std::size_t at = want.find(from); + Check(at != std::string::npos, "sos pin template line present"); + if (at != std::string::npos) want.replace(at, from.size(), to); + }; + swap("INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n", + "INVITE urn:service:sos SIP/2.0\r\n"); + swap("To: \r\n", + "To: \r\n"); + Eq(BuildInvite(c, d, Sdp, /*precond=*/false), want, "emergency INVITE (urn:service:sos)"); + } + + // ---- IsEmergency classification + Check(IsEmergency("112"), "112 is emergency"); + Check(IsEmergency("911"), "911 is emergency"); + Check(IsEmergency("1 1 2"), "space separators stripped"); + Check(IsEmergency("1-1-2"), "dash separators stripped"); + Check(!IsEmergency("0112"), "prefixed 112 is not emergency"); + Check(!IsEmergency("1120"), "suffixed 112 is not emergency"); + Check(!IsEmergency("+31612345678"), "E.164 number is not emergency"); + Check(!IsEmergency(""), "empty string is not emergency"); + Check(!IsEmergency("199"), "extra number not classified by default"); + { + std::vector extra = {"199", "088"}; + Check(IsEmergency("199", extra), "EMERGENCY_NUMBERS extra matches"); + Check(IsEmergency("088", extra), "second extra matches"); + Check(!IsEmergency("19", extra), "extra requires exact match"); + } + // ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks) { Dialog d;