Replaces WebView call media on Android with a native LiveKit connection (iOS
parity), so call audio survives backgrounding and the output can be switched
to Speaker/Bluetooth/Earpiece — the #3 (missing speaker control) and #4
(background audio) items.
Gated behind a new, default-off server flag ANDROID_NATIVE_MEDIA (independent
of ANDROID_CALLS, which only governs the native ring). Flag off = today's
WebView media, video included; flag on = native audio (video is Increment 2b,
so enable it for VOICE testing first). No behavior change until flipped.
Native (mobile/plugins/native-call/android):
- build.gradle: add kotlin-android + io.livekit:livekit-android 2.29.2 (+ twilio
audioswitch, coroutines) + JitPack repo; minSdk 24 (LiveKit floor).
- CallMedia.kt: LiveKit Room wrapper — connect/mic/disconnect + AudioSwitchHandler
routing (auto + selectDevice) and route-change events.
- CallForegroundService.java: microphone-type foreground service for background
audio (no-ops without RECORD_AUDIO so it never crashes the call).
- NativeCallPlugin.java: real bodies for reportOutgoingCall/reconnectRoom/setMuted
/endCall + new setAudioRoute/getAudioRoute; fires callConnected/callError/audioRoute.
- AndroidManifest: FG service + FOREGROUND_SERVICE(_MICROPHONE)/RECORD_AUDIO/
MODIFY_AUDIO_SETTINGS/BLUETOOTH_CONNECT.
- android-patch.js: bump the generated app minSdk to 24.
Server:
- config.js/routes.js: ANDROID_NATIVE_MEDIA → /api/meetings/config.androidNativeMedia.
Web (home.html):
- Android flips to the native-media path when androidNativeMedia is set; the
existing outgoing/answer/mic/end code then drives the plugin unchanged.
- Speaker button wired for Android native: cycles speaker/earpiece/bluetooth via
NC.setAudioRoute and reflects the real output from the audioRoute event.
Video stays no-op on the native path (Increment 2b).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The incoming ring is driven by our own Ringer (we aren't a telecom
ConnectionService yet), so the OS doesn't route volume keys to silence
it. Capture them two ways that together cover every state:
- Foreground/locked: IncomingCallActivity.dispatchKeyEvent consumes
VOLUME_UP/DOWN/MUTE and silences.
- Unlocked/backgrounded heads-up (no activity): a MediaSession with a
remote VolumeProvider so the framework delivers volume presses to us.
Silence stops audio + vibration but keeps the call ringing visually
(notification + full-screen stay) so answer/decline still work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an unlocked Android 14 phone the OS shows our call as a heads-up and does
NOT launch the full-screen IncomingCallActivity (full-screen-intent is
restricted for non-dialer apps while unlocked), so the ring — which lived in
the activity — was silent when unlocked (it rang only when locked, where the
activity launches).
Move the ring to a call-scoped Ringer (looping ringtone + vibration) started
in CallStore.showIncoming and stopped in CallStore.cancel (which every
answer/decline/cancel/end path already calls). The notification channel stays
silent, so there's still a single ring source. IncomingCallActivity no longer
rings — it's just UI — and no longer stops the ring on destroy (backgrounding
the call screen keeps ringing like a real call until answer/decline/timeout).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-device (OnePlus, build 5cb3d0c): new ring UI confirmed; backgrounded rang
twice (channel sound + activity ringtone, and both wake paths raised it);
battery dialog deemed unprofessional (WhatsApp/Teams don't prompt it).
- Double-ring: notification channel is now SILENT (new id bzc_incoming_calls_v2,
no sound/vibration, onlyAlertOnce) so IncomingCallActivity is the single ring
source (looping ringtone + vibrator). showIncoming dedups atomically
(if(!active.add(uuid)) return) so the FCM push and WebSocket paths can't both
ring the same call.
- Battery dialog removed: dropped requestCallReliability (web call + plugin
method) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission.
- Logging: Log.i(bzcall, ...) across the wake chain to diagnose swiped-away
(OnePlus force-kills on swipe and blocks FCM cold-start).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses on-device feedback from Increment 1:
- #2 Professional incoming screen: IncomingCallActivity redesigned — brand-
blue backdrop, circular avatar with the caller's initial, name + call
type, and two round icon buttons (red hang-up = Decline, green phone =
Accept) instead of the full-screen colored buttons. New vector drawables
ic_call_answer / ic_call_decline (also used on the notification actions).
- #1 Killed-state ring: the missed-call notification arriving while killed
proves FCM reaches the app, so the ring (high-priority DATA) is being
held by OEM battery management (OnePlus/Oppo Doze) until the call is over.
Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + a requestCallReliability()
plugin method that prompts the "allow background activity" dialog once;
the web calls it on Android call setup. (OEM auto-launch is a manual
toggle with no API — guide the user.)
#3 (speaker/Bluetooth) and #4 (background audio) ride Increment 2 (native
LiveKit Android media + foreground service) — kept separate so the LiveKit
dependency can't block these UI/reliability fixes (the iOS coupling lesson).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.
Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
(same jsName as iOS, so the shared web bridge drives both). getToken
(FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
setMuted; video/screen/hole-punch/transcript methods are safe no-ops
until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
MessagingService — handles high-priority call-wake DATA messages
(data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
(showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.
Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.
Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
(native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
joins the meeting normally while the callee still rings via the server.
Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>