Porting baresip to Wear OS: What We Shipped and What Is Left
Porting a C-based telephony stack to a wrist-worn watch is not a typical weekend project. After spending the last few weeks deep in the Android NDK, Wear Compose, and baresip internals, I wanted to write down exactly what we shipped, what broke along the way, and what is still left to do. This post is a technical walkthrough of the Wear OS port in baresip-studio.
Background
baresip-studio is an Android SIP client that wraps the baresip library with a native NDK layer. On phones it handles registrations, calls, audio routing, and account management through a full Activity-based UI. For a long time the watch was just a notification target: you could see incoming calls on a Wear companion surface, but the actual SIP stack lived on the phone. We wanted to change that.
The goal was simple: run baresip natively on the watch, register accounts directly from the wrist, and answer calls without the phone in the loop. That goal turned out to be simple to state and hard to execute.
Why Wear OS
Wear OS is an attractive target for a softphone for the same reason it is attractive for any communication tool: you do not need to pull your phone out of your pocket. A watch on your wrist is always reachable, always listening, and already paired with a headset. From an architecture standpoint, it also forces you to strip away assumptions. Phone softphones rely heavily on Telecom/ConnectionService, Bluetooth SCO audio routing, and lifecycle hacks that do not exist on the watch. If you want a call to ring on your wrist, the SIP stack has to be there.
The architecture
The new module is called :wear. It is a standalone com.android.application module with Wear Compose, minSdk 30, compileSdk 37. The native layer ships a second JNI library called libwearbaresip.so, which links against the same baresip static libraries as the phone app but removes phone-specific callbacks like g_ctx.mainActivityObj.
The UI is built with Jetpack Compose for Wear OS: a ScalingLazyColumn dialer, a dedicated in-call screen with answer, decline, hang-up, and mute actions, and a debug accounts configuration screen. Because there is no Telecom framework on Wear, call state is managed entirely inside baresip and surfaced through JNI callbacks.
A foreground service keeps the SIP stack alive. On Wear, foreground services have stricter requirements than on phones: the microphone type works without requesting default-dialer, while phoneCall is rejected outright.
What broke first
The first boot of the watch module ended in a SecurityException from the foreground service. Starting the FGS from Application.onCreate() worked on phones but failed on Wear because the app was not yet in a foreground-eligible state. Moving the service start into MainActivity.onCreate(), when the Activity is already visible, fixed the crash.
The next failure was more obscure. After initialization, baresip attempted registration and immediately returned ENOTSUP [95]: "Operation not supported on transport endpoint." On the phone this never happened, but the watch had two compounding issues. First, baresip had no local interface addresses, so it could not bind a source socket. Second, baresip registered accounts synchronously during ua_init before DNS servers were configured.
The first half of the fix was to enumerate local IPs from NetworkInterface.getNetworkInterfaces() and pass them into baresipStart so baresip could bind a valid local address. IPv6 link-local addresses are filtered out because baresip's net layer rejects them with EINVAL. The second half was to collect DNS servers from ConnectivityManager, call net_use_nameserver() after baresip_init but before ua_init, and only then register accounts. Once those two changes landed, the stack actually attempted a SIP REGISTER.
A side note that turned out to be important: baresip does not register accounts automatically from the config file. Even with a valid accounts entry, registration only happens after an explicit ua_register(ua.uap) call. The phone app already knew this and exposed a "register?" checkbox. The watch app mirrors the same behavior with a manual Register button.
UI on a 1.5-inch screen
Wear Compose has its own opinion about layout. Scaling lists, curved typography, and touch targets measured in density-independent pixels rather than logical pixels. The biggest surprise was that androidx.compose.material.TextField does not exist in Wear Compose. The correct replacement is androidx.compose.material3.OutlinedTextField, which required adding a compose-material3 dependency.
We also had to remove the org.jetbrains.kotlin.android plugin. AGP 9.x already bundles Kotlin support, and keeping the old plugin declaration caused unresolved reference errors inside the DSL.
Provisioning
Typing SIP credentials on a watch screen is unpleasant. To make onboarding tolerable, we added baresip:// provisioning, mirroring the phone app's behavior. A URL encodes an endpoint and extension inside an RSA/AES encrypted bundle. The watch decrypts the bundle, writes accounts and auth files in baresip format, and reloads the stack. Physical hardware buttons also work: KEYCODE_STEM_1 and KEYCODE_BACK both navigate back from the accounts screen via onKeyDown.
What works today
libwearbaresip.so loads. libre_init and baresip_init complete. re_main() runs on a background thread from the foreground service. DNS servers are applied before registration. The watch can reach mail.txt3.net:5062 over TCP/TLS. The dialer, in-call screen, debug accounts, and provisioning link all function. We verified an actual SIP REGISTER attempt on the armeabi-v7a device over wireless ADB.
The most important verification was not that the watch compiled or that the UI launched, but that the SIP stack attempted a real outbound registration. Without that, the rest of the work is a very polished music player.
The TLS problem
Registration currently fails with Protocol error [71] because the Asterisk server presents a self-signed certificate signed by an expired Private CA. The watch trust store does not contain this CA, so verification fails. For testing, we can set sip_verify_server=no in the baresip config, but that is not acceptable for production. The next step is to pin the Asterisk Private CA or bundle it into the APK and install it into the watch trust store during onboarding.
Audio routing
Wear OS has no Telecom or ConnectionService. Audio for calls must travel through AAudio and Bluetooth HFP. The JNI layer already includes the AAudio headers, but no audio module is wired yet. This is the largest remaining piece of work: routing microphone input and speaker output during a call, with automatic fallback to the paired headset when available.
Debug accounts and cleanup
The debug accounts screen is intentionally ugly and unfiltered: scrollable fields for AOR, auth_user, auth_pass, outbound proxy, and registration interval. It is the only way to configure an account without provisioning, but it is not a user-facing feature. It needs to be removed or hidden behind a developer flag before any kind of release.
Next steps
The immediate roadmap is:
1. Fix TLS trust by pinning the Asterisk Private CA and setting sip_verify_server=yes.
2. Wire AAudio and Bluetooth HFP for call audio.
3. Add network-change detection and automatic re-registration.
4. Remove the debug accounts screen.
5. Verify RTP audio end-to-end over the watch speaker, microphone, and paired headset.
What we learned
The watch kernel allows raw UDP and TCP socket connects. The ENOTSUP [95] errors were never a kernel-level restriction; they were caused by missing local interface addresses and missing DNS server configuration. Once those two environmental inputs were provided in the correct order, baresip behaved exactly as it does on the phone.
Wear OS also rewards minimalism. Without Telecom, without ConnectionService, and without the assumption that the phone is always nearby, every piece of the stack becomes explicit. That explicitness is painful at first, but it produces a cleaner architecture. The watch module is smaller, more focused, and more honest about what it actually does.
If you are working on a similar NDK port or on Wear OS telephony, the baresip-studio repository is open. The feature/wear-sip branch contains everything discussed here, including the JNI layer, the Compose UI, and the provisioning implementation.