diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 783fa74..f64b0a5 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,7 +6,7 @@ name: package # plain 'apk upgrade' without an fp6-img image run. # # Build: crafter-build cross-compiles against an Alpine aarch64 sysroot -# (packaging/build-package.sh — the README's "Cross-compiling" flow), the +# (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the # test suite runs natively, and packaging/APKBUILD.binary wraps the result. # This needs no privileged runner: it runs in an alpine:edge container on # the ordinary arch-latest runner. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c9f446 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1030 @@ + + + +# AGENTS.md — working on imsd + +This file is for coding agents and for anyone changing imsd: what the +repository contains, how it is built, tested and packaged, how the daemon +behaves at runtime, and the conventions a change has to follow. The README +is the short, human-facing introduction; everything it leaves out is here. +Where this file and the code disagree, the code is right and this file has a +bug — fix it in the same change. + +## 1. What imsd is + +imsd is a userspace IMS/VoLTE client for mainline Linux phones. The modem +firmware on such phones brings up the IMS PDN but cannot carry call audio, so +imsd does the whole IMS client itself: SIP registration with USIM AKA +authentication over kernel IPsec (ESP), call signalling for outgoing and +incoming calls, and the RTP media plane (AMR-WB, AMR, G.711) wired into +PipeWire. A stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh +and GNOME Mobile — places and receives real VoLTE calls on a commercial +network through it. + +It talks to the modem through ModemManager (`mmcli`) and `qmicli` over QRTR +(the USIM AKA run goes through a UIM logical channel), installs its security +associations with `ip xfrm`, and plays and records audio with the PipeWire +tools (`pw-play`, `pw-record`). In practice that means a Qualcomm modem on a +mainline kernel with working WWAN/ModemManager integration, kernel ESP, and +PipeWire. The verified device/carrier combinations are in the README. + +## 2. Repository layout + +| Path | What it is | +|---|---| +| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. | +| `Makefile` | Distro-packaging mirror of `project.cpp`: the same clang module pipeline, the same flags. Keep the two in sync when products, module partitions or link flags change. | +| `lint-rules.h` | The house-style lint rules (§6), registered from `project.cpp` through `AddProjectLintRules`. Project-local on purpose. | +| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. | +| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | +| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | +| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | +| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | +| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume. | +| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | +| `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). | +| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | +| `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. | +| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg. Pure std + POSIX, dlopen's the AMR codecs. | +| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | +| `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | +| `tests//main.cpp` | The seven unit-test suites: `Util`, `Aka`, `Ipsec`, `Messages`, `Engine`, `Sip`, `Sdp`. | +| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, the two APKBUILDs and the CI helpers (§9). | +| `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). | +| `bin/`, `build/` | Build outputs (crafter-build and make respectively). Ignored. | + +Also ignored by `.gitignore`, and never to be committed: `*.raw`, `*.rtp`, +`*.stats`, `imsreg.state`, `imsd-*.tar.gz` — captures and runtime state carry +the subscriber's IMSI/MSISDN and network addresses. + +## 3. Build products + +Six products come out of the tree. C++26 modules throughout, `import std;` +in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. + +| Product | Kind | Built from | Links | Notes | +|---|---|---|---|---| +| `imsd-core` | static library | the `Imsd` module and its seven partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. | +| `imsd` | executable | `implementations/main.cpp` | `imsd-core`, `imsd-bus`, gio-2.0, pthread | The daemon. Default product. | +| `imsd-bus` | static library | `interfaces/ImsdBus.cppm` | gio-2.0 | The polkit gate (`net.catcrafts.IMS1.Voice`) imsd and imsd-ofonod both link. A translation daemon that runs as root on the system bus reaches imsd as root, which polkit always allows, so it gates its own callers through this or it is a bypass. | +| `imsd-media` | executable | `implementations/media.cpp` | `-ldl -lpthread` | The RTP/AMR data plane, spawned once per call. No GLib, no core. A separate process for crash isolation and for the far-end-hangup exit-code contract (§7.6). `-- --product=media`. | +| `imsd-dialerd` | executable | `implementations/dialerd.cpp` | gio-2.0 | Plasma Mobile integration: a session daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, bridging to imsd. GDBus translation only. A separate process because those are per-user session-bus names a root system daemon cannot own. `-- --product=dialerd`. | +| `imsd-ofonod` | executable | `implementations/ofonod.cpp` | `imsd-bus`, gio-2.0 | GNOME Calls integration: a system daemon owning `org.ofono`, presenting imsd as an oFono modem. GDBus translation plus the polkit gate on its own callers. `-- --product=ofonod`. | + +The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so +that every decision in it has a unit test.** New protocol logic goes into a +partition with a test. The shell (`main.cpp`) holds protocol decisions no +test reaches — some older than 0.3.10, some added by it: the reg-event +subscription schedule (§7.4: its retry +ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the +registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: +which message is a REGISTER's final, the dispatch of requests arriving +meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the +12 s wait restarted on a provisional; `Reregister`'s one re-challenge); the +inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local +refusals and network silence, and the catch sites that sort a failed refresh +(the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt +(`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the +status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a +refusal's Retry-After fed as a hold, the old registration written off after +any failed resume), the `OnLink` read that writes the old registration off +when its address has left the netdev (`FreshRecover` after a failed move, +`AddressGone`), and `PacketServiceUp`'s read of +ModemManager before a silent cycle is counted; the shell's reaction to a NOTIFY +terminating our binding (the flow closed at once, or kept under a call and +closed when the call ends, a reconnect cycle's or the bring-up resume's wait +ended by it — the reducer's verdict is pinned, the reaction is not); and the +holds file's codec (`PersistHolds`/`LoadHolds`: aging by boot seconds, the +pdn/boot match, defaults for absent keys — proven on the phone only, captures +45- and 70- of 2026-09-25-polkit-voice). For the schedule, a reducer next to +`RegRecovery`, with a Subscription-State parser in `Imsd:Sip` pinned by +`tests/Sip`, is the follow-up that moves it; a pure format/parse pair in +`Imsd:Engine` is the holds file's. + +## 4. Building + +### 4.1 Native, with crafter-build + +Prerequisites: crafter-build, clang with libc++, lld, and the gio-2.0 headers +(`glib2` on Arch, `glib-dev` on Alpine). + +```sh +crafter-build # bin/imsd----/imsd +crafter-build -- --product=media # bin/imsd-media-…/imsd-media +crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd +crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod +crafter-build test # the seven unit-test suites +crafter-build lint # the house-style rules of lint-rules.h +``` + +Output directories under `bin/` are named `----`; +the `./bin/imsd-*/imsd` globs in §8.3 rely on that. + +### 4.2 With make (distro packaging) + +GNU make, `clang++` with libc++ and the libc++ std module sources (`std.cppm`: +package `llvm-runtimes` on Alpine, `libc++` on Arch; override `STD_CPPM` if it +lives elsewhere), lld, pkg-config and the gio-2.0 headers. + +```sh +make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} +make check # the same seven suites +make install # DESTDIR/PREFIX staged install, including the packaging/ files +``` + +The Makefile forces `CXX = clang++` (abuild exports `CXX=c++`, which is gcc +and has no `--precompile`); an explicit `make CXX=…` still wins. One flag set +is used for every translation unit because BMIs are rejected on +compiler-option mismatch — per-target extras are limited to `-I`/`-D` and +`-l`/`-L`. From `pkg-config gio-2.0` only `-I`/`-D` and `-l`/`-L` pass +through: it also emits `-pthread`, which would config-mismatch the shared +`std` BMI (`project.cpp` filters identically; pthread lives in libc on every +platform targeted). The BMI file names must stay `-.pcm` — +that is how clang resolves `import :Part;` under `-fprebuilt-module-path`. + +`make install` places: the four binaries (`imsd-media` under `libexec`), +`ims-pdn-up.sh` and `imsd-handover.sh` under `libexec`, `imsd.service` and +`imsd-ofonod.service`, `imsd-dialerd.desktop` in `/etc/xdg/autostart`, the +D-Bus policies (`net.catcrafts.IMS1.conf`, `imsd-ofono.conf`), the polkit +policy, `imsd-ofono-restart-calls.sh`, the GNOME Calls gschema override and +the `80-imsd-ofono.preset`. + +### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS) + +```sh +packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN +crafter-build -- --target=aarch64-alpine-linux-musl \ + --sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \ + --march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 +# the same flags after `crafter-build test --target=aarch64-alpine-linux-musl` +# run the suite under qemu-aarch64 against the sysroot +``` + +`make-sysroot.sh` needs no root, qemu or device: it downloads +`apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl, +libc++, glib and their headers, compiler-rt, libunwind, gcc for its +`crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links +against). In a cross build `project.cpp` does not ask the host's pkg-config +(it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0` +and `-I=/usr/lib/glib-2.0/include`, which clang resolves inside the sysroot. +This is the flow CI runs (§9.4). + +## 5. Tests + +`crafter-build test` and `make check` run the same seven suites. Each suite +is a plain `main()` with a `Check(bool, message)` counter: failures print +`FAIL: …`, success prints `: all tests passed`, and the exit status is +the failure count for six suites — non-zero short of a multiple of 256 — and +1 for `Engine`. Seven `all tests passed` lines from `make check`, on every +commit of a range (§11), is the project's definition of green. + +What the suites pin: + +- `Util` — tokens, IPv6 detection and host/port formatting, hex/base64, MD5. +- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli` + text parsers, the AUTHENTICATE APDU and its response parser. +- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the + Security-Server mechanism selection, the SA reader. +- `Messages` — every outgoing SIP message byte-for-byte, cross-generated from + the Python reference stack imsd replaced. +- `Sip` — the text layer's edge cases (empty header values, line anchoring, + keepalive discards), `ResponseOf`, refused finals with reason headers, + reginfo documents. A change that flips one of those tests is a behaviour + change, not a cleanup. +- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's + own payload types and octet-align mode, the `CODECS` override. +- `Engine` — the call state machine driven through recorded network dialogs + (reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer + races, busy/error finals, the terminating side's 100/180/200/486/487/488, + G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the + emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle + schedule, Retry-After, which waits a Dial may cut short, when the old + registration counts as gone, the holds a restart takes over, a binding the + network terminated). + +Rules: + +- A decision in `imsd-core` gets a test in the suite that owns the partition. + Fixtures are recorded network traffic where any exists, inline fixtures + only where no recording exists. +- Behaviour that lives in the shell (`main.cpp`: timers, the reg-event + subscription schedule, the audio-session resolution, the hand-over scripts) + is proven on a phone where a bench can reach it; the commit message says + in one sentence that it was tested, and the arms no bench has reached are + named here. The known gaps: the reg-event subscription schedule (the + arming and KPN's + `noresource` closure have run on hardware, the 60 s retry only on a + pre-fix build that renewed that closure; the retry on this code, the + renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the + renew arm and the back-off's survival across a refresh have run nowhere — + §3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery + step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the + `Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine` + only); a refused initial or fresh + REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or + stayed silent — pinned in `tests/Engine` only); the write-off of the old + registration by a recovery's fresh attempt that fails past the SA setup + (such attempts are on record only as a bring-up's fatal exit, which does + not pass that arm — pinned in `tests/Engine` only); a refused + refresh counted + towards the escalation, and the escalation by two of them (KPN refuses a + refresh only with the 403 that says the registration is gone — pinned in + `tests/Engine` only); of the throttled-attempt chain in recovery (a + throttled fresh attempt, the old SA retried once meanwhile with the stored + binding's Contact and refused 403, the deferred attempt accepted at the + hold's end — run 2026-10-03 on the series as it then stood) the deferred + attempt made by the + same process, since a restart inside the hold made it from the holds file + instead (the same process made it on 2026-09-24, on the build before the + policy's rework); the address moves + writing `old_gone` to the holds file (the move benches predate that + field); a SUBSCRIBE final read inside a REGISTER wait (KPN answers within + the second); the shell's handling of a NOTIFY that terminates our own + binding (§3; every captured termination is a previous contact's O2 + `deactivated`, and a deregistration during a call cannot be staged); + `SipFlow::Connect`'s errno arms — socket() failing, a UDP connect() + error, a bind() refused while the address is still on the link — which + no bench reaches without fault injection (§3); a coverage gap on the air + (no bench stages a radio gap without an RF shield) — the packet-service + read's rule has run through a fake `mmcli` only (2026-10-03: `searching` + and `detached` not counted, `connecting` with `unknown` counted), its + other states, ModemManager not answering and its second site (a refresh + unanswered over a reconnected flow) not at all, and `tests/Engine` pin + nothing of it; the REGISTER wait's diverting arms — a datagram skipped on + the init port, a response to another transaction ignored, a provisional + restarting the wait, a request (an INVITE among them) dispatched and + answered under the wait; an incoming call answered from a session's + dialer since the polkit gate — imsd-dialerd's `Accept` by the active + session, or GNOME Calls' `Answer` through imsd-ofonod's gate (imsd's own + `Accept` gate has run: refused for an account without a seat session and + for nobody, accepted for root, 2026-10-04); a refused + final carrying reason headers (KPN's 403s carry none — the rendering is pinned + in `tests/Sip` only); an `AUDIO_USER` pin in imsd's own environment that + names no account (the logind fallback) or an account without a PipeWire + socket (a silent call) — a pin naming an account with one ran, 2026-10-04. + `DetectLocal`'s three-tier address ranking for a run without `LOCAL` + (§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the + netdev and two cut-down ones). +- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both + formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 + PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams + for driving the leg against a synthetic RTP peer without a phone. +- `imsd --session` and `imsd-ofonod --session` own their names on the session + bus for development without root, polkit or a phone (§8.3). + +## 6. Code style and lint + +`crafter-build lint` reports the rules of `lint-rules.h`; `crafter-build format` +applies the transform rules. The rules, by name: + +| Rule | Meaning | +|---|---| +| `spdx-header` | Line 1 `// SPDX-License-Identifier: …`, line 2 `// SPDX-FileCopyrightText: …`, line 3 blank. Line 4 may carry `// lint-disable-file …` where a file needs one. | +| `no-tabs` | Spaces only. | +| `naming` | Types, aliases, functions, statics, namespace-scope globals and `constexpr` constants are PascalCase; locals and parameters camelCase; one trailing underscore marks a member or shadows a keyword (`label_`, `requires_`). Heuristic, report-only. | +| `enum-class` | `enum class`, never a plain `enum`. | +| `no-iostream-print` | `std::println` (and `std::println(std::cerr, …)`), never `std::cout` / `<<` to `std::cerr`. | +| `no-char-pointer` | `std::string` / `std::string_view` over `char*`. C interop stays: argv, getenv/setenv, dlerror, `c_str()`/`.data()` call sites, `reinterpret_cast`, `extern "C"`. | +| `format-concat` | `std::format` instead of `+` with a string literal; simple single-line chains are rewritten automatically, anything else is reported. | +| `fixed-width-types` | `std::int32_t` and friends instead of `short`/`int`/`long` (transform); lines with `main`/`argc`/`argv` or `extern "C"` keep C ints. Nearly every source file disables this one on line 4. | +| `single-declaration` | One declaration per statement; `Type a = x, b = y;` is auto-split, declarators with pointers, references, parens or templates are reported. | +| `brace-style` | K&R: a `{` alone on a line after a `)`/`else`/`do`/`try` header joins the header line. Standalone scope blocks stay. | +| `if-single-line` | A single-statement `if` body joins the `if` line when the result stays within 250 columns and neither line carries a comment. | +| `wrap-join` | Wrapped call arguments and `&&`/`||`/`|` continuations join back onto one line when the expression fits in 250 columns. | +| `paren-spacing` | No padding inside parentheses. | +| `trim-trailing-ws` | No trailing whitespace. | +| `final-newline` | The file ends with a newline: one is appended when it is missing. | + +Beyond the rules: + +- Comments explain constraints the code cannot show — a network's observed + behaviour, a spec clause, the reason a shape was chosen — and name their + evidence (a dated bench, a capture) when they rest on one. They do not + narrate what the code does. +- Log lines are the operator's interface (§7.8). A new state the daemon can + be in gets a journal line that names it; existing lines are grepped for by + scripts (`imsd-handover.sh` reads a pre-0.3.10 daemon's `bring-up deferred: … + next fresh registration in N min`), so changing one is an interface change. +- GLib only in `main.cpp`, `dialerd.cpp`, `ofonod.cpp` and `ImsdBus.cppm`. + `media.cpp` is std + POSIX. The partitions are std only. + +## 7. How the daemon works + +### 7.1 Processes and buses + +`imsd` runs as root on the system bus as `net.catcrafts.IMS1` (object +`/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`; the frozen ABI, §8). +The bus policy `packaging/net.catcrafts.IMS1.conf` lets any local account +reach it; placing, answering and ending a call is polkit's +`net.catcrafts.IMS1.Voice` (`packaging/net.catcrafts.IMS1.policy`, shaped like +ModemManager's Voice action: the active local session and root — not an ssh +login, an inactive session or a system service account). Reads (`GetStatus`, +`GetCalls`) stay open, as ModemManager's are to every local account: the call +signals reach them all anyway, the status (the address, the P-CSCF, the +phone number) travels in no signal. + +Two threads: a dedicated engine thread runs the SIP loop (blocking framed +receive on the protected client flow, the protected-port listeners for +terminating requests, media reaping, the keepalive refresh); the GLib main +loop owns the bus. D-Bus method calls push commands to the engine queue; +engine events reach the bus through `g_idle_add`, where the calls table and +the status snapshot live (main thread only). + +Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the +`imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and +payload type as arguments and the codec and audio knobs in its environment +(§7.6, §10.2). + +The desktop daemons are translation layers: `imsd-dialerd` runs as the +session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in +place of plasma-dialer's `modem-daemon`, whose autostart the package hides +through `/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`); +`imsd-ofonod` runs as root on the system bus (`imsd-ofonod.service`, +`Conflicts=ofono.service`). Both consume the frozen ABI and add nothing to it. + +### 7.2 Bring-up + +`imsd.service` runs two `ExecStartPre=` steps before the daemon: + +1. `/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager + (§7.3) and writes `/run/imsd.env` with `DEV=` (the connected netdev) and + `LOCAL=` (the PDN address ModemManager assigned). +2. `/usr/libexec/imsd-handover.sh` (`ExecStartPre=-`, failure-tolerant) hands + a pre-0.3.10 daemon's throttle wait over (§7.5). + +The unit then reads `EnvironmentFile=-/run/imsd.env` and +`EnvironmentFile=-/etc/imsd.env` (the admin's file wins) and starts +`/usr/bin/imsd`. systemd reads those files anew for every command it spawns, +so each `ExecStartPre=` already sees the values the previous one wrote. +`TimeoutStartSec=900` covers the bring-up's worst case (a 2-minute modem wait, +a 5-minute registration gate, connect retries). `Restart=on-failure`, +`RestartSec=120`; `KillMode=mixed` with `TimeoutStopSec=10`; `StateDirectory=imsd` +(`/var/lib/imsd`). + +The daemon's own bring-up (`BringUp()` in `main.cpp`): + +1. `PCSCF` must be set (no P-CSCF discovery from the PDN's PCO yet). +2. `qmicli -d qrtr://0 --uim-get-card-status` finds the ready USIM (slot, AID); + `mmcli -m any` gives the primary SIM path and the IMEI (for the + `+sip.instance` URN); `mmcli -i ` gives IMSI and MCC/MNC, from which + the IMPI/IMPU/realm are derived. `mmcli --location-get` fills + `P-Access-Network-Info`. +3. **Warm resume** when an SA pair is already in the kernel and the state + file (`STATE_FILE`, default `/var/lib/imsd/imsreg.state`) describes the + registration: reconnect through the existing SA and send a refresh + re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed + + true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0` + forces a fresh registration. A resume the network refuses is not fatal: the + fresh registration replaces its SAs. +4. **Fresh registration** otherwise (`FRESH registration`): an unprotected + REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and + the `Security-Server` list, the AKA run over a UIM logical channel + (`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed + with `ip xfrm`, the protected REGISTER over the negotiated transport + (§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event + SUBSCRIBE (§7.4). +5. A keepalive re-REGISTER refresh at half the granted lifetime, within + 120–1800 s (`REFRESH_INTERVAL`), only while idle. + +The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, +`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`. + +A failed fresh bring-up exits non-zero and systemd retries after +`RestartSec` — except a bring-up the network refused or throttled, which +waits in process on the refusal schedule (§7.4) with nothing to reconnect +over, then tries again (`bring-up deferred: …`). A registered daemon whose +address leaves the netdev with no PDN address there to move to also exits +(`engine fatal: … is no longer on qmapmux0.0 — exiting for systemd +restart`) — not at once: the loss is +noticed at the next recovery step, the refresh (every 1800 s on KPN's +7200 s grant), whose +re-REGISTER gets no reply and whose reconnect cannot bind the address (dev +phone 2026-10-02, the ims bearer disconnected under a registered daemon: +14 min from the disconnect to the exit). After `RestartSec` (120 s) +`ims-pdn-up.sh` runs again and imsd registers on the PDN's address (the +same bench: a new prefix, `REGISTERED (fresh)` six seconds into the new +start). + +### 7.3 The ims PDN (`packaging/ims-pdn-up.sh`) + +Finds or creates the `ims` bearer through ModemManager, connects it and +configures the muxed netdev. Idempotent; a non-zero exit fails the unit. + +- Waits up to 120 s for a modem, then gates the connect attempts on network + registration (`IMS_REG_TIMEOUT`, default 300 s): some carriers attach ~2 min + after boot. +- Connects by **3GPP profile index** (`IMS_PROFILE_ID`, default: looked up by + APN in `qmicli --wds-get-profile-list=3gpp`), not by APN string: a + profile-indexed request is exempt from the modem's attach-family forcing + that refuses an APN-string IPv6 request when the attach PDN was granted + IPv4-only (Odido, O2 UK, Swisscom). An empty profile list is the modem still + coming up; a list without the APN is final only once the modem is + registered (the list can be partial while it enables). A leftover + APN-string bearer is never reused and is deleted when connecting by index. +- Sets `accept_ra_defrtr=0` on the netdev before link-up: the PDN's router + advertisement must never make the ims PDN the phone's IPv6 default route (on + IPv4-only wifi every AAAA-first client would send its SYNs into the IMS + walled garden and hang). The RA's prefix and MTU still apply. An RA default + route already present (an imsd restart) is flushed. +- Sets the PDN MTU ModemManager reports on the link (1280 on the FP6), adds + the address, waits out DAD, and installs a host route to `PCSCF` + (`dev `, no gateway — a raw-IP netdev without header_ops). The PDN + is IPv6-only, so `PCSCF` must be an IPv6 address; anything else on the IMS + network (its DNS servers, a traceroute target) needs its own host route + (`ip -6 route add dev qmapmux0.0`). imsd installs a host route per + media peer itself. +- Opens imsd's protected ports 45061–45062 (TCP and UDP) on `qmapmux*` in + nftables when a `inet filter` table exists (pmOS's default INPUT chain drops + inbound on `qmapmux*`, which silently killed every network-initiated + request after ESP decapsulation). Best-effort. +- Writes `/run/imsd.env`: `DEV=`, `LOCAL=`. imsd + registers on the address the network assigned, not on the kernel's + temporary SLAAC address from the RA, which the netdev lists first and + retires after about two days (the SAs and the Contact would go with it). + +Its own settings (read from the environment, else from `/etc/imsd.env` for +manual runs): `IMS_APN` (default `ims`), `IMS_IP_TYPE` (`ipv6`), +`IMS_PROFILE_ID` (empty = look up; `none` = connect by APN string), +`IMS_REG_TIMEOUT` (300), `PCSCF`. + +### 7.4 Registration recovery (`RegRecovery`, `Imsd-Engine.cppm`) + +What the daemon does when the registration stops working after bring-up, as +a pure reducer the shell feeds with the clock and the network's answers. Two +kinds of failure reach it: a **refusal** (the network answered a refresh or a +fresh REGISTER with a final other than 200/401) and **silence** (the flow died +and reconnecting through the SA, or the refresh over it, got no answer). +Neither is retried for long on the same registration (TS 24.229 5.1.1.4 and +RFC 5626 have the UE register anew). Before this reducer existed the daemon +looped reconnect + re-REGISTER on one Call-ID and SA forever (O2 UK, +2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect cycles over +2 h 39 min). + +The policy, with its constants: + +| Step | Rule | +|---|---| +| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | +| Escalate to a fresh registration | After two refused refreshes (`RefusalsBeforeFresh`) or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | +| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | +| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | +| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | +| A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). | +| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed: no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. | +| A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. | +| Success | Resets everything. | + +The network's waits are **holds**, and a restart of imsd (an upgrade restarts +it) does not cut them short: `PersistHolds` writes them to the holds file +(`HOLDS_FILE`, default `/run/imsd.holds`) as JSON — `pdn` (the PDN's own +address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`, +`fresh_hold`, `throttle_minutes` (the schedule's place), `old_gone` (the old +registration is gone: a 403 once a fresh registration is due, an attempt past +the SA setup, a binding the network terminated, an older address that left +the netdev, and every bring-up that goes on to register fresh — its resume +failed or there was nothing to resume) — and `LoadHolds` takes them +over at the next start for the same `pdn` and boot id, waiting them out +before the resume (a refresh's `Retry-After`) or before the fresh +registration (a fresh attempt's schedule or `Retry-After`, a terminated +binding's wait); with `old_gone` set the start does not resume over the old SAs at +all, it waits the fresh hold out and registers fresh — also after a start +during an outage, whose resume and fresh attempt both fail without an answer: +the next start then registers fresh while the network still holds the old +registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where +0.3.9's retried resume would have recovered at once). A file without the key +reads as not gone; the 0.3.9 hand-over (`imsd-handover.sh`) writes the key as +true, since the wait it hands over follows a throttled initial REGISTER, at +which KPN drops the old registration (§13). A reboot or another PDN address +starts clean. The schedule's place is kept +even after the holds run out, so a restart does not start it over at 21 min. +When nothing is held the file is removed — when it is a regular file; +`/dev/null` switches the knob off and anything that is not a regular file is +emptied, never unlinked. The recovery policy's waits and the holds are +measured on the boot clock (`CLOCK_BOOTTIME`), so a suspend counts; the +keepalive refresh gate, the CRLF keepalive and the reg-event retry and +renewal timers run on the steady clock and count awake seconds only. + +The **reg-event subscription** (RFC 3680; the network's own view of the +binding) is one live dialog per registration, subscribed anew ahead of the +lifetime the S-CSCF granted. Refused or unanswered, it is retried after 60 s +doubling to 10 min (a `Retry-After` honoured up to an hour; a registration +refresh neither brings a scheduled retry forward nor starts its back-off +over — the back-off resets when a subscription starts afresh or is +accepted). When the S-CSCF ends it +for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`, +`giveup`, `probation`) it is renewed; after any other reason — `noresource`, +`rejected`, a reason-less end — the next registration refresh subscribes anew. An +earlier subscription's NOTIFY is still read for the binding's state, but its +end is not ours to renew (KPN closes a registration's previous subscription +with `noresource` when a new one replaces it). A NOTIFY that **terminates the +binding** triggers a fresh registration — at once, or after the network's +`retry-after` (up to an hour); after the call, when one is up (one exception: +a REGISTER wait that still gets its 200 after such a NOTIFY, a call having +kept the flow, leaves that registration standing). After +`rejected` and `unregistered` it waits on the refusal schedule, a wait no +`Dial` cuts short: the binding is gone, so nothing is retried meanwhile. One +termination counts once, however many NOTIFYs report it (a repeated +`probation` report may extend its hold from its own `retry-after`). The schedule is +shell code in `main.cpp`, not a reducer (§3); its hardware coverage per arm +is in §5. + +A registration resumed on an older address (a pre-0.3.10 temporary one) +moves to the PDN's own address in place: with its next fresh registration, or +when the older address leaves the netdev first, which takes that registration +with it. + +### 7.5 Upgrades and the 0.3.9 hand-over + +An apk upgrade (or an `apk add` of a package whose daemon an `apk del` left +running) restarts `imsd` and `imsd-ofonod` once the transaction is done: the +packages' `post-upgrade`/`post-install` scripts mark the units +`needs-restart` (`systemctl set-property … Markers=+needs-restart`, after a +`daemon-reload` when the unit is not-found because its file was just +reinstalled) and pmOS's systemd trigger runs `enqueue-marked`. A call in +progress ends. The restart resumes the registration over its existing SAs, +or goes on waiting out a hold the network had set (§7.4). A stopped unit +keeps no marker and `enqueue-marked` leaves it stopped (dev phone, systemd +262~rc3, 2026-10-03). + +A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so +the restart registered fresh at once instead of waiting the schedule out. +For that one hop: `imsd.post-upgrade` records the running +daemon's `InvocationID` in `/run/imsd.handover` (when no holds file exists); +at the next start `imsd-handover.sh` reads that finished invocation's last +bring-up line from the journal and, when it is the throttle's (`bring-up +deferred: fresh-SA throttle active (spi-s=0) — … next fresh registration in +N min`; the old daemon's other deferrals, after a 401 it could not use, are +not handed over), ages it on the monotonic clock (`__MONOTONIC_TIMESTAMP` against +`/proc/timer_list`; the phone's wall clock can read 1970 until its first NTP +sync, and the monotonic clock stops in a suspend, so the wait can only come +out long, never short of the network's window), and writes what is left to +the holds file as 0.3.10 keeps it — for the address this start registers on +when that is the address the wait was earned on (the old daemon logged its +`LOCAL=` once at start); on another address — the normal hop, 0.3.9 on the +kernel's temporary address and 0.3.10 on the PDN's own, which holds no +registration (§13) — only the verdict that the old registration is gone is +written and the new start registers fresh at once (dev phone 2026-10-03: +accepted, registered 16 s after 0.3.9's refused attempt) — +`LOCAL` as systemd hands it to the unit's commands (the `/etc/imsd.env` pin +if there is one, else what `ims-pdn-up.sh` just wrote), unquoted the way imsd +itself gets it. Reading at the start rather than at the upgrade means a wait +that ended in between — the old daemon making another attempt — is +read as it ended. When the old address cannot be read the wait is handed +over anyway; when the wait cannot be timed, or this start has no `LOCAL`, the +journal says it was not handed over. + +### 7.6 Calls and the media plane + +`CallMachine` owns one call's SIP dialog, outgoing or incoming, and never +touches a socket, a clock or a subprocess: each event returns `Action`s for +the shell (send this on the client flow, answer that request on the channel +it came in on, start/stop the media leg, emit this D-Bus state change, arm +this deadline). States and reasons are the frozen D-Bus vocabulary: +`dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons +`outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / +`refused-or-busy` / `error`. + +- Outgoing: INVITE with the SDP offer (AMR-WB + AMR + telephone-event by + default; `CODECS` restricts and orders; `PRECOND=1` adds IR.92 + preconditions), reliable 18x with PRACK, 2xx ACK and media start, CANCEL + before answer, BYE after. Busy and error finals end the call with + `refused-or-busy` or `error`. +- Incoming: an INVITE on a protected-port listener is validated before it is + announced to the UI; 100 and 180 (reliable when the caller requires + 100rel); `Accept` sends the 200 with the SDP answer at the offer's own + payload types and octet-align mode; a remote CANCEL gets 200 + 487; an + unusable offer 488 naming what was offered; ring timeout 480. A landline + gateway's G.711-only or AMR-NB offer rings and is answered at its own + payload types. +- Emergency: a number `IsEmergency` classifies (112, 911, plus + `EMERGENCY_NUMBERS`; SIM `EF_ECC` is not read yet) is tried as + `urn:service:sos` first, with one fallback to a plain INVITE of the digits; + a plain INVITE answered `380 Alternative Service` with an emergency body is + re-attempted as `urn:service:sos`. +- `SendDtmf` is a stub (RFC 4733 not implemented). +- Incoming calls reach imsd only when the network's T-ADS routes the call to + the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and + `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — + a network/modem-integration matter, not a daemon defect. + +`imsd-media` is one process per call: `imsd-media + `. It binds the advertised +RTP port (`RTP_PORT`, default 50004), sends uplink frames from `pw-record` +through the encoder, captures the downlink, and with `PLAY=1` reconstructs +the media clock from RTP timestamps so `pw-play` stays real-time-paced +through far-end DTX silence. Codecs: AMR-WB (16 kHz, the default), AMR +narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR +frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient +(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd +(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); +G.711 is a table. **Exit code 3 means the downlink dried up** for +`MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on +carriers whose BYE never reaches our SAs is the reliable teardown trigger. +Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1` +captures the raw downlink next to them. + +**Audio session.** Call audio goes through the PipeWire of one account. +imsd resolves it for each call from logind: the user session active on +seat0 (`loginctl show-seat seat0`; a greeter does not count) when it has a +`/run/user//pipewire-0` socket, and passes it to `imsd-media` as +`AUDIO_USER`. `AUDIO_USER` set in imsd's own environment pins an account +instead; one that is not an account falls back to logind. imsd as packaged +(root, on the system bus) logs the resolution at start and when a call's +media starts (`audio session: …`), `imsd-media` once per call. An `imsd-media` started without the variable at all (by hand, or +by an imsd older than 0.3.10 while an upgrade replaces it) tries the +account `user`, its old default. + +### 7.7 Runtime files + +| Path | Written by | Content | +|---|---|---| +| `/etc/imsd.env` | the admin | configuration (§10); `PCSCF=` is required. The apk adds a drop-in with `ConditionPathExists=/etc/imsd.env` (§9.2), so an unconfigured phone does not boot into a failing unit | +| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN | +| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds and the schedule's place (§7.4) | +| `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` | +| `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot | +| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume | +| `/var/lib/imsd/*.stats`, `*.rtp`, `*.raw` | imsd, imsd-media | per-call media stats, raw RTP (`RTP_DUMP`), SIP dumps (`DUMP_SIP`; mode 0600 — they carry the IMSI/MSISDN) | +| `$XDG_STATE_HOME/imsd/` (else `~/.local/state/imsd/`), `$XDG_RUNTIME_DIR/imsd.holds` (else `/tmp/imsd.holds`) | imsd | the same files for a non-root (`--session`) run | + +### 7.8 Journal vocabulary + +Lines the operator (and the scripts) look for, all prefixed `imsd: `: + +- `LOCAL=… P-CSCF=…`, `SIP transport: …`, `SIM slot=… IMSI=… domain=…` at start. +- `RESUME via existing SA (cseq N)`, `REGISTERED (resumed + true-refreshed)`, + `FRESH registration`, `Security-Server: …`, `REGISTERED (fresh)`. +- `bring-up deferred: …; next: fresh registration in N s` — one line; `in N + min` from a minute (the pre-0.3.10 form `bring-up deferred: … next fresh + registration in N min` is what `imsd-handover.sh` reads from an older + daemon's journal), `a hold the network set before this start stands (…)`, + `the refusal schedule stands at N min from before this start: …`. +- `…; next: fresh registration in N s` (`in N min` from a minute, `now`), + `registering fresh (recovery attempt N)`, + `fresh registration deferred/refused/failed: …`, `reconnected + re-registered`, + `recovered by a fresh registration`. +- `the registration's SAs are on X, not the PDN's own address Y: resuming + there until the next fresh registration`, `the fresh registration moves + from X to the PDN's own address Y`, `back on X: the old registration stands + until the next fresh attempt`, `X has left DEV: the registration on it is + gone; staying on Y; next: …`, `X is no longer on DEV: the registration on + it is gone; on the PDN's own address Y now; next: …`. +- `engine fatal: … — exiting for systemd restart`. +- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. +- `audio session: …` at start and at each call's media start; `CODECS override active: …`. +- A refused REGISTER or SUBSCRIBE is logged with its status line and + any `Retry-After` / `Warning` / `Reason` / `Server` headers. +- `connect: no answer from the P-CSCF N times (…)` when the protected + connect series ends unanswered. + +From the scripts: `ims-pdn-up: …`, `imsd-handover: …`, +`imsd-ofono-restart-calls: …`. + +## 8. D-Bus ABI and desktop integration + +### 8.1 `net.catcrafts.IMS1` (frozen) + +Object `/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`. The ABI is +frozen — the two translation daemons depend on it, and a new desktop needs a +new translation daemon, not a change here. + +| Member | Signature | +|---|---| +| `Dial(number)` | `s → s` (callUni) | +| `HangUp(callUni)` | `s` | +| `Accept(callUni)` | `s` | +| `SendDtmf(callUni, tones)` | `ss` | +| `GetCalls()` | `→ aa{sv}` | +| `GetStatus()` | `→ a{sv}`: `registered` (b), `local` (s), `pcscf` (s), `expiry` (x), `cseq` (x), `ppi` (s) | +| `CallAdded` | `s a{sv}` | +| `CallStateChanged` | `s s s` (callUni, state, reason) | +| `CallDeleted` | `s` | +| `RegistrationChanged` | `b` | + +`Dial`, `HangUp`, `Accept` and `SendDtmf` are gated by polkit (§7.1); a +refused caller gets `org.freedesktop.DBus.Error.AccessDenied`, and polkit not +answering is logged with the sender. A translation daemon that runs as root +on the system bus must gate its own callers through `imsd-bus`, or it is a +bypass of `net.catcrafts.IMS1.Voice`. + +### 8.2 Plasma Mobile (`imsd-dialerd`) + +A drop-in for plasma-dialer's `modem-daemon`, owning the same session-bus +names and objects (`org.kde.telephony.CallUtils` at +`/org/kde/telephony/CallUtils/tel/mm`, likewise `DeviceUtils` and +`UssdUtils`; the `/tel/mm` suffix is hard-coded in plasma-dialer's QML plugin +and kde-telephony-daemon — keep it). plasma-dialer, kde-telephony-daemon and +spacebar run unmodified. ABI details verified against plasma-dialer 6.7.3 are +in the header of `implementations/dialerd.cpp` (`CallData` marshalled as a +struct wrapping `a{sv}`, struct-wrapped int32 enums in MMCallState numbering, +an empty callUni meaning the active call, `callsChanged` with the full vector +on every add/delete). It lists no modem while imsd is unregistered. + +### 8.3 GNOME Calls, Phosh, GNOME Mobile (`imsd-ofonod`) + +Presents imsd as an oFono modem: `org.ofono` with a `Manager` at `/`, one +`Modem` + `VoiceCallManager` at `/imsd`, and a `VoiceCall` object per live +call — what GNOME Calls' bundled `ofono` provider plugin drives; Calls, Phosh +and GNOME Shell run unmodified. `org.ofono` is the seam because it is the +only one a third party can implement: `org.gnome.Calls.Call` is what Calls +*exports* so a shell can observe and control a call that already exists +(Accept, Hangup, SendDtmf, Silence — no Dial), and Phosh consumes it from +Calls — its call UI needs nothing extra once Calls has a working origin; +Calls' own backends are libpeas provider plugins (`mm`, `ofono`, `sip`, +`dummy`) against private headers no distro installs. +Consumer-shaped details verified against calls 50.0 are in the header of +`implementations/ofonod.cpp`. Verified on a Fairphone 6 (postmarketOS, +dbus-broker) with GNOME Calls 50.0: outgoing and incoming calls with audio +both ways, answered and hung up through `org.gnome.Calls.Call` — the surface +Phosh drives. The answered calls predate the polkit gate (§5). + +Setup: `apk add imsd-ofono` enables `imsd-ofonod.service` by preset and ships +a gschema override setting `org.gnome.Calls autoload-plugins` to `['ofono']` +(the per-user equivalent is `gsettings set org.gnome.Calls autoload-plugins +"['ofono']"`); a hand install (`make install`) enables the unit itself with +`systemctl enable --now imsd-ofonod` (it is a system service: `org.ofono` is +a system-bus name) and compiles the schema override. Drop `mm` rather than adding `ofono` alongside it: the +ModemManager provider would offer the modem's CS voice path as a second +origin, and on a phone whose voice path is imsd that origin carries no audio. +Never run it beside a real `ofonod` (one name, one owner; +the unit has `Conflicts=ofono.service` and the daemon exits if it loses the +name). Running `imsd-dialerd` at the same time is fine (different bus, +different consumer). One dbus-broker +detail is load-bearing and lives in `packaging/org.ofono.conf`: dbus-broker +checks a broadcast against the sender's send policy for every name the +receiver owns, so root (imsd) must be allowed `send_destination="org.ofono"` +or imsd's call signals never reach the daemon. + +Known gaps, all inherited rather than introduced: + +- USSD is refused with `org.ofono.Error.NotSupported` and not advertised in + the modem's `Interfaces`: the IMS stack has no USSD path. +- No hold, multiparty, transfer or call waiting (`org.ofono.Error.NotImplemented`): + imsd is a single-call stack. +- Per-call caller-ID withholding is refused rather than silently ignored: + imsd cannot honour a CLIR request, and failing is safer than dialling with + the caller ID exposed. +- Phosh's emergency-call screen lists nothing: `imsd-ofonod` publishes + `org.ofono.VoiceCallManager.EmergencyNumbers` (112, 911, plus + `EMERGENCY_NUMBERS`), oFono's standard place for it, but Calls' ofono + provider never reads it: it makes no `GetProperties` call on the VoiceCallManager + and returns NULL for the origin's `emergency-numbers`, which is what + `org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from (the + ModemManager provider fills its equivalent via + `mm_sim_dup_emergency_numbers()`). An upstream fix would close this. + Dialling an emergency number from the dialpad is unaffected — that reaches + imsd's own classifier. +- DTMF is forwarded to imsd, where `SendDtmf` is a stub — the same + limitation Plasma has. +- Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's + `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a + new origin but leaves the old origin's `CallAdded` handler connected, so + after one such cycle every call is added twice, and after a few the stale handler runs on + freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` (measured + on the FP6). Calls-side; an upstream fix belongs in + `plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own + restarts and registration drops from triggering it, the + `VoiceCallManager` interface is listed permanently (`Online` + tracks registration; `Dial` fails with "imsd is not registered" / "imsd is + not running"), and only this daemon's own restart cycles the origin — after + which Calls dials nothing until it restarts, so the unit's `ExecStartPost` + (`imsd-ofono-restart-calls.sh`) restarts the Calls daemon of every user + running one. The first start since boot leaves a running Calls alone. + +Development without a phone or root: + +```sh +./bin/imsd-*/imsd --session & # imsd's ABI on the session bus +./bin/imsd-ofonod-*/imsd-ofonod --session +busctl --user call org.ofono / org.ofono.Manager GetModems +``` + +## 9. Packaging, CI and releases + +### 9.1 The two APKBUILDs + +- `packaging/APKBUILD.binary` is **the** packaging since 2026-09-02: it wraps + a crafter-build binary cross-compiled per §4.3 (bundled by + `packaging/make-bin-tarball.sh` together with the units, scripts and + policies) into the apks CI publishes. `pkgver` is taken from the `Version` + constant in `implementations/main.cpp` by `build-package.sh`. + Subpackages: `imsd-ofono` (the GNOME Calls backend, opt-in) and + `imsd-systemd` (the unit files; `install_if` systemd — ordered after + `imsd-ofono` because `systemd()` amoves all of `usr/lib/systemd/system`). +- `packaging/APKBUILD` is the from-source route through the Makefile (`make`, + `make check`, `make install`) for distros that build from source. It is + not the binary route's equal: no `imsd-systemd` subpackage and none of what + §9.2 lists for the apk (imsd's preset, the enable link, the condition + drop-in, the `/etc/skel` override), no `libc++` in `depends`; only the + `imsd-ofono` file list matches. Its `pkgver` is not bumped per release + (0.3.0) and its `source=` expects a `v$pkgver` tag archive, which the + repository has no tags for. + +Both declare `provides="81voltd=$pkgver-r$pkgrel"`: it satisfies +`soc-qcom-modem`'s dependency and excludes the real package, because 81voltd +serves the modem's own ims-PDN requests, races imsd for the PDN and flaps it +with a new prefix every ~2.5 min — two IMS stacks cannot share one PDN. +`depends` lists `modemmanager`, the AMR codec libraries, `pipewire-tools` and +`polkit` (the gate fails closed without it). There is no OpenRC service: the +unit's sequencing is only tested under systemd. + +### 9.2 What the package installs and enables + +`imsd.service` is enabled by preset (`80-imsd.preset`, plus the +`multi-user.target.wants` link) and gated by a drop-in +`ConditionPathExists=/etc/imsd.env`, so VoLTE survives reboots without a +manual `systemctl enable` and an unconfigured phone does not boot into a +failing unit. `imsd-ofonod.service` is enabled the same way by `imsd-ofono`. +All of that is the apk's doing (`APKBUILD.binary`'s `package()`): `make +install` places the unit files and `80-imsd-ofono.preset` only, so a hand +install runs `systemctl enable imsd` (and `imsd-ofonod`) itself and gets no +`/etc/imsd.env` condition. +The main package hides kde-telephony's `modem-daemon` autostart for accounts +created after it is installed (`/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`) +because `imsd-dialerd` owns those names. + +### 9.3 Install scripts + +`imsd.post-upgrade`, `imsd-ofono.post-upgrade` and `imsd-systemd.post-upgrade` +mark their units `needs-restart` (§7.5); `imsd.post-upgrade` also records the +old invocation for the 0.3.9 hand-over. Each `*.post-install` is a symlink to +its `post-upgrade`: `apk del` leaves the daemon running, so a later `apk add` +is an upgrade for that daemon and apk runs post-install for it. A first +install finds no unit active and marks nothing. `imsd-systemd` marks +`imsd.service` too, since it ships the unit file and apk installs it after +`imsd`. + +### 9.4 CI and what a push means + +`.forgejo/workflows/package.yml` runs on every push to `main` (and on +dispatch): in an `alpine:edge` container, `packaging/build-package.sh` +provisions clang/lld/llvm-runtimes/glib-dev/abuild, fetches the musl build of +crafter-build, builds the sysroot, cross-compiles the four products, runs the +suite natively, wraps the result with `APKBUILD.binary`, and the publish step +uploads the apks to the Forgejo Alpine registry +(`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The +registry answers 409 for an already-published version and the step treats +that as "nothing to do", so **a push releases exactly when the `Version` +constant changes**. This is the only producer of the imsd apk: fp6-img +images install the published package from the registry, and +installed phones upgrade with plain `apk upgrade`. **A push to `main` is a +release to real users' phones.** A release is a range of commits ending in a +bump commit titled `imsd X.Y.Z` that changes only the `Version` constant. + +## 10. Configuration reference + +Everything is environment variables. The packaged unit reads +`/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (yours, wins +on conflict). Minimum viable `/etc/imsd.env`: + +```sh +# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your +# carrier's P-CSCF address. Find it in a stock-firmware capture or your +# carrier's IMS documentation. +PCSCF=2001:db8::105 +``` + +### 10.1 imsd + +| Variable | Default | Meaning | +|---|---|---| +| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | +| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | +| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | +| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | +| `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | +| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | +| `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`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | +| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | +| `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. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property | +| `RTP_PORT` | `50004` | local RTP port advertised in SDP | +| `PRECOND` | `0` | `1` offers SDP QoS preconditions | +| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | +| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | +| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | + +The protected client/server ports are fixed at 45061/45062 +(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires +updating the firewall rules `ims-pdn-up.sh` installs. + +### 10.2 imsd-media (per call, set through imsd's environment) + +| Variable | Default | Meaning | +|---|---|---| +| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, as imsd passes them (`imsd-media` started by hand defaults both to off) | +| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio; imsd sets it per call from logind's seat0 session (§7.6). Set in imsd's own environment it pins an account instead | +| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain, as imsd passes them (`imsd-media` started by hand defaults `GAIN` to `1.0`) | +| `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) | +| `DTX` | `0` | encoder discontinuous transmission | +| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | +| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | +| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | +| `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | + +### 10.3 ims-pdn-up.sh and imsd-ofonod + +`IMS_APN`, `IMS_IP_TYPE`, `IMS_PROFILE_ID`, `IMS_REG_TIMEOUT` and `PCSCF` for +the bring-up script (§7.3). `imsd-ofonod` reads `EMERGENCY_NUMBERS` (from +`/etc/imsd.env` through its unit) and `IMSD_BUS=session|system` to override +only the imsd side of its bus choice. + +## 11. Making changes + +- **Commit messages.** Subject `area: what changes` — the area is the module + partition, program, script or `packaging`/`ci`/`tests`/`README` the change + lives in (several joined by commas), the rest a short lowercase clause + with no trailing period, imperative or declarative. The body says why — + the field incident or the spec clause — and, where the change alters + behaviour, in one sentence how it was tested. A version bump is a commit + titled `imsd X.Y.Z` that changes only the `Version` constant and has no + body. +- **No AI attribution trailers.** Commits carry no `Assisted-by:`, + `Co-Authored-By:` or similar lines, whoever or whatever drafted them. +- **Signed commits.** Every commit is GPG-signed with the repository's + configured identity (`git config user.signingkey`, `commit.gpgsign`). Do + not override the identity from the environment. +- **One change, one commit; a fix goes into the commit that introduced the + problem** while a range is unpublished (rebase), never as a follow-up on + top. Every commit of a range builds and passes the seven suites on its own. +- **Keep `project.cpp` and the Makefile in sync**, and the two APKBUILDs' + file lists with `make install`, `make-bin-tarball.sh` and + `APKBUILD.binary`'s `package()` when a packaged file is added. +- **The README and this file follow the code in the same commit.** A new + knob gets its row in §10; a new state its journal line in §7.8; a new file + under `/run` or `/var/lib` its row in §7.7. +- **Compatibility notes are kept where the mechanism lives**, not in a + changelog the repository does not have: the 0.3.9 hand-over is documented + in §7.5 because `imsd.post-upgrade` ships in every later package and the + path stays live for any direct upgrade from an older daemon. +- **Never commit captures or state** (`*.raw`, `*.rtp`, `*.stats`, + `imsreg.state`): they carry the subscriber's identity. + +## 12. Security considerations + +- imsd runs as root (xfrm, qmicli). Its attack surface on the bus is the + frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an + ungated write member, and do not add a root translation daemon that + forwards calls without gating its own callers through `imsd-bus`. +- The bus policies deliberately let any local account *reach* both names; + authorization is polkit's, not the policy file's. Keep it that way: a + policy that names an account breaks the moment the account is called + something else. +- SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and + addresses. Dumps are mode 0600 under the state dir, off by default + (`DUMP_SIP=0`), and never leave the phone in a commit. +- The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming + the default route and gives the P-CSCF a host route; imsd adds one per + call for the media peer (§7.3). Nothing else is routed over it. + A change that routes anything else over it needs a reason and a bench. +- The fixed protected ports (45061/45062) are opened in nftables for + `qmapmux*` only, by the bring-up script. + +## 13. Carrier and platform facts the code relies on + +Observed on real networks and encoded in the policy; cite them when touching +the related code. + +- **KPN's fresh-SA throttle** — the `bring-up deferred: fresh-SA throttle + active (spi-s=0)` case — refuses a fresh SA pair on an address while the + registration on that address is live, whatever its age or last refresh: + refused 11 s, 23 min and 2 h 58 min into a registration, 4 and 6.6 min + after its last refresh (2026-09-19, -20, -24, 2026-10-03). KPN drops that + registration the moment the initial REGISTER arrives, throttled or not, + and with it gone accepts a fresh pair there: 5 and 21 min after the + throttled attempt (2026-10-03). An address that holds no registration is + accepted while another address's is live (2026-09-26, 2026-10-03), and a + new prefix's PDN address was accepted 15 s after a throttled attempt on + the temporary one (the 0.3.9 → 0.3.10 hop, 2026-10-03). An address whose + registration another address's had replaced was refused 26 s after, + accepted 141 s and 10 min after (2026-10-03): how soon KPN accepts where a + registration has just gone is not measured more closely. The schedule's + 21 min is the wait that has never been refused, not a measured minimum; + imsd resumes rather than registers fresh wherever it can, since on a live + registration's address the attempt is refused and costs the registration. +- **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33 + unanswered reconnect cycles; a fresh registration was accepted in one + second. The origin of `RegRecovery`. +- **KPN closes a registration's previous reg-event subscription with + `noresource`** when a new one replaces it: that end is not renewed. +- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string + IPv6 bearer request is refused when the attach PDN was granted IPv4-only; + a profile-indexed request is not. +- **pmOS's default nftables** drops inbound on `qmapmux*`; without the + protected-port rule every network-initiated request (NOTIFY, MT INVITE) + dies after ESP decapsulation and MT calls fall back to CS. +- **The kernel's temporary SLAAC address** on the ims netdev is listed first + and retired after about two days; a registration bound to it dies with it. +- **systemd reads `EnvironmentFile=` per spawned command** (systemd 262~rc3 + on the FP6, 2026-10-03: an `ExecStart` saw what its `ExecStartPre` had + just written), and unquotes a quoted value — which is why the hand-over script + takes `LOCAL` from its environment rather than parsing the files itself. +- **GNOME Calls 50.0** crashes after an oFono origin withdraw/re-add cycle + and dials nothing after `org.ofono` restarts until it restarts itself. +- **dbus-broker** checks broadcasts against the sender's send policy for + every name the receiver owns (the `org.ofono.conf` detail). +- **The phone's wall clock reads 1970 until the first NTP sync**; anything + that ages a journal line uses the monotonic or boot clock. diff --git a/README.md b/README.md index 67797cb..ff99e53 100644 --- a/README.md +++ b/README.md @@ -15,402 +15,114 @@ network. ## Status -Call-capable, both directions. Outgoing VoLTE calls place, answer, carry -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: +Call-capable, both directions: outgoing calls place, answer, carry full-duplex +AMR-WB audio and tear down; incoming calls ring, answer and carry media the +same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI. +Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting, +USSD, P-CSCF discovery (the address is configured by hand). -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 -box with no bus, modem, or phone; several suites pin output byte-for-byte -against recorded network traffic. GLib/GDBus, sockets, and subprocess control -live only in the `imsd` daemon shell, apart from imsd-bus, the GLib polkit -gate it shares with imsd-ofonod. +Verified by community members to be fully working: -## Architecture - -C++26 modules, built with Crafter Build. Six build products: - -- **imsd-core** (static library) — the engine, pure `import std` C++ with no - GLib and no I/O side effects. Everything here is unit-testable on any dev - box without a modem, a bus, or a phone; several suites pin output - byte-for-byte against recorded network traffic. -- **imsd** (executable) — the daemon shell: GLib main loop, GDBus service, - sockets, subprocess control (`qmicli`/`mmcli`/`ip`), and the registration - sequence + run loop that pumps the core. -- **imsd-bus** (static library) — the polkit gate in front of call control - (`net.catcrafts.IMS1.Voice`), which imsd and imsd-ofonod both link. GLib, - so kept outside imsd-core. A translation daemon that runs as root on the - system bus reaches imsd as root, which polkit always allows, so it gates - its own callers through this or it is a bypass. -- **imsd-media** (executable) — the RTP/AMR-WB data plane, spawned once per - call. Pure `import std` + POSIX, no GLib; links `-ldl` and dlopen's the - codecs. A separate process for crash isolation and the far-end-hangup - exit-code contract. -- **imsd-dialerd** (executable) — the Plasma Mobile integration: a session - daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, - bridging them to imsd on the system bus. GDBus translation only, no core. - It is a separate process because those are per-user *session*-bus names - that the root system daemon cannot own; built with `-- --product=dialerd`, - autostarted from an `.desktop` file in place of plasma-dialer's - `modem-daemon` (whose autostart must be disabled). -- **imsd-ofonod** (executable) — the GNOME Calls integration, for Phosh and - GNOME Mobile: a system daemon owning `org.ofono` and presenting imsd as an - oFono modem, which is the seam GNOME Calls' bundled `ofono` provider plugin - drives. GDBus translation, no core, plus the imsd-bus gate on its own - callers; built with `-- --product=ofonod`. - See [GNOME Calls (Phosh, GNOME Mobile)](#gnome-calls-phosh-gnome-mobile). - -### Desktop integration - -imsd itself is desktop-agnostic: everything a dialer needs is on the system -bus as `net.catcrafts.IMS1`. Each desktop gets a small translation daemon -speaking whatever backend protocol its dialer already consumes, so the dialer, -the shell and the call-history/contacts stack all stay unmodified. - -| Desktop | Daemon | Owns | Consumed by | -|---|---|---|---| -| Plasma Mobile | `imsd-dialerd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` | -| Phosh / GNOME Mobile | `imsd-ofonod` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin | - -Phosh's own call UI needs nothing extra: it consumes `org.gnome.Calls`, which -GNOME Calls exports once it has a working origin. - -### Terminating-network dependency (incoming) - -A UE only receives a VoLTE call as a SIP INVITE if the network's Terminating -Access Domain Selection (T-ADS) routes it to the PS/IMS domain rather than -paging the modem over CS. That requires the IMS registration to advertise -voice capability — the MMTEL ICSI (`+g.3gpp.icsi-ref=...mmtel`) and -`+sip.instance` media-feature tags in the REGISTER Contact, which -`Imsd:Messages` emits. T-ADS may still select CS for reasons outside the -client's control (operator policy, the modem's own radio-capability -signalling, a competing registration). When it does, no INVITE reaches imsd; -that is a network/modem-integration matter, not a daemon defect. - -## Supported hardware & carriers - -imsd talks to the modem through ModemManager plus `qmicli` over QRTR (USIM -AKA via a UIM logical channel), so in practice it currently needs a Qualcomm -modem on a mainline kernel with working WWAN/MM integration, kernel ESP -(`ip xfrm`), and PipeWire for audio. - -Everything below has been verified by community members to be fully workking: - -| Device | OS | Carrier +| Device | OS | Carrier | |---|---|---| | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | -## Build +## What it needs -[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) + -gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine). +imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs +its security associations with `ip xfrm` and plays audio through PipeWire, so +in practice: a Qualcomm modem on a mainline kernel with working +WWAN/ModemManager integration, kernel ESP, PipeWire, systemd and polkit — and +your carrier's P-CSCF address. + +## Install and configure + +On a Catcrafts Fairphone 6 image imsd is installed from the project's apk +registry and upgrades with `apk upgrade` (an upgrade restarts the daemon; a +call in progress ends). Elsewhere, build it (below), `make install`, and +enable the units yourself — `systemctl enable imsd`, and `systemctl enable +--now imsd-ofonod` for GNOME Calls: imsd's preset, the +`multi-user.target.wants` link and the `/etc/imsd.env` condition below come +with the apk, not with `make install`. + +Configuration is environment variables in `/etc/imsd.env`; the one required +setting is the P-CSCF: ```sh -crafter-build # bin/imsd--/imsd (the daemon) -crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg) -crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (Plasma backend) -crafter-build -- --product=ofonod # bin/imsd-ofonod-.../imsd-ofonod (GNOME Calls backend) -crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp) -``` - -### Building with make (distro packaging) - -For environments without crafter-build, the Makefile mirrors the same clang -module pipeline: GNU make, clang++ with libc++ and the libc++ std module -sources (`std.cppm` — package `llvm-runtimes` on Alpine, `libc++` on Arch), -lld, and gio-2.0 headers. - -```sh -make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} -make check # the same 7 unit-test suites -make install # DESTDIR/PREFIX staged install incl. the packaging/ files -``` - -`project.cpp` stays the canonical build description; the Makefile tracks it. - -### Cross-compiling for a phone (aarch64 Alpine/postmarketOS) - -```sh -packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN -crafter-build -- --target=aarch64-alpine-linux-musl \ - --sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \ - --march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720 -# same flags after `crafter-build test --target=aarch64-alpine-linux-musl` -# run the suite under qemu-aarch64 against the sysroot -``` - -## Run (development) - -```sh -./bin/imsd-*/imsd --session # own net.catcrafts.IMS1 on the session bus -busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus -``` - -On a phone the daemon runs as root on the system bus, where any local -account may reach it (`packaging/net.catcrafts.IMS1.conf`); placing, -answering and ending a call is polkit's `net.catcrafts.IMS1.Voice` -(`packaging/net.catcrafts.IMS1.policy`, checked by imsd and imsd-ofonod -alike): the active local session — the dialer, whatever the account is -called — and root, not an ssh login. It is started by `packaging/imsd.service` -after `packaging/ims-pdn-up.sh` has brought up the ims PDN through -ModemManager. An apk upgrade restarts `imsd` and `imsd-ofonod` once the -transaction is done (a call in progress ends), as does adding a package -again over the daemon an `apk del` left running; the restart resumes the -registration over its existing SAs, or goes on waiting out a hold the -network had set. The bring-up never lets the ims PDN become the phone's IPv6 -default route: the PDN's router advertisement is not accepted as a default -router (`accept_ra_defrtr=0` on the netdev; its prefix and MTU still apply) -and the P-CSCF gets a host route instead, so on IPv4-only wifi the phone's -other IPv6 traffic (apk, browsers) is not sent into the IMS walled garden, -where it would hang. Media peers get a host route per call; anything else -on the IMS network (its DNS servers, a traceroute target) needs its own -(`ip -6 route add dev qmapmux0.0`). The PDN MTU ModemManager reports -is set on the link. It spawns -`/usr/libexec/imsd-media` per call. Registration -resumes an in-kernel IPsec SA when one is present (avoiding the network's -fresh-SA throttle) and keeps itself alive with a re-REGISTER refresh at half -the granted lifetime. `packaging/APKBUILD` builds the apk from source via -the Makefile; `packaging/APKBUILD.binary` + `make-bin-tarball.sh` wrap a -cross-compiled build into an apk instead. - -## GNOME Calls (Phosh, GNOME Mobile) - -`imsd-ofonod` presents imsd on the system bus as an oFono modem — a Manager at -`/`, one Modem + VoiceCallManager at `/imsd`, and a VoiceCall object per live -call — which is what GNOME Calls' bundled `ofono` provider plugin drives. -Calls, Phosh and GNOME Shell run unmodified. - -`org.ofono` is the seam because it is the only one a third party can -implement. `org.gnome.Calls.Call` is what Calls *exports* so a shell can -observe and control a call that already exists (Accept, Hangup, SendDtmf, -Silence — there is no Dial); Phosh consumes it from Calls. Calls' own backends -are libpeas provider plugins (`mm`, `ofono`, `sip`, `dummy`) linked against -private headers that no distro installs. - -Setup: - -```sh -apk add imsd-ofono # enables imsd-ofonod via a systemd preset -systemctl enable --now imsd-ofonod # hand installs: system service, org.ofono is a system name -# point Calls at the ofono provider (the package ships this as a gschema -# override; this is the per-user equivalent) -gsettings set org.gnome.Calls autoload-plugins "['ofono']" -``` - -Drop `mm` from `autoload-plugins` rather than adding `ofono` alongside it: the -ModemManager provider would offer the modem's own CS voice path as a second -origin, and on a phone whose voice path is imsd that origin carries no audio. - -Do not run this alongside a real `ofonod` — there is one `org.ofono` name and -one owner; the unit declares `Conflicts=ofono.service` and the daemon exits if -it loses the name. Running `imsd-dialerd` at the same time is fine (different -bus, different consumer). - -Verified on a Fairphone 6 (postmarketOS, dbus-broker) with GNOME Calls 50.0: -outgoing and incoming calls with audio both ways, answered and hung up through -`org.gnome.Calls.Call` — the surface Phosh drives. One dbus-broker detail is -load-bearing and lives in the policy file: dbus-broker checks a broadcast -against the sender's send policy for every name the receiver owns, so root -(imsd) must be allowed `send_destination="org.ofono"` or imsd's call signals -never reach the daemon. - -Known gaps, all inherited rather than introduced: - -- **USSD is refused** with `org.ofono.Error.NotSupported`; the IMS stack has no - USSD path. It is deliberately not advertised in the modem's `Interfaces`. -- **No hold, multiparty, transfer or call waiting** — imsd is a single-call - stack, so those methods return `org.ofono.Error.NotImplemented`. -- **Per-call caller-ID withholding is refused** rather than silently ignored: - imsd cannot honour a CLIR request, and failing is safer than dialling with - the caller ID exposed. -- **Phosh's emergency-call screen lists nothing.** Not for want of the data: - `imsd-ofonod` publishes `org.ofono.VoiceCallManager.EmergencyNumbers` (112, - 911, plus `EMERGENCY_NUMBERS`), which is oFono's standard place for it. - Calls' ofono provider simply never reads it — it makes no `GetProperties` - call on the VoiceCallManager, and returns NULL for the origin's - `emergency-numbers`, which is what - `org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from. (The - ModemManager provider does read it, via `mm_sim_dup_emergency_numbers()`.) - An upstream fix would close this. Dialling an emergency number from the - normal dialpad is unaffected — that reaches imsd's own classifier. -- DTMF is forwarded to imsd, where `SendDtmf` is still a stub (RFC 4733 is not - implemented yet) — the same limitation Plasma has. -- **Restarting `imsd-ofonod` restarts GNOME Calls with it.** When the - modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0 - adds a new origin but leaves the old origin's `CallAdded` handler connected: - after one such cycle every call is added twice, after a few the stale handler - runs on freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` - (measured on the FP6). Calls-side; an upstream fix belongs in - `plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own - restarts and registration drops from triggering it, `imsd-ofonod` lists the - interface permanently: `Online` tracks registration, and `Dial` fails with - "imsd is not registered" / "imsd is not running" instead. Only `org.ofono` - vanishing — this daemon restarting — still cycles the origin, and after - that Calls dials nothing until it restarts, so the unit's `ExecStartPost` - restarts the Calls daemon of every user running one. Its first start - since boot leaves a running Calls alone. - -Development, without a phone or root: - -```sh -./bin/imsd-*/imsd --session & # imsd's ABI on the session bus -./bin/imsd-ofonod-*/imsd-ofonod --session -busctl --user call org.ofono / org.ofono.Manager GetModems -# or keep org.ofono on the system bus and imsd on the session bus: -IMSD_BUS=session imsd-ofonod -``` - -## Configuration - -Everything is environment variables. The packaged unit reads -`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev and -the PDN address ModemManager assigned to it) and -then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable -`/etc/imsd.env`: - -```sh -# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your -# carrier's P-CSCF address. Find it in a stock-firmware capture or your -# carrier's IMS documentation. +# your carrier's P-CSCF address — from a stock-firmware capture or the +# carrier's IMS documentation PCSCF=2001:db8::105 ``` -### imsd +The apk's `imsd.service` is enabled by preset and starts at boot once that +file exists (the condition is checked when the unit starts, so after writing +the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers: +resuming an existing registration when it can, registering fresh otherwise, +and waiting out whatever the network tells it to wait out. Check on it with: -| Variable | Default | Meaning | -|---|---|---| -| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error | -| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | -| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | -| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | -| `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | -| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | -| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied) | -| `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`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | -| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file (`/var/lib/imsd/imsreg.state`) records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | -| `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 | -| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | -| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | -| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | +```sh +journalctl -u imsd -f +busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus +``` -The protected client/server ports are fixed at 45061/45062 -(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires -updating the firewall rules `ims-pdn-up.sh` installs. +Every variable, what the daemon does when the registration breaks, and +what the journal lines mean are in [AGENTS.md](AGENTS.md) (§10, §7.4, §7.8). -**When the registration stops working** (the refresh is refused, or the -protected flow dies and nothing answers the reconnect) imsd does in place -what a restart does: it reconnects through the existing SA and refreshes; -after two refused refreshes or two unanswered reconnect cycles (such a -cycle is not counted while the modem reports no packet service) it -registers fresh (new Call-ID, new challenge, new SA pair). Reconnects back -off 5 s → 5 min; a refused or throttled fresh attempt waits on the throttle -schedule (21 min, growing to 60), a silent one 30 s → 10 min, and a -network `Retry-After` is honoured, up to an hour (the schedule's longest wait): a longer -one is cut to it and the refusal heard again then. While a fresh registration is deferred, the existing SA is -still retried on the reconnect backoff, unless the old registration is known gone: a `403` once a -fresh registration is due, an attempt that got past the SA setup, or a binding -the network terminated (reg-event). A `Dial` -that reaches imsd while it is unregistered brings the next recovery step -forward (the reconnect, or the fresh attempt once it is due), never a wait -the network imposed (a `Retry-After`, or the schedule after a -refused or throttled fresh attempt); imsd-ofonod refuses a `Dial` then, and -imsd-dialerd lists no modem. A bring-up the network deferred waits the same -schedule out with nothing to reconnect over (the resume just found the -stored SAs unusable), then tries again. The network's holds outlive a -restart of imsd (an upgrade restarts it): they are kept in -`/run/imsd.holds` for the PDN they were set on, with the schedule's place -(a restart does not start it over at 21 min), and waited out at the next -start, before the resume (a refresh's `Retry-After`) or before the fresh -registration (a fresh attempt's schedule or `Retry-After`, a terminated -binding's wait); a reboot or another PDN address starts clean. An upgrade from a daemon -older than 0.3.10, which kept its bring-up throttle wait in memory only, -hands that wait over the same way when this start registers on the address -it was earned on (the fresh-SA throttle's wait, `spi-s=0`; the old daemon's -other deferrals, after a 401 it could not use, are not handed over): the -upgrade records that daemon's invocation, and the next -start reads its journal lines once the daemon has stopped — a wait that -ended meanwhile is read as it ended; on another address (the normal hop: -0.3.9 sat on the kernel's temporary address) only the verdict that the old -registration is gone is kept and the start registers fresh at once. When -the old address cannot be read the wait is handed over anyway; when the wait -cannot be timed, or this start has no `LOCAL`, the journal says it was not -handed over. -The journal says which (`…; next: fresh registration in N s`, `registering -fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: -…`, `bring-up deferred: …`). One case leaves through a real restart -instead: when the address imsd is registered on has left the ims netdev -and the PDN's own address is not there to move to (the PDN went away) — -the journal shows `engine fatal: … — exiting for systemd restart`, -and after `RestartSec` (120 s) `ims-pdn-up.sh` runs again and imsd -registers on the PDN's address. A registration resumed on an older -address (a pre-0.3.10 temporary one) moves to the PDN's own address in -place: with its next fresh registration, or when the older address leaves -the netdev first, which takes that registration with it. The reg-event -subscription — the network's own -view of the binding, one live dialog per registration, subscribed anew ahead of -the lifetime the S-CSCF granted — is retried when refused or unanswered -(60 s doubling to 10 min, a `Retry-After` honoured up to an hour: a registration refresh -neither brings a scheduled retry forward nor starts its back-off over) and renewed when the S-CSCF ends -it for a reason that allows renewal (RFC 6665; after `noresource`, -`rejected` or any other reason the renewal does not cover the next -registration refresh subscribes anew); an earlier -subscription's NOTIFY is still read for the binding's state, but its end is -not ours to renew (KPN closes a registration's previous subscription with -`noresource` when a new one replaces it); a NOTIFY that terminates the binding triggers a fresh -registration (at once, or after the network's `retry-after`, up to an hour; after the -call, when one is up; one exception: a REGISTER wait that still gets its 200 -after such a NOTIFY, a call having kept the flow, leaves that registration -standing); after -`rejected` and `unregistered` it waits on the refusal schedule (21 min, -growing to 60), a wait no `Dial` cuts short — the binding is gone, so -nothing is retried meanwhile. One termination counts once, however many -NOTIFYs report it (a repeated `probation` report may extend its hold from -its own `retry-after`). +## Dialers -### imsd-media (per-call, set through imsd's environment) +imsd itself is desktop-agnostic: everything a dialer needs is on the system +bus as `net.catcrafts.IMS1`. A small translation daemon per desktop speaks +the backend protocol its dialer already consumes, so dialer, shell and the +call-history/contacts stack run unmodified. -| Variable | Default | Meaning | -|---|---|---| -| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire | -| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio. imsd sets it for each call from logind: the user session active on seat0 (`loginctl show-seat seat0`, a greeter does not count) when it has a `/run/user//pipewire-0` socket. Set in imsd's own environment it pins an account instead. imsd as packaged (root, on the system bus) logs the resolution at start and when a call's media starts (`audio session: …`), imsd-media once per call. imsd-media started without the variable at all (by an imsd older than 0.3.10 while an upgrade replaces it, or by hand) tries the account `user`, its old default. | -| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain | -| `AMR_MODE` | `2` | AMR-WB encoder mode (0–8) | -| `DTX` | `0` | encoder discontinuous transmission | -| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) | -| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file | +| Desktop | Daemon | Package | Owns | Consumed by | +|---|---|---|---|---| +| Plasma Mobile | `imsd-dialerd` | `imsd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` | +| Phosh / GNOME Mobile | `imsd-ofonod` | `imsd-ofono` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin | -## D-Bus ABI +Plasma Mobile needs nothing beyond the `imsd` package: `imsd-dialerd` is +autostarted in the session in place of plasma-dialer's `modem-daemon`, whose +own autostart must be off — the package hides it for accounts created after +the install (`/etc/skel`); an existing account hides it itself with a +`Hidden=true` copy of `org.kde.modem.daemon.desktop` in `~/.config/autostart`. -`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer -integrations depend on it: +For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and +points GNOME Calls at its `ofono` provider (a gschema override; the per-user +equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`). +Do not run it alongside a real `ofonod`. Known gaps — USSD, hold and +multiparty, caller-ID withholding, the empty emergency-call screen, and why a +restart of `imsd-ofonod` restarts GNOME Calls — are listed in +[AGENTS.md §8.3](AGENTS.md). -| Member | Signature | -|---|---| -| `Dial(number)` | `s → s` (callUni) | -| `HangUp(callUni)` | `s` | -| `Accept(callUni)` | `s` | -| `SendDtmf(callUni, tones)` | `ss` | -| `GetCalls()` | `→ aa{sv}` | -| `GetStatus()` | `→ a{sv}` | -| `CallAdded` | `s a{sv}` | -| `CallStateChanged` | `s s s` (callUni, state, reason) | -| `CallDeleted` | `s` | -| `RegistrationChanged` | `b` | +Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local +session and root may place, answer and end calls; an ssh login may not. -Both `imsd-dialerd` and `imsd-ofonod` translate this ABI and add no members -to it; a new desktop needs a new translation daemon, not changes here — one -that runs as root on the system bus also gates its own callers through -imsd-bus (see Architecture), or it bypasses `net.catcrafts.IMS1.Voice`. +## Building + +[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) plus +clang/libc++/lld and the gio-2.0 headers: + +```sh +crafter-build # the daemon; -- --product=media|dialerd|ofonod for the others +crafter-build test # the seven unit-test suites +make && make check # the same, without crafter-build (distro packaging) +``` + +Cross-compiling for a phone, the Makefile's requirements, the package layout +and the CI that publishes the apks are in [AGENTS.md](AGENTS.md) (§4, §9). + +## Documentation + +[AGENTS.md](AGENTS.md) is the complete reference — for coding agents, and +for anyone who wants to know how imsd works or change it: the build products +and the rule that keeps the engine core free of GLib and I/O, the test +suites, the lint rules, the registration and recovery behaviour, the ims PDN +bring-up, upgrades, the frozen D-Bus ABI, packaging and release mechanics, +the full configuration reference, and the carrier behaviours the code was +shaped by. ## License diff --git a/implementations/main.cpp b/implementations/main.cpp index 81103b8..aa82987 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -6,7 +6,7 @@ imsd — the userspace IMS/VoLTE daemon (control plane). Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI -(README) and drives it with the engine core: register on the IMS PDN (USIM AKA +(AGENTS.md §8.1) and drives it with the engine core: register on the IMS PDN (USIM AKA + IPsec sec-agree, fresh or warm-resume, with a keepalive re-REGISTER refresh), run outgoing and incoming calls through the Imsd:Engine call machine (inbound INVITEs on the protected-port listeners become ringing calls a dialer answers diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 333529e..4abd25e 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # Maintainer: Jorijn van der Graaf # Binary packaging: wraps a crafter-build binary cross-compiled per the -# README's "Cross-compiling" section into a proper apk — used by this repo's +# cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's # package CI (packaging/build-package.sh, which also seds pkgver from # implementations/main.cpp) and runnable by hand. The source tarball is # produced by packaging/make-bin-tarball.sh; the desktop/preset files and diff --git a/packaging/build-package.sh b/packaging/build-package.sh index a089cac..e316a10 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # CI package build: cross-compile imsd for aarch64 with crafter-build (the -# README's "Cross-compiling" flow), run the test suite natively, and package +# cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package # the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine # environment with root — the workflow runs it in an alpine:edge job # container on an ordinary runner. Root only installs packages and hands off diff --git a/packaging/imsd-ofono-restart-calls.sh b/packaging/imsd-ofono-restart-calls.sh index 44ee402..a3563ed 100755 --- a/packaging/imsd-ofono-restart-calls.sh +++ b/packaging/imsd-ofono-restart-calls.sh @@ -3,8 +3,8 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® # # ExecStartPost of imsd-ofonod.service. GNOME Calls 50.0 keeps a stale modem -# when org.ofono vanishes and dials nothing until it is restarted (README, -# Known gaps), so every start of imsd-ofonod after org.ofono went away +# when org.ofono vanishes and dials nothing until it is restarted (AGENTS.md +# §8.3, Known gaps), so every start of imsd-ofonod after org.ofono went away # restarts the Calls daemon of each user running one. Its first start since # boot is not one: no Calls has seen org.ofono go, and a Calls that runs # already (a login during the bring-up) is left alone. The unit's own diff --git a/packaging/imsd.service b/packaging/imsd.service index b5c6d57..13c454c 100644 --- a/packaging/imsd.service +++ b/packaging/imsd.service @@ -22,7 +22,7 @@ TimeoutStartSec=900 # /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, # LOCAL= the PDN address ModemManager assigned to it). # /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's -# P-CSCF address; see the README's Configuration section) and wins over the +# P-CSCF address; see AGENTS.md §10, or the README) and wins over the # runtime-derived values. EnvironmentFile=-/run/imsd.env EnvironmentFile=-/etc/imsd.env