android(call): Increment 2a — native LiveKit call audio, background service, speaker/BT routing
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>
This commit is contained in:
1 parent
08eff043ec
commit
aa4aeeaba1
9 files changed
+444
-24
No files matched your search
@@ -55,6 +55,14 @@ const CALLKIT_ENABLED = process.env.CALLKIT_ENABLED === '1';
|
||||
// FCM configured (FCM_SERVICE_ACCOUNT) + an app build carrying the native-call Android plugin. Set ANDROID_CALLS=1.
|
||||
const ANDROID_CALLS = process.env.ANDROID_CALLS === '1';
|
||||
|
||||
// Native call MEDIA on Android (Increment 2): LiveKit audio in the plugin + a foreground service, instead of the
|
||||
// WebView carrying the media. INDEPENDENT of ANDROID_CALLS (which only governs the native ring) so media can be
|
||||
// flipped on for testing after the ring is solid. OFF (default) → Android keeps WebView call media (works today,
|
||||
// video included). ON → the Android app drives call audio natively (background audio + Speaker/Bluetooth), but
|
||||
// video is not yet native (Increment 2b) — so enable this for VOICE testing first. Requires an app build carrying
|
||||
// the LiveKit-Android plugin. Set ANDROID_NATIVE_MEDIA=1.
|
||||
const ANDROID_NATIVE_MEDIA = process.env.ANDROID_NATIVE_MEDIA === '1';
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 8090,
|
||||
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
||||
@@ -73,6 +81,7 @@ module.exports = {
|
||||
GIPHY_API_KEY,
|
||||
CALLKIT_ENABLED,
|
||||
ANDROID_CALLS,
|
||||
ANDROID_NATIVE_MEDIA,
|
||||
PUBLIC_DIR,
|
||||
REC_DIR,
|
||||
TRANS_DIR,
|
||||
|
||||
+45
-8
@@ -4346,11 +4346,19 @@ async function setupNativeCall(){
|
||||
const plat=nativePlatform();
|
||||
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
|
||||
if(plat==='android'){
|
||||
// Android Increment 1: native full-screen ring (over the lock screen, wakes from closed via FCM data), but
|
||||
// the WebView still carries the call media. Gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
|
||||
// Android native calling (ring + answer) gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
|
||||
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
|
||||
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
|
||||
_callkitReady=true; _callWebViewMedia=true;
|
||||
_callkitReady=true;
|
||||
// Increment 2: when the server enables native media, the plugin carries the call AUDIO (background audio +
|
||||
// Speaker/Bluetooth) and the WebView must NOT also join SFU media. Until then (default), media stays in the
|
||||
// WebView (video calls included). androidNativeMedia === true → _callWebViewMedia=false (native media path).
|
||||
_callWebViewMedia = !(cfg && cfg.androidNativeMedia);
|
||||
if(!_callWebViewMedia){
|
||||
console.log('[nativecall] Android native call media ON — background audio + native routing');
|
||||
// Android CAN switch the output (unlike iOS): mirror the plugin's real route into the speaker button.
|
||||
try{ NC.addListener('audioRoute', (e)=>{ bzOnAndroidRoute(e); }); }catch(_){}
|
||||
}
|
||||
} else {
|
||||
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
|
||||
// media carries the audio.
|
||||
@@ -4385,7 +4393,7 @@ async function setupNativeCall(){
|
||||
}catch(e){ console.warn('[nativecall] answerCall failed:', e); } });
|
||||
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
|
||||
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
|
||||
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} } } }); // re-applies mic/cam after a #12 media reconnect too
|
||||
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} try{ if(nativeCallAudioOn() && NC.getAudioRoute){ NC.getAudioRoute().then((r)=>bzOnAndroidRoute(r)).catch(()=>{}); } }catch(_){} } }); // re-applies mic/cam (and seeds the Android audio route) after a #12 media reconnect too
|
||||
NC.addListener('callError', (e)=>{ console.warn('[callkit] call error:', (e&&e.error)||''); });
|
||||
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
||||
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
||||
@@ -5588,7 +5596,12 @@ function bzApplyRoute(route){
|
||||
}
|
||||
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
|
||||
// plugin is present.
|
||||
function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
|
||||
function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; if(nativeCallAudioOn()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
|
||||
// Android native call media: the NativeCall plugin owns the audio and CAN switch the output (speaker/earpiece/
|
||||
// bluetooth), unlike iOS. True only when the server enabled native media and the plugin is present.
|
||||
function nativeCallAudioOn(){ try{ return nativePlatform()==='android' && _callkitReady && !_callWebViewMedia && !!nativeCallPlugin(); }catch(_){ return false; } }
|
||||
let _androidRoutes=[]; // outputs the plugin reports we can switch to (from the audioRoute event / getAudioRoute)
|
||||
function bzOnAndroidRoute(e){ if(!e) return; if(e.output) _nativeOutput=e.output; if(Array.isArray(e.available)) _androidRoutes=e.available; refreshSpkBtn(); }
|
||||
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
|
||||
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
|
||||
function makeDraggable(el, key){
|
||||
@@ -5637,11 +5650,22 @@ let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }
|
||||
let _nativeOutput=null;
|
||||
function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported
|
||||
function spkIcon(){
|
||||
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'bluetooth' : (_nativeOutput==='wired' ? 'headphones' : 'speaker'); }
|
||||
// iosRoute() is truthy on iOS AND on Android native media (both drive the icon from the real output _nativeOutput).
|
||||
if(iosRoute()){
|
||||
if(_nativeOutput==='bluetooth') return 'bluetooth';
|
||||
if(_nativeOutput==='wired') return 'headphones';
|
||||
if(_nativeOutput==='earpiece') return 'speakerOff'; // Android can route to the earpiece (iOS reports 'receiver')
|
||||
return 'speaker';
|
||||
}
|
||||
return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff');
|
||||
}
|
||||
function routeLabel(){
|
||||
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'Bluetooth' : (_nativeOutput==='wired' ? 'Headset' : 'Speaker'); }
|
||||
if(iosRoute()){
|
||||
if(_nativeOutput==='bluetooth') return 'Bluetooth';
|
||||
if(_nativeOutput==='wired') return 'Headset';
|
||||
if(_nativeOutput==='earpiece') return 'Earpiece';
|
||||
return 'Speaker';
|
||||
}
|
||||
return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece');
|
||||
}
|
||||
function refreshSpkBtn(){
|
||||
@@ -5671,7 +5695,7 @@ function bzInitNativeRoute(){
|
||||
function bzOnNativeRoute(output){
|
||||
if(!output) return;
|
||||
_nativeOutput=output;
|
||||
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
|
||||
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='earpiece' ? 'earpiece' : (output==='speaker' ? 'speaker' : meetRoute));
|
||||
refreshSpkBtn();
|
||||
}
|
||||
// Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
|
||||
@@ -5783,6 +5807,19 @@ async function toggleSpeakerphone(){
|
||||
: 'On speaker — connect a Bluetooth/wired headset to use it');
|
||||
return;
|
||||
}
|
||||
// Android native call media: the plugin CAN switch the route — cycle through the outputs it reports (bluetooth/
|
||||
// wired only appear while a headset is connected).
|
||||
if(nativeCallAudioOn()){
|
||||
const NC=nativeCallPlugin(); if(!NC){ return; }
|
||||
const avail=(_androidRoutes && _androidRoutes.length) ? _androidRoutes : ['speaker','earpiece'];
|
||||
const order=['speaker','earpiece','bluetooth','wired'].filter(r=>avail.indexOf(r)>=0);
|
||||
const cur=_nativeOutput||'speaker';
|
||||
const i=order.indexOf(cur);
|
||||
const next=order[(i<0?0:(i+1))%(order.length||1)] || 'speaker';
|
||||
try{ const r=await NC.setAudioRoute({ route: next }); if(r && r.output){ _nativeOutput=r.output; } else { _nativeOutput=next; } }catch(_){ _nativeOutput=next; }
|
||||
refreshSpkBtn(); toast(routeLabel());
|
||||
return;
|
||||
}
|
||||
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
|
||||
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
|
||||
const routes = ['speaker','earpiece'].concat(bt?['bt']:[]);
|
||||
|
||||
+2
-2
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
|
||||
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
||||
const CALLS = require('./calls');
|
||||
require('./reminders'); // start the 10-minute meeting-reminder loop
|
||||
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED, ANDROID_CALLS } = require('./config');
|
||||
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED, ANDROID_CALLS, ANDROID_NATIVE_MEDIA } = require('./config');
|
||||
const https = require('https');
|
||||
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
|
||||
function fetchJSON(url) {
|
||||
@@ -993,7 +993,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
|
||||
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
|
||||
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
||||
route('GET', '/api/meetings/config', (req, res) => {
|
||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, androidCall: ANDROID_CALLS, fcm: PUSH.fcmReady() });
|
||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, androidCall: ANDROID_CALLS, androidNativeMedia: ANDROID_NATIVE_MEDIA, fcm: PUSH.fcmReady() });
|
||||
});
|
||||
|
||||
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
|
||||
|
||||
Reference in new issue
Block a user