Android GPL-compliance roadmap — removing liblinphone¶
Goal: ship a proprietary, GPL-free Astradial Android binary. liblinphone and the linphone-android fork are GPLv3; distributing them (or a derivative) forces the whole app under GPLv3 (source disclosure). This doc is the tracked plan to get to zero GPL.
Status: ACTIVE (started 2026-07-09). Companion: astracall-android-plan.md (engine build), android-status.md (fork app state).
The GPL surface (audit, 2026-07-09)¶
Two distinct GPL liabilities — removing the SDK is necessary but NOT sufficient:
- The native SDK —
org.linphone:linphone-sdk-android:5.5.+(gradle/libs.versions.toml:76,implementation(libs.linphone)inapp/build.gradle.kts:281, fromdownload.linphone.org). Bundles the Belledonne native stack: mediastreamer2, oRTP, belle-sip, bctoolbox, bzrtp — all GPLv3. This is the classic "GPL library you link" trigger. - The app is itself a GPLv3 fork —
LICENSE.txt= GNU GPL v3. 263 Kotlin files underorg.linphone.*; only 20 are ours (org.linphone.astradial.*— login, Pulse, Contacts, Tickets, SIM, push, api, auth). The other ~243 are GPL fork code (linphone call/main/chat/ meetings/conference UI +CoreContext).
Source-API usage: 191 files import org.linphone.core.*, but ~173 are just the SDK Log helper (trivial swap); the real API is Core/Factory/Call/Account/Address/CoreListenerStub + a large chat/conference/Friend chunk (the removable modules).
Verdict: even after the engine cutover deletes dependency #1, distributing the ~243 GPL fork files (#2) keeps the binary GPL-derived. True compliance = a clean-room app with zero Belledonne files — exactly what iOS did (AstraCall is a fresh app, no linphone fork). Android took the half-measure (build in the fork); this roadmap corrects course.
The roadmap (phased; nothing wasted — each phase feeds the next)¶
Phase 1 — Engine cutover — 2-way audio PACKET-PROVEN on staging (native run); real-device pending¶
RCA 2026-07-09 (evidence-based): our engine carries 2-way WebRTC audio — proven running the app natively (x86_64) on the emulator → staging *43: audio out 600 / in 586 pkts growing, ICE CONNECTED, DTLS done, zero SRTP errors; MediaSession matches iOS. Two real bugs found+fixed: (1) FGS crash — MicForegroundService.start() while app not foreground threw ForegroundServiceStartNotAllowedException, killing the call (b4f4a4a84); (2) emulator "no audio" was a binary-translation artifact — app ships arm-only, so on x86_64 emulator it ran arm64 under translation where libsrtp fails to init (Failed to init SRTP err=11 → drops every RTP packet); fixed by adding x86_64 to debug builds. APK 32+b4f4a4a84. Still real-device-pending: moto g34 (native arm64 — translation bug N/A) *43 audio, and the prod devsip media path (only ever proven with iOS on staging; prod transport-wss has no external_media_address — verify on the g34, add it if in=0).
Earlier note — the F2 agent's "live 2-way audio on a real device" claim was FALSE (emulator mic-off + the arm-translation artifact); do not trust unverified agent audio claims.
Superseded: signaling ✅; earlier "real-device audio NOT yet proven": CORRECTION (2026-07-09): an F2 agent claimed "live 2-way audio on a real device" — that was NOT verified; the earlier "audio" was the emulator mic being off (an emulator artifact, the exact RCA-rule trap). The new engine's audio has not been tested on hardware yet. Now set up for the real first test: prod WSS listener is live ([[prod-wss-listener-for-mobile]]) AND Om Chamber ext 1001 is flipped to sip_port='wss' (transport-wss + webrtc=yes + opus,alaw — verified via pjsip show endpoint). So a real WebRTC call can finally negotiate. Test order on the moto g34 (ext 1001): (1) *43 echo — isolates WebRTC 2-way audio, no trunk; (2) ext-to-ext; (3) PSTN to +91 9944421125 (adds Tata trunk + opus→alaw transcode). Only after audio is truly confirmed: DTMF/speaker/wake, then delete liblinphone.
Signaling gate (c45650d4c..47a936d38, provable on emulator): Replaced liblinphone's call backend with our AstraSIP + WebRTC engine, inside the fork. AstraEngine (namespace com.softphone.astradial.engine) integrated + WebRTC dep added; build green, astrasip JVM tests green (15). Registered e1003ra9v72 on staging wss (REGISTER→401→200), placed call INVITE→200→ACK→Established (ICE connected)→BYE; hold works on the wire; DTMF wired (needs a real mic to verify). Debug gate driven by a src/debug-only receiver — never ships. - Deferred to F2 (deliberate): rewiring the 1600-line liblinphone CurrentCallViewModel / in-call UI — F2 rebuilds that UI on the engine anyway, so the app currently places AstraSIP calls but shows no call screen yet. liblinphone is untouched + still compiles (deleted only after the real-device gate). - Still NOT compliance-free (fork code + SDK still present until Phase 3 + the delete step). - Remaining for the real-device (moto g34) gate: flip an Om Chamber ext to sip_port='wss'; prove 2-way audio (getStats) + DTMF (dtmf_mode=rfc4733) + speaker/mute + killed-app FCM wake cold-start (verify the cred-restore-on-cold-process path). Only then delete the liblinphone call code.
Phase 2 — Call UI (mockup #37) in the fork — ✅ rendering gate DONE 2026-07-09 (7b8c5a730)¶
Fresh AstraCallActivity (com.softphone.astradial.engine) — 4 states (incoming/outgoing/active/ended) driven by AstraEngine.onCallState, near-pixel match to #37, light+dark. Controls wired to AstraEngine (hangup/answer/reject/mute/speaker/call-again; Bluetooth stub = Telecom's job). Old 1600-line liblinphone CurrentCallViewModel/CallActivity left intact (deleted only after the real-device gate). Also our code, so it ports to the clean-room app. APK 26+47a936d38. - Remaining (real-device / small follow-ups): in-call numpad→DTMF UI (only the control row built); incoming as a true full-screen-intent notification over locked/killed device (currently direct-launch + showWhenLocked); audio/routing effects (emulator can't test). Then delete liblinphone call code.
Phase 3 — Clean-room app (the compliance finish line) — ACTIVE, started 2026-07-10¶
Grounding (audit 2026-07-10). The clean-room repo astradial-astracall-android (branch master) already contains the engine, GPL-free: - :astrasip — pure-JVM SIP-over-WSS lib (our code). - :probe — a com.android.application (applicationId com.astradial.astracall.probe) holding the working WebRTC/Telecom/wake glue: Engine.kt, MediaSession.kt, AstraConnection.kt, AstraConnectionService.kt, MicForegroundService.kt, WakeReceiver.kt, IncomingCallActivity.kt + a bench MainActivity. Zero linphone / Belledonne.
What's missing = the product app: One UI screens, login, tab host, contacts/tickets/pulse, the
37 call UI, and the FCM/api/auth backend — all of which live in the FORK today (20 `org.linphone.¶
astradial.files + 6com.softphone.astradial.engine` files, buried among 263 GPLv3* files).
Strategy: grow :probe into the product app (reuse the working glue + gradle + manifest that already declares the Telecom/FGS/permission plumbing) rather than green-field a new module. Rename to :app, applicationId com.astradial.phone (preserve the keystore → keeps the FCM token + Firebase registration). NOT a fork — the repo has no Belledonne code.
Ordered sub-phases (each ends at a build/verify gate): 1. ✅ App identity — DONE 2026-07-10. Added a new :app module (com.astradial.phone, left :probe intact as the bench), reused the engine glue. :app:assembleDebug green (verified), zero linphone. Firebase/signing/theme land in sub-phases 2–3 as needed. 2. ✅ Backend port (no UI) — DONE 2026-07-10. Ported AstradialApi, AstradialSession, SecureStore, AstradialSipProvisioner (rebound to the clean Engine.register/unregister), AstradialPush (FCM) into com.astradial.phone.*; org.linphone.core.tools.Log → android.util.Log; Firebase wired (fork's google-services.json, token continuity). Fable-5 audited → 2 should-fix one-liners fixed (Telecom account in register; same-account de-dupe) + probe-label rebrand. Build verified green, zero linphone. Branch phase3/app-module. Deferred (tracked): cold-wake onWake path (Phase C), Application.init (sub-phase 3), wake-gateway must accept the new bundle_id=com.astradial.phone. 3. ⚠️ Login vertical — CODE-COMPLETE 2026-07-10, on-device acceptance PENDING. Built fresh in Jetpack Compose (decision: Compose governs sub-phases 3–6): App(session-init), LoginScreen (#24 + server dropdown + full error taxonomy), wss-gated LinePickerScreen, MainScreen placeholder, state-based nav (no linphone nav). Fable-5 planned → implemented → audited (2 should-fix fixed: cold-start fallback race + rotation state) → build + 10 unit tests green, zero linphone. Default server = Production. Branch phase3/app-module. Remaining gate: on a real device — sign in → pick a wss line → REGISTER 200 on wss://stagesip:8089/ws → Main; sign-out drops the contact. (Blocked on connecting the test device.) On-device watch-items from the audit: server-pill tap opens the menu; late-Registered-after-20s-timeout. 4. ✅ Tab shell — DONE 2026-07-10. Compose Material3 NavigationBar (Pulse|Keypad|Recents| Contacts|Tickets, Keypad start), state-based nav, admin/signed-in tab gating (pure visibleTabs), functional dial pad (call button = inert seam for sub-phase 5), Settings stub w/ sign-out. Fable-5 planned → implemented → audited (guard added for a tab leaving visibleTabs) → build + unit tests green (MainTabs 5, Keypad 4), zero linphone. Branch phase3/app-module (9efa371). On-device rendering check pending device. Feature-screen content = sub-phase 6. 5. ⚠️ Call vertical — CODE-COMPLETE 2026-07-11, on-device audio acceptance PENDING. Ported the in-call engine controls (mute/speaker/hold/DTMF/resetCallFlags, our code back from the fork) + onCallState→listener list + AstraSDP SDP logging; built the #37 Compose call UI (4 states) as one composable / two hosts (MainActivity overlay + Telecom IncomingCallActivity); wired Keypad→Engine.dial. Fable-5 planned → implemented → audited (no blockers; 5 lifecycle should-fixes applied pre-device: Call-Again race, answer idempotence + portrait-lock, listener replay, dial not-registered guard, timer anchor). Build + unit tests green (CallUiState 7), zero linphone. Branch phase3/app-module (9f881bb). Remaining gate (needs the device): REGISTER → *43 2-way audio + mute/speaker/DTMF/inbound. Unproven-until-device: hold (no UI button), DTMF rfc4733, speaker route, mute audibility. 6. Feature screens — split into increments, each plan→implement→Fable-5 audit→fix→verify: - ✅ 6a Contacts (1887c0b) — directory + search + status toggle + shared dial lambda. - ✅ 6b Settings+SIM (8eaf667) — account/server-switch/change-line/₹, Full SIM mode (setSimRouting inbound + click-to-call, iOS parity); audit caught a SIM front-truncation bug (fixed). - ✅ 6c Tickets (92c4662) — list/detail/new, displaySummary unwrap, optimistic patchTicket (NonCancellable write, per-field revert). - ✅ 6d Pulse (e53aa46) — analytics: pure PulseMath (answer-rate/at-risk/WoW), Canvas charts, two-tier load+cache. Audit: exact fork parity incl. the UTC→local hourly bucketing (fixture-pinned). - ✅ 6e Recents (526b85e) — getCalls-backed, own-extension filter (interim; audit fixes: auto-advance pagination, dedup keys, <ext> word-boundary match, blank-ext→empty). Being replaced by a server-side extension-CDR endpoint (platform feature/calls-extension-scope) that fixes outbound attribution (endpoint-join not src) + closes the org-CDR privacy gap. - ⏳ tab-state-retention polish — data tabs reload + lose state on tab switch (cross-cutting). Sub-phase 6 feature-complete 2026-07-11; 142 unit tests green, zero linphone. On-device gate pending device. 7. Cutover — com.astradial.phone becomes the shipping app; the fork astradial-android is retired. Result: implementation(libs.linphone) gone, LICENSE ours, no GPL in the binary.
The fork stays the shipping app until Phase 3 sub-phase 7 lands, so there's always a working build.
Phase 4 — Verify + relicense¶
- Grep the built APK: no
liblinphone*.so, noorg.linphone.coreclasses, nolinphone-sdk. - App
LICENSE→ proprietary. WebRTC (BSD) + Firebase (Apache-2.0) attribution in About. - Play Store submission from the clean binary.
What ports vs what's dropped (Phase 3 detail)¶
- Ports cleanly (ours): everything in
org.linphone.astradial.*—auth/(sign-in, session, SecureStore, SIP provisioner logic),api/,push/(FCM),analytics/(Pulse),contacts/,tickets/, the SIM-Only + server-switch. Re-namespace + rebind toAstraEngine. - Rebuilt fresh (was GPL fork UI): the tab host/nav, dialer, recents, in-call screens, settings — designs exist (mockup-#24 system + #37 call UI); rebuild as our own layouts on our engine.
- Dropped: chat/conversations, meetings, conference (already hidden; not in scope).
Risks / notes¶
- Phase 1 keeps a rollback tag (
pre-astrasip-cutover); direct-replacement in the fork. - The clean-room repo already has the engine (Phases A/B/C) +
astrasip+ WebRTC + Telecom glue. - Keeping
applicationId com.astradial.phone+ the keystore across the cutover preserves the FCM token + Firebase Android app registration (misssellerai). - This is a multi-session effort; track phase completion here.