diff --git a/Makefile b/Makefile index 1428dec..758d1ba 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,11 @@ # flags. Keep the two in sync when products, module partitions or link # flags change. # -# make -> build/make/{imsd,imsd-media,imsd-dialerd} +# make -> build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod} # make check -> build + run the 7 unit-test suites # make install -> DESTDIR/PREFIX staged install (binaries + systemd -# unit, D-Bus policy, dialerd autostart, ims-pdn-up.sh) +# units, D-Bus policies, dialerd autostart, the GNOME +# Calls plugin override, ims-pdn-up.sh) # # Requires clang++ with libc++ and the libc++ std module sources # (std.cppm — Alpine: llvm-runtimes, Arch: libc++). Override STD_CPPM if @@ -54,7 +55,7 @@ CORE_OBJS = $(CORE_MODULES:%=$(OBJ)/%.o) TESTS = Util Aka Ipsec Messages Engine Sip Sdp -all: $(O)/imsd $(O)/imsd-media $(O)/imsd-dialerd +all: $(O)/imsd $(O)/imsd-media $(O)/imsd-dialerd $(O)/imsd-ofonod # ---- module BMIs ---------------------------------------------------------- @@ -103,6 +104,13 @@ $(OBJ)/dialerd.o: implementations/dialerd.cpp $(PCM)/std.pcm | $(OBJ) $(O)/imsd-dialerd: $(OBJ)/dialerd.o $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ +$(OBJ)/ofonod.o: implementations/ofonod.cpp $(PCM)/std.pcm | $(OBJ) + $(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \ + -c $< -o $@ + +$(O)/imsd-ofonod: $(OBJ)/ofonod.o + $(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@ + # ---- tests ----------------------------------------------------------------- $(O)/tests/%: tests/%/main.cpp $(PCM)/Imsd.pcm $(O)/libimsd-core.a | $(O)/tests @@ -121,6 +129,7 @@ PREFIX ?= /usr install: all install -Dm755 $(O)/imsd $(DESTDIR)$(PREFIX)/bin/imsd install -Dm755 $(O)/imsd-dialerd $(DESTDIR)$(PREFIX)/bin/imsd-dialerd + install -Dm755 $(O)/imsd-ofonod $(DESTDIR)$(PREFIX)/bin/imsd-ofonod install -Dm755 $(O)/imsd-media $(DESTDIR)$(PREFIX)/libexec/imsd-media install -Dm755 packaging/ims-pdn-up.sh \ $(DESTDIR)$(PREFIX)/libexec/ims-pdn-up.sh @@ -130,6 +139,12 @@ install: all $(DESTDIR)/etc/xdg/autostart/imsd-dialerd.desktop install -Dm644 packaging/net.catcrafts.IMS1.conf \ $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/net.catcrafts.IMS1.conf + install -Dm644 packaging/imsd-ofonod.service \ + $(DESTDIR)$(PREFIX)/lib/systemd/system/imsd-ofonod.service + install -Dm644 packaging/org.ofono.conf \ + $(DESTDIR)$(PREFIX)/share/dbus-1/system.d/imsd-ofono.conf + install -Dm644 packaging/90_imsd-ofono.gschema.override \ + $(DESTDIR)$(PREFIX)/share/glib-2.0/schemas/90_imsd-ofono.gschema.override $(PCM) $(OBJ) $(O)/tests: mkdir -p $@ diff --git a/README.md b/README.md index 17c2c94..e2b3fdf 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but can't be used for actual call audio. imsd implements the whole IMS client in userspace instead: SIP registration with USIM AKA authentication over kernel IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into -PipeWire so a stock Plasma Mobile dialer can place and receive real VoLTE -calls on a commercial network. +PipeWire so a stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh +and GNOME Mobile — can place and receive real VoLTE calls on a commercial +network. ## Status @@ -28,7 +29,7 @@ live only in the `imsd` daemon shell. ## Architecture -C++26 modules, built with Crafter Build. Four build products: +C++26 modules, built with Crafter Build. Five 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 @@ -48,6 +49,26 @@ C++26 modules, built with Crafter Build. Four build products: 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 only, no core; 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) @@ -85,7 +106,8 @@ gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine). ```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 (dialer backend) +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) ``` @@ -97,7 +119,7 @@ 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} +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 ``` @@ -134,6 +156,86 @@ 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 # or install the files by hand +systemctl enable --now imsd-ofonod # 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. +- **Restart GNOME Calls after restarting `imsd-ofonod` (or imsd).** 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`. + +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 @@ -204,6 +306,9 @@ integrations depend on it: | `CallDeleted` | `s` | | `RegistrationChanged` | `b` | +Both `imsd-dialerd` and `imsd-ofonod` are pure translations of this ABI; a new +desktop needs a new translation daemon, not changes here. + ## License GPL-3.0-only — see [LICENSE](LICENSE). @@ -212,4 +317,3 @@ GPL-3.0-only — see [LICENSE](LICENSE). Copyright (C) 2026 Catcrafts® catcrafts.net -| `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 | diff --git a/implementations/ofonod.cpp b/implementations/ofonod.cpp new file mode 100644 index 0000000..79c941e --- /dev/null +++ b/implementations/ofonod.cpp @@ -0,0 +1,770 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types no-char-pointer +/* +imsd-ofonod — GNOME Calls backend for imsd (Phosh, GNOME Mobile). + +Presents imsd as an oFono modem on the system bus: `org.ofono` with a Manager +at `/`, one Modem + VoiceCallManager at `/imsd`, and one VoiceCall object per +live call. GNOME Calls' bundled `ofono` provider plugin then drives imsd — +dial, ring, answer, hang up, call history, contact lookup — with Calls, Phosh +and GNOME Shell unmodified. It is the exact mirror of what imsd-dialerd does +for Plasma Mobile's org.kde.telephony.*. + +Why this seam and not org.gnome.Calls: that interface is what Calls *exports* +so a shell can observe and control a call that already exists (Accept, Hangup, +SendDtmf, Silence — there is no Dial, and no way to register as a call +source). Phosh consumes it from Calls; nobody provides it to Calls. Calls' +own backends are libpeas provider plugins (mm, ofono, sip, dummy) linked +against private headers that no distro installs, so `org.ofono` is the only +D-Bus seam a third party can implement. + +Consumer-shaped details, verified against calls 50.0 +plugins/provider/ofono/ — get these wrong and the plugin silently ignores us: + - the provider watches `org.ofono` on the SYSTEM bus and proxies `/` as + org.ofono.Manager. A modem becomes a dialable Origin only while its + `Interfaces` property contains "org.ofono.VoiceCallManager", so that list + is our registration gate — the analogue of the dialerd's deviceUniList. + - VoiceCall `State` strings are parsed as CallsCallState enum nicks: + dialing / alerting / incoming / active / held / disconnected. imsd's + "ringing" (a 180 from the far end) is oFono's "alerting". + - inbound-ness is derived ONCE, from State == "incoming" as the call is + added, so an MT call must be announced incoming and never corrected later. + - DisconnectReason is cached by the plugin and read when CallRemoved + arrives — emit it before the removal or the history entry loses its + reason. + - Dial's returned object path is ignored (the plugin waits for CallAdded). + We return a real one anyway. + +Runs as root because `org.ofono` is a system-bus name (policy in +packaging/org.ofono.conf). It does no I/O beyond D-Bus: every call decision +stays in imsd. Everything happens on the GLib main loop, so there is no +locking. + + --session own org.ofono on the session bus and talk to imsd there + (development: no root, no policy file) + IMSD_BUS=session|system override only the imsd side of that choice +*/ + +#include +#include + +import std; + +namespace { + +constexpr const char* ImsdBus = "net.catcrafts.IMS1"; +constexpr const char* ImsdPath = "/net/catcrafts/IMS1"; +constexpr const char* ImsdIface = "net.catcrafts.IMS1"; + +constexpr const char* OfonoBus = "org.ofono"; +constexpr const char* ManagerPath = "/"; +constexpr const char* ManagerIface = "org.ofono.Manager"; +constexpr const char* ModemPath = "/imsd"; +constexpr const char* ModemIface = "org.ofono.Modem"; +constexpr const char* VcmIface = "org.ofono.VoiceCallManager"; +constexpr const char* CallIface = "org.ofono.VoiceCall"; +constexpr const char* SsIface = "org.ofono.SupplementaryServices"; + +constexpr const char* ErrFailed = "org.ofono.Error.Failed"; +constexpr const char* ErrNotImplemented = "org.ofono.Error.NotImplemented"; +constexpr const char* ErrNotSupported = "org.ofono.Error.NotSupported"; +constexpr const char* ErrInvalidArgs = "org.ofono.Error.InvalidArguments"; + +void Log(std::string_view m) { std::println("imsd-ofonod: {}", m); std::fflush(stdout); } + +std::string EnvOr(const char* k, std::string d) { + const char* v = std::getenv(k); + return v ? std::string(v) : std::move(d); +} + +// ---- state (main thread only) ---------------------------------------------- +GDBusConnection* Bus = nullptr; // where we own org.ofono +GDBusConnection* Imsd = nullptr; // where net.catcrafts.IMS1 lives +GMainLoop* Loop = nullptr; +bool ImsdUp = false; +bool Registered = false; +std::vector EmergencyNumbers; + +struct CallRec { + std::string uni, path, number; + std::string state = "dialing"; // oFono nick, already mapped + std::string reason; // oFono DisconnectReason, once known + bool inbound = false; + std::int64_t startedAt = 0; + guint regId = 0; // exported VoiceCall object +}; + +std::map Calls; // uni -> record +std::map ByPath; // object path -> uni +std::map UniToPath; // uni -> object path (survives + // a Dial reply that races the + // CallAdded signal) +unsigned PathSeq = 0; + +// ---- GVariant helpers ------------------------------------------------------- +std::string DictStr(GVariant* dict, const char* key) { + GVariant* v = g_variant_lookup_value(dict, key, G_VARIANT_TYPE_STRING); + if (!v) return ""; + std::string s = g_variant_get_string(v, nullptr); + g_variant_unref(v); + return s; +} +std::int64_t DictInt64(GVariant* dict, const char* key) { + GVariant* v = g_variant_lookup_value(dict, key, G_VARIANT_TYPE_INT64); + if (!v) return 0; + std::int64_t x = g_variant_get_int64(v); + g_variant_unref(v); + return x; +} +bool DictBool(GVariant* dict, const char* key) { + GVariant* v = g_variant_lookup_value(dict, key, G_VARIANT_TYPE_BOOLEAN); + if (!v) return false; + bool b = g_variant_get_boolean(v); + g_variant_unref(v); + return b; +} + +// ---- imsd <-> oFono vocabulary --------------------------------------------- +// imsd call states -> oFono VoiceCall State nicks. Returns nullptr for a state +// we don't know, so the caller keeps the one it already published rather than +// inventing a transition. +const char* OfonoState(std::string_view s) { + if (s == "dialing") return "dialing"; + if (s == "ringing") return "alerting"; // 180 from the far end + if (s == "incoming") return "incoming"; + if (s == "active") return "active"; + if (s == "terminated") return "disconnected"; + return nullptr; +} + +// imsd hangup reasons -> the three oFono DisconnectReason values. +const char* OfonoReason(std::string_view r) { + if (r == "local-hangup") return "local"; + if (r == "remote-hangup" || r == "refused-or-busy") return "remote"; + return "network"; +} + +// ---- signal emission -------------------------------------------------------- +void Emit(const char* path, const char* iface, const char* name, GVariant* params) { + if (!Bus) return; + g_dbus_connection_emit_signal(Bus, nullptr, path, iface, name, params, nullptr); +} + +GVariant* PropChanged(const char* name, GVariant* value) { + return g_variant_new("(sv)", name, value); +} + +// ---- Modem / VoiceCallManager properties ------------------------------------ +bool VoiceUp() { return ImsdUp && Registered; } + +// The registration gate: the plugin only builds an Origin while +// org.ofono.VoiceCallManager is listed here. +GVariant* InterfacesValue() { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("as")); + if (VoiceUp()) g_variant_builder_add(&b, "s", VcmIface); + return g_variant_builder_end(&b); +} + +GVariant* ModemProps() { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&b, "{sv}", "Name", g_variant_new_string("imsd")); + g_variant_builder_add(&b, "{sv}", "Manufacturer", g_variant_new_string("Catcrafts")); + g_variant_builder_add(&b, "{sv}", "Model", g_variant_new_string("userspace IMS")); + g_variant_builder_add(&b, "{sv}", "Type", g_variant_new_string("hardware")); + g_variant_builder_add(&b, "{sv}", "Powered", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&b, "{sv}", "Online", g_variant_new_boolean(VoiceUp() ? TRUE : FALSE)); + g_variant_builder_add(&b, "{sv}", "Lockdown", g_variant_new_boolean(FALSE)); + g_variant_builder_add(&b, "{sv}", "Interfaces", InterfacesValue()); + return g_variant_builder_end(&b); +} + +// EmergencyNumbers mirrors imsd's own configuration (builtin 112/911 plus the +// EMERGENCY_NUMBERS list) so an oFono client can display it. Classification +// itself is imsd's — it decides at Dial time whether a number becomes an +// urn:service:sos INVITE — and this list never gates a dial here. +GVariant* VcmProps() { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}")); + GVariantBuilder n; + g_variant_builder_init(&n, G_VARIANT_TYPE("as")); + for (const std::string& e : EmergencyNumbers) + g_variant_builder_add(&n, "s", e.c_str()); + g_variant_builder_add(&b, "{sv}", "EmergencyNumbers", g_variant_builder_end(&n)); + return g_variant_builder_end(&b); +} + +// oFono's StartTime is an ISO 8601 string; imsd hands us epoch seconds. +std::string StartTimeOf(std::int64_t epoch) { + if (epoch <= 0) return ""; + GDateTime* dt = g_date_time_new_from_unix_utc(epoch); + if (!dt) return ""; + gchar* s = g_date_time_format_iso8601(dt); + std::string out = s ? s : ""; + g_free(s); + g_date_time_unref(dt); + return out; +} + +// Deliberately no "Emergency" key: imsd owns emergency classification and does +// not export its per-call verdict, and a second guess here could disagree with +// the INVITE that actually went out. +GVariant* CallProps(const CallRec& r) { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&b, "{sv}", "LineIdentification", g_variant_new_string(r.number.c_str())); + g_variant_builder_add(&b, "{sv}", "Name", g_variant_new_string("")); + g_variant_builder_add(&b, "{sv}", "State", g_variant_new_string(r.state.c_str())); + g_variant_builder_add(&b, "{sv}", "Multiparty", g_variant_new_boolean(FALSE)); + g_variant_builder_add(&b, "{sv}", "RemoteHeld", g_variant_new_boolean(FALSE)); + g_variant_builder_add(&b, "{sv}", "RemoteMultiparty", g_variant_new_boolean(FALSE)); + std::string started = StartTimeOf(r.startedAt); + if (!started.empty()) + g_variant_builder_add(&b, "{sv}", "StartTime", g_variant_new_string(started.c_str())); + return g_variant_builder_end(&b); +} + +GVariant* CallVector() { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a(oa{sv})")); + for (const auto& [uni, rec] : Calls) + g_variant_builder_add(&b, "(o@a{sv})", rec.path.c_str(), CallProps(rec)); + return g_variant_builder_end(&b); +} + +// ---- imsd (client side) ----------------------------------------------------- +// Fire-and-forget into imsd; a dead imsd shows up via the name watch, so a +// failure here is logged rather than fatal (same contract as imsd-dialerd). +void CallImsd(const char* method, GVariant* params) { + g_dbus_connection_call( + Imsd, ImsdBus, ImsdPath, ImsdIface, method, params, nullptr, + G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, + [](GObject* src, GAsyncResult* res, gpointer m) { + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err); + if (err) { + Log(std::format("{} failed: {}", static_cast(m), err->message)); + g_error_free(err); + } + if (r) g_variant_unref(r); + }, + const_cast(method)); +} + +std::string ActiveUni() { + for (const auto& [uni, rec] : Calls) + if (rec.state != "disconnected") return uni; + return ""; +} + +// ---- the VoiceCall objects -------------------------------------------------- +constexpr const char* CallXml = R"xml( + + + + + + + + + +)xml"; + +void HandleCallMethod(GDBusConnection*, const gchar*, const gchar* objectPath, const gchar*, const gchar* method, GVariant*, GDBusMethodInvocation* inv, gpointer) { + auto byPath = ByPath.find(objectPath ? objectPath : ""); + if (byPath == ByPath.end()) { + g_dbus_method_invocation_return_dbus_error(inv, ErrFailed, "no such call"); + return; + } + auto it = Calls.find(byPath->second); + if (it == Calls.end()) { + g_dbus_method_invocation_return_dbus_error(inv, ErrFailed, "no such call"); + return; + } + std::string_view m = method; + if (m == "GetProperties") { + g_dbus_method_invocation_return_value(inv, g_variant_new("(@a{sv})", CallProps(it->second))); + return; + } + if (m == "Answer") { + Log(std::format("Answer({})", it->second.uni)); + CallImsd("Accept", g_variant_new("(s)", it->second.uni.c_str())); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + if (m == "Hangup") { + Log(std::format("Hangup({})", it->second.uni)); + CallImsd("HangUp", g_variant_new("(s)", it->second.uni.c_str())); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + if (m == "Deflect") { + g_dbus_method_invocation_return_dbus_error(inv, ErrNotImplemented, "call deflection is not implemented"); + return; + } + g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); +} + +const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} }; + +guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) { + GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr); + guint id = g_dbus_connection_register_object(Bus, path, node->interfaces[0], vtable, nullptr, nullptr, nullptr); + g_dbus_node_info_unref(node); + return id; +} + +// Allocate (or recall) the object path for an imsd call uni. imsd's unis +// ("ims-call-3") are not valid object-path elements, and a Dial reply can beat +// the CallAdded signal, so the mapping is created on first mention either way. +const std::string& PathFor(const std::string& uni) { + auto it = UniToPath.find(uni); + if (it != UniToPath.end()) return it->second; + std::string path = std::format("{}/voicecall{:02}", ModemPath, ++PathSeq); + ByPath[path] = uni; + return UniToPath.emplace(uni, std::move(path)).first->second; +} + +void ForgetPath(const std::string& uni) { + auto it = UniToPath.find(uni); + if (it == UniToPath.end()) return; + ByPath.erase(it->second); + UniToPath.erase(it); +} + +void AddCall(const std::string& uni, const std::string& number, std::string_view imsdState, std::int64_t startedAt) { + if (uni.empty() || Calls.contains(uni)) return; + const char* st = OfonoState(imsdState); + CallRec rec; + rec.uni = uni; + rec.path = PathFor(uni); + rec.number = number; + rec.state = st ? st : "dialing"; + rec.inbound = rec.state == "incoming"; + rec.startedAt = startedAt ? startedAt : g_get_real_time() / G_USEC_PER_SEC; + rec.regId = RegisterObject(CallXml, rec.path.c_str(), &CallVtable); + Log(std::format("call added {} {} {} [{}]", rec.path, rec.inbound ? "<-" : "->", rec.number, rec.state)); + GVariant* props = CallProps(rec); + Calls[uni] = std::move(rec); + Emit(ModemPath, VcmIface, "CallAdded", g_variant_new("(o@a{sv})", Calls[uni].path.c_str(), props)); +} + +void SetCallState(CallRec& rec, const char* state) { + if (rec.state == state) return; + rec.state = state; + Emit(rec.path.c_str(), CallIface, "PropertyChanged", + PropChanged("State", g_variant_new_string(state))); +} + +// The plugin caches DisconnectReason and reads it when CallRemoved lands, so +// the order here is load-bearing: disconnected state, then reason, then removal. +void RemoveCall(const std::string& uni, const char* reasonHint) { + auto it = Calls.find(uni); + if (it == Calls.end()) return; + CallRec& rec = it->second; + SetCallState(rec, "disconnected"); + const char* reason = !rec.reason.empty() ? rec.reason.c_str() + : (reasonHint ? reasonHint : "network"); + Emit(rec.path.c_str(), CallIface, "DisconnectReason", g_variant_new("(s)", reason)); + if (rec.regId) g_dbus_connection_unregister_object(Bus, rec.regId); + std::string path = rec.path; + Log(std::format("call removed {} ({})", path, reason)); + Calls.erase(it); + ForgetPath(uni); + Emit(ModemPath, VcmIface, "CallRemoved", g_variant_new("(o)", path.c_str())); +} + +// ---- Manager / Modem / VoiceCallManager objects ----------------------------- +constexpr const char* ManagerXml = R"xml( + + + + + + +)xml"; + +constexpr const char* ModemXml = R"xml( + + + + + + + + + +)xml"; + +constexpr const char* VcmXml = R"xml( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +)xml"; + +// USSD has no path through the IMS stack. The interface is answered (Calls +// proxies it unconditionally, and a legible error beats UnknownInterface) but +// deliberately NOT advertised in the modem's Interfaces list. +constexpr const char* SsXml = R"xml( + + + + + + + + + + + + + + + + + +)xml"; + +void HandleManagerMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant*, GDBusMethodInvocation* inv, gpointer) { + if (std::string_view(method) == "GetModems") { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a(oa{sv})")); + g_variant_builder_add(&b, "(o@a{sv})", ModemPath, ModemProps()); + g_dbus_method_invocation_return_value(inv, g_variant_new("(@a(oa{sv}))", g_variant_builder_end(&b))); + return; + } + g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); +} + +void HandleModemMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) { + std::string_view m = method; + if (m == "GetProperties") { + g_dbus_method_invocation_return_value(inv, g_variant_new("(@a{sv})", ModemProps())); + return; + } + if (m == "SetProperty") { + const gchar* prop = nullptr; + GVariant* value = nullptr; + g_variant_get(params, "(&sv)", &prop, &value); + if (value) g_variant_unref(value); + // Powered/Online track imsd's registration; nothing here is settable. + g_dbus_method_invocation_return_dbus_error( + inv, ErrNotSupported, + std::format("{} is driven by imsd's registration and cannot be set", prop ? prop : "?").c_str()); + return; + } + g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); +} + +void OnDialReply(GObject* src, GAsyncResult* res, gpointer user) { + GDBusMethodInvocation* inv = static_cast(user); + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err); + if (!r) { + Log(std::format("imsd Dial failed: {}", err ? err->message : "?")); + g_dbus_method_invocation_return_dbus_error(inv, ErrFailed, err ? err->message : "imsd Dial failed"); + if (err) g_error_free(err); + return; + } + const gchar* uni = nullptr; + g_variant_get(r, "(&s)", &uni); + if (!uni || !*uni) { + g_variant_unref(r); + g_dbus_method_invocation_return_dbus_error(inv, ErrFailed, "imsd returned no call id"); + return; + } + const std::string& path = PathFor(uni); + g_dbus_method_invocation_return_value(inv, g_variant_new("(o)", path.c_str())); + g_variant_unref(r); +} + +void HandleVcmMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) { + std::string_view m = method; + if (m == "GetProperties") { + g_dbus_method_invocation_return_value(inv, g_variant_new("(@a{sv})", VcmProps())); + return; + } + if (m == "GetCalls") { + g_dbus_method_invocation_return_value(inv, g_variant_new("(@a(oa{sv}))", CallVector())); + return; + } + if (m == "Dial") { + const gchar* number = nullptr; + const gchar* clir = nullptr; + g_variant_get(params, "(&s&s)", &number, &clir); + if (!number || !*number) { + g_dbus_method_invocation_return_dbus_error(inv, ErrInvalidArgs, "empty number"); + return; + } + // "default" is the only CLIR setting imsd can honour. Failing loudly on + // a withhold request beats dialling with the caller ID exposed. + std::string_view hide = clir ? clir : ""; + if (!hide.empty() && hide != "default") { + g_dbus_method_invocation_return_dbus_error(inv, ErrNotImplemented, "per-call caller-ID control is not implemented"); + return; + } + if (!VoiceUp()) { + g_dbus_method_invocation_return_dbus_error(inv, ErrFailed, "imsd is not registered"); + return; + } + Log(std::format("Dial({})", number)); + g_dbus_connection_call(Imsd, ImsdBus, ImsdPath, ImsdIface, "Dial", + g_variant_new("(s)", number), G_VARIANT_TYPE("(s)"), + G_DBUS_CALL_FLAGS_NONE, 10000, nullptr, OnDialReply, inv); + return; + } + if (m == "HangupAll") { + for (const auto& [uni, rec] : Calls) + if (rec.state != "disconnected") + CallImsd("HangUp", g_variant_new("(s)", uni.c_str())); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + if (m == "SendTones") { + const gchar* tones = nullptr; + g_variant_get(params, "(&s)", &tones); + std::string target = ActiveUni(); + if (target.empty()) { + g_dbus_method_invocation_return_dbus_error(inv, ErrFailed, "no active call"); + return; + } + CallImsd("SendDtmf", g_variant_new("(ss)", target.c_str(), tones ? tones : "")); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + // Single-call stack: no hold, no multiparty, no transfer, no dial memory. + if (m == "Transfer" || m == "SwapCalls" || m == "ReleaseAndAnswer" || m == "ReleaseAndSwap" + || m == "HoldAndAnswer" || m == "PrivateChat" || m == "CreateMultiparty" + || m == "HangupMultiparty" || m == "DialLast" || m == "DialMemory") { + g_dbus_method_invocation_return_dbus_error(inv, ErrNotImplemented, "not implemented by imsd"); + return; + } + g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); +} + +void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant*, GDBusMethodInvocation* inv, gpointer) { + std::string_view m = method; + if (m == "GetProperties") { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&b, "{sv}", "State", g_variant_new_string("idle")); + g_dbus_method_invocation_return_value(inv, g_variant_new("(@a{sv})", g_variant_builder_end(&b))); + return; + } + if (m == "Cancel") { + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + g_dbus_method_invocation_return_dbus_error(inv, ErrNotSupported, "USSD is not supported on the IMS stack"); +} + +const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} }; +const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} }; + +// ---- imsd events ------------------------------------------------------------ +void PublishInterfaces() { + Emit(ModemPath, ModemIface, "PropertyChanged", PropChanged("Interfaces", InterfacesValue())); + Emit(ModemPath, ModemIface, "PropertyChanged", + PropChanged("Online", g_variant_new_boolean(VoiceUp() ? TRUE : FALSE))); + Log(std::format("voice interface {}", VoiceUp() ? "available" : "withdrawn")); +} + +void OnImsdSignal(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* signal, GVariant* params, gpointer) { + std::string_view sig = signal; + if (sig == "CallAdded") { + const gchar* uni = nullptr; + GVariant* info = nullptr; + g_variant_get(params, "(&s@a{sv})", &uni, &info); + AddCall(uni ? uni : "", DictStr(info, "number"), + DictStr(info, "state").empty() + ? (DictStr(info, "direction") == "incoming" ? "incoming" : "dialing") + : DictStr(info, "state"), + DictInt64(info, "startedAt")); + g_variant_unref(info); + } else if (sig == "CallStateChanged") { + const gchar* uni = nullptr; + const gchar* state = nullptr; + const gchar* reason = nullptr; + g_variant_get(params, "(&s&s&s)", &uni, &state, &reason); + auto it = Calls.find(uni ? uni : ""); + if (it == Calls.end()) return; + const char* mapped = OfonoState(state ? state : ""); + if (!mapped) return; + if (std::string_view(mapped) == "disconnected") { + // Bank the reason before the state flips: RemoveCall emits it. + it->second.reason = OfonoReason(reason ? reason : ""); + SetCallState(it->second, mapped); + Log(std::format("{} -> disconnected ({})", it->second.path, it->second.reason)); + return; + } + Log(std::format("{} -> {}", it->second.path, mapped)); + SetCallState(it->second, mapped); + } else if (sig == "CallDeleted") { + const gchar* uni = nullptr; + g_variant_get(params, "(&s)", &uni); + RemoveCall(uni ? uni : "", "network"); + } else if (sig == "RegistrationChanged") { + gboolean reg = FALSE; + g_variant_get(params, "(b)", ®); + if (Registered == static_cast(reg)) return; + Registered = reg; + PublishInterfaces(); + } +} + +void RefreshFromImsd() { + GError* err = nullptr; + GVariant* st = g_dbus_connection_call_sync(Imsd, ImsdBus, ImsdPath, ImsdIface, "GetStatus", nullptr, G_VARIANT_TYPE("(a{sv})"), G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, &err); + if (!st) { + Log(std::format("imsd status query failed: {}", err ? err->message : "?")); + if (err) g_error_free(err); + Registered = false; + return; + } + GVariant* dict = g_variant_get_child_value(st, 0); + Registered = DictBool(dict, "registered"); + g_variant_unref(dict); + g_variant_unref(st); + + GVariant* calls = g_dbus_connection_call_sync(Imsd, ImsdBus, ImsdPath, ImsdIface, "GetCalls", nullptr, G_VARIANT_TYPE("(aa{sv})"), G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, &err); + if (!calls) { + if (err) g_error_free(err); + return; + } + GVariant* arr = g_variant_get_child_value(calls, 0); + GVariantIter it; + g_variant_iter_init(&it, arr); + while (GVariant* info = g_variant_iter_next_value(&it)) { + AddCall(DictStr(info, "uni"), DictStr(info, "number"), DictStr(info, "state"), DictInt64(info, "startedAt")); + g_variant_unref(info); + } + g_variant_unref(arr); + g_variant_unref(calls); +} + +void OnImsdAppeared(GDBusConnection*, const gchar*, const gchar*, gpointer) { + if (ImsdUp) return; + ImsdUp = true; + Log("imsd up"); + RefreshFromImsd(); + PublishInterfaces(); +} + +void OnImsdVanished(GDBusConnection*, const gchar*, gpointer) { + if (!ImsdUp) return; + ImsdUp = false; + Registered = false; + Log("imsd down"); + std::vector live; + for (const auto& [uni, rec] : Calls) live.push_back(uni); + for (const std::string& uni : live) RemoveCall(uni, "network"); + PublishInterfaces(); +} + +gboolean OnTerm(gpointer loop) { + g_main_loop_quit(static_cast(loop)); + return G_SOURCE_REMOVE; +} + +void LoadEmergencyNumbers() { + EmergencyNumbers = { "112", "911" }; + std::string extra = EnvOr("EMERGENCY_NUMBERS", ""); + for (std::size_t pos = 0; pos <= extra.size();) { + std::size_t comma = extra.find(',', pos); + std::size_t end = comma == std::string::npos ? extra.size() : comma; + std::string n = extra.substr(pos, end - pos); + std::erase(n, ' '); + if (!n.empty() && std::ranges::find(EmergencyNumbers, n) == EmergencyNumbers.end()) + EmergencyNumbers.push_back(std::move(n)); + if (comma == std::string::npos) break; + pos = comma + 1; + } +} + +} // namespace + +int main(int argc, char** argv) { + bool session = false; + for (int i = 1; i < argc; i++) + if (std::string_view(argv[i]) == "--session") session = true; + + GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; + std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system"); + GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM; + + LoadEmergencyNumbers(); + + GError* err = nullptr; + Bus = g_bus_get_sync(ofonoBus, nullptr, &err); + if (!Bus) { + std::println(std::cerr, "imsd-ofonod: no {} bus: {}", session ? "session" : "system", err ? err->message : "?"); + return 1; + } + Imsd = imsdBus == ofonoBus ? Bus : g_bus_get_sync(imsdBus, nullptr, &err); + if (!Imsd) { + std::println(std::cerr, "imsd-ofonod: no {} bus for imsd: {}", imsdBusName, err ? err->message : "?"); + return 1; + } + + RegisterObject(ManagerXml, ManagerPath, &ManagerVtable); + RegisterObject(ModemXml, ModemPath, &ModemVtable); + RegisterObject(VcmXml, ModemPath, &VcmVtable); + RegisterObject(SsXml, ModemPath, &SsVtable); + + Loop = g_main_loop_new(nullptr, FALSE); + + // DO_NOT_QUEUE: a real ofonod already owning the name means two stacks are + // fighting over the modem — fail loudly instead of waiting our turn. + guint owner = g_bus_own_name_on_connection( + Bus, OfonoBus, G_BUS_NAME_OWNER_FLAGS_DO_NOT_QUEUE, + [](GDBusConnection*, const gchar* n, gpointer) { Log(std::format("owning {}", n)); }, + [](GDBusConnection*, const gchar* n, gpointer lp) { + Log(std::format("lost {} (is ofonod running?) — exiting", n)); + g_main_loop_quit(static_cast(lp)); + }, + Loop, nullptr); + + g_dbus_connection_signal_subscribe(Imsd, ImsdBus, ImsdIface, nullptr, ImsdPath, nullptr, G_DBUS_SIGNAL_FLAGS_NONE, OnImsdSignal, nullptr, nullptr); + g_bus_watch_name_on_connection(Imsd, ImsdBus, G_BUS_NAME_WATCHER_FLAGS_NONE, OnImsdAppeared, OnImsdVanished, nullptr, nullptr); + + g_unix_signal_add(SIGTERM, OnTerm, Loop); + g_unix_signal_add(SIGINT, OnTerm, Loop); + + Log(std::format("up (org.ofono on the {} bus, imsd on the {} bus)", session ? "session" : "system", imsdBusName)); + g_main_loop_run(Loop); + g_bus_unown_name(owner); + g_main_loop_unref(Loop); + return 0; +} diff --git a/packaging/90_imsd-ofono.gschema.override b/packaging/90_imsd-ofono.gschema.override new file mode 100644 index 0000000..b609f3a --- /dev/null +++ b/packaging/90_imsd-ofono.gschema.override @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# GNOME Calls autoloads ["mm", "sip"] by default. imsd is reached through the +# `ofono` provider (imsd-ofonod), so point Calls at that one instead. +# +# "mm" is dropped on purpose rather than appended: it would add the real +# ModemManager modem as a second, competing origin, and on a phone whose voice +# path is imsd the modem's own CS/voice route carries no audio — a dial that +# picks that origin fails silently. Re-add it if your device has a working +# ModemManager voice path and you want both. +# +# Install: /usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override +# then run glib-compile-schemas /usr/share/glib-2.0/schemas (the distro's +# package trigger normally does this for you). +[org.gnome.Calls] +autoload-plugins=['ofono'] diff --git a/packaging/APKBUILD b/packaging/APKBUILD index 9a7e429..a048ea7 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -19,6 +19,11 @@ makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgc # requests, which races imsd for the PDN and flaps it with a new prefix every # ~2.5 min — the two IMS stacks cannot share one PDN provides="81voltd=$pkgver-r$pkgrel" +# imsd-ofono is opt-in: it carries the org.ofono shim that lets GNOME Calls +# (Phosh, GNOME Mobile) drive imsd, plus the gschema override that repoints +# Calls at the ofono provider. A Plasma Mobile phone wants imsd-dialerd, which +# is in the main package, and must NOT get that override. +subpackages="$pkgname-ofono:ofono" source="$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz" builddir="$srcdir/$pkgname" @@ -33,3 +38,12 @@ check() { package() { make install DESTDIR="$pkgdir" } + +ofono() { + pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" + depends="$pkgname=$pkgver-r$pkgrel calls" + amove usr/bin/imsd-ofonod + amove usr/lib/systemd/system/imsd-ofonod.service + amove usr/share/dbus-1/system.d/imsd-ofono.conf + amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override +} diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 0043af0..97a588f 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -24,8 +24,11 @@ depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools" # (fp6 journal/ims.md s57) — the two IMS stacks cannot share one PDN provides="81voltd=$pkgver-r$pkgrel" # no OpenRC service yet: the unit's PDN-bring-up/env-file sequencing is only -# tested under systemd; an initd is welcome once someone can verify one -subpackages="$pkgname-systemd" +# tested under systemd; an initd is welcome once someone can verify one. +# see packaging/APKBUILD: the org.ofono shim for GNOME Calls is opt-in +# (ofono before systemd: systemd() amoves all of usr/lib/systemd/system, +# so ofono() must take imsd-ofonod.service first) +subpackages="$pkgname-ofono:ofono $pkgname-systemd" options="!check !tracedeps" source=" imsd-$pkgver.tar.gz @@ -60,6 +63,22 @@ package() { mkdir -p "$pkgdir"/usr/lib/systemd/system/imsd.service.d printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf + install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod + install -Dm644 imsd-ofonod.service \ + "$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service + install -Dm644 org.ofono.conf \ + "$pkgdir"/usr/share/dbus-1/system.d/imsd-ofono.conf + install -Dm644 90_imsd-ofono.gschema.override \ + "$pkgdir"/usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override +} + +ofono() { + pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd" + depends="$pkgname=$pkgver-r$pkgrel calls" + amove usr/bin/imsd-ofonod + amove usr/lib/systemd/system/imsd-ofonod.service + amove usr/share/dbus-1/system.d/imsd-ofono.conf + amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override } systemd() { diff --git a/packaging/build-package.sh b/packaging/build-package.sh index ae45bf6..75a0aeb 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -83,6 +83,7 @@ XTARGET="--target=aarch64-alpine-linux-musl --sysroot=$SYSROOT --march=armv8.6-a crafter-build -- $XTARGET crafter-build -- --product=media $XTARGET crafter-build -- --product=dialerd $XTARGET +crafter-build -- --product=ofonod $XTARGET crafter-build test # --- bundle + package diff --git a/packaging/imsd-ofonod.service b/packaging/imsd-ofonod.service new file mode 100644 index 0000000..be308ee --- /dev/null +++ b/packaging/imsd-ofonod.service @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +[Unit] +Description=oFono-compatible GNOME Calls backend for imsd +Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd +# org.ofono is a system-bus name, so this is a system service; it needs no +# privileges beyond owning that name. Only enable it on a GNOME/Phosh session +# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different +# buses, different consumers), but a real ofonod must not. +Conflicts=ofono.service +After=imsd.service +Wants=imsd.service + +[Service] +Type=simple +# EMERGENCY_NUMBERS only, and only so the oFono EmergencyNumbers property +# matches imsd's configuration; imsd itself owns emergency classification. +EnvironmentFile=-/etc/imsd.env +ExecStart=/usr/bin/imsd-ofonod +# it holds no call state of its own — imsd is re-queried on every reconnect +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 03f7186..24e4ffe 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -11,9 +11,11 @@ VER="${1:-$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' implementations/main.c BIN=$(ls -t bin/imsd-aarch64-*/imsd 2>/dev/null | head -n1) MEDIA=$(ls -t bin/imsd-media-aarch64-*/imsd-media 2>/dev/null | head -n1) DIALERD=$(ls -t bin/imsd-dialerd-aarch64-*/imsd-dialerd 2>/dev/null | head -n1) +OFONOD=$(ls -t bin/imsd-ofonod-aarch64-*/imsd-ofonod 2>/dev/null | head -n1) [ -n "$BIN" ] || { echo "no aarch64 imsd build found — cross-compile first" >&2; exit 1; } [ -n "$MEDIA" ] || { echo "no aarch64 imsd-media build — cross-compile with --product=media" >&2; exit 1; } [ -n "$DIALERD" ] || { echo "no aarch64 imsd-dialerd build — cross-compile with --product=dialerd" >&2; exit 1; } +[ -n "$OFONOD" ] || { echo "no aarch64 imsd-ofonod build — cross-compile with --product=ofonod" >&2; exit 1; } stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT @@ -21,8 +23,10 @@ mkdir "$stage/imsd-$VER" cp "$BIN" "$stage/imsd-$VER/imsd" cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" +cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" cp packaging/ims-pdn-up.sh packaging/imsd.service \ - packaging/imsd-dialerd.desktop \ + packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ + packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \ packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/" tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER" echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))" diff --git a/packaging/org.ofono.conf b/packaging/org.ofono.conf new file mode 100644 index 0000000..0b73669 --- /dev/null +++ b/packaging/org.ofono.conf @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/project.cpp b/project.cpp index d38bcac..3507130 100644 --- a/project.cpp +++ b/project.cpp @@ -58,7 +58,7 @@ static void ApplyGioFlags(Configuration& cfg) { } extern "C" Configuration CrafterBuildProject(std::span args) { - // Three executables come out of this repo; which one a build produces is + // Four executables come out of this repo; which one a build produces is // chosen by `--product=`: // daemon (default) — imsd, the control-plane daemon (GDBus + engine), // and the unit tests, which link imsd-core. @@ -68,11 +68,32 @@ extern "C" Configuration CrafterBuildProject(std::span a // dialerd — imsd-dialerd, the session-bus Plasma Dialer // backend (org.kde.telephony.*) bridging to imsd // (pure GDBus translation, no core). + // ofonod — imsd-ofonod, the system-bus GNOME Calls backend + // (org.ofono.*) bridging to imsd, for Phosh and + // GNOME Mobile (pure GDBus translation, no core). bool wantMedia = false; bool wantDialerd = false; + bool wantOfonod = false; for (std::string_view a : args) { if (a == "--product=media") wantMedia = true; if (a == "--product=dialerd") wantDialerd = true; + if (a == "--product=ofonod") wantOfonod = true; + } + if (wantOfonod) { + Configuration ofonod; + ofonod.path = "./"; + ofonod.name = "imsd-ofonod"; + ofonod.outputName = "imsd-ofonod"; + ApplyStandardArgs(ofonod, args); + ofonod.type = ConfigurationType::Executable; + { + std::array ifaces = {}; + std::array impls = { "implementations/ofonod" }; + ofonod.GetInterfacesAndImplementations(ifaces, impls); + } + ApplyGioFlags(ofonod); + ProjectLint::AddProjectLintRules(ofonod); + return ofonod; } if (wantDialerd) { Configuration dialerd;