Android WebRTC Audio Call Lab (Firestore Signaling + Foreground Service)
JetCallLab is a learning-oriented Android project to explore how real-time audio calls work end-to-end using WebRTC, with a strong focus on:
- Signaling flow (Offer / Answer / ICE)
- Android lifecycle & background execution
- Audio routing (speaker, wired, Bluetooth SCO)
- Call progress feedback (connecting / reconnecting tones)
- In-call UX behavior (proximity screen off)
- Network instability handling & recovery
- Clean separation between UI, state, service, and call engine
⚠️ This project is not meant to be production-ready. It is intentionally built as a lab / playground to understand how apps like WhatsApp, Telegram, Zoom, or Google Meet work under the hood, especially under imperfect network conditions.
- Features
- UI Preview
- Project Structure
- Core Components
- Tech Stack & Libraries
- How It Works (High Level)
- Firebase / Signaling Setup
- How to Run
- Audio & Call Controls
- Call Tones
- Proximity & In-Call Screen Behavior
- Reconnect & Network Recovery
- Lifecycle & Resource Management
- ICE / STUN / TURN Notes
- Known Limitations
- Roadmap
- License
- ✅ Peer-to-peer audio call using WebRTC
- ✅ Firestore-based signaling (Offer / Answer / ICE candidates)
- ✅ Foreground Service keeps calls alive when app is backgrounded
- ✅ Call timer (elapsed time)
- ✅ Mute / Unmute toggle
- ✅ Speaker On / Off toggle
- ✅ Auto audio routing
- Wired headset has the highest priority
- Bluetooth SCO auto-connect when a compatible device is detected
- Speaker enabled only via user toggle (explicit intent)
- Routing is re-evaluated when devices change
- ✅ Bluetooth active indicator (more realistic than a UI toggle)
isBluetoothAvailable= device exists / detectedisBluetoothActive= SCO is actually connected
- ✅ Call Tones
- Audio ringtone is running well while waiting answer (connecting)
- Plays a looping tone while WaitingAnswer / ExchangingIce
- Plays a different looping tone while Reconnecting
- Stops automatically on Connected / End / Fail
- ✅ Proximity screen-off behavior (in-call UX)
- Uses proximity sensor +
PROXIMITY_SCREEN_OFF_WAKE_LOCK - Enabled only for in-call earpiece mode
- Automatically releases on speaker/Bluetooth/wired headset
- Uses proximity sensor +
- ✅ Network reconnect awareness (the best effort)
- Detects
ICE DISCONNECTED / FAILED - Emits
Reconnecting state with attempt count & elapsed seconds - Returns to
Connectedwhen ICE recovers
- Detects
- ✅ Safe cleanup
- Prevents memory leaks, listener leaks, audio lockups, and zombie calls
Minimal call playground UI (Caller / Callee) with timer, mute/speaker toggles, reconnect indicator, and Bluetooth status.
| Condition | Caller | Callee |
|---|---|---|
| Idle | ![]() |
![]() |
| In call (Success) | ![]() |
![]() |
| In call (Waiting Answer/Exchanging ICE) | ![]() |
![]() |
| Reconnecting | ![]() |
![]() |
| Bluetooth Status (auto detected on idle state/on connected state) | ![]() |
![]() |
| Proximity Sensor | ![]() |
![]() |
- Start as Caller or Callee using the same
roomId - Observe call state transitions
- See call duration timer
- Toggle Mute / Speaker
- Toggle End
app/
└── src/main/java/id/yumtaufikhidayat/jetcalllab/
├── enum/
│ ├── AudioRoute.kt # EARPIECE / SPEAKER (Bluetooth auto-detected)
│ ├── CallRole.kt # CALLER / CALLEE role in a call session
│ ├── GateStage.kt # Navigation/Permission gate stages
│ ├── PendingAction.kt # Actions deferred during state transitions
│ ├── RoutePreference.kt # Routing intention: AUTO vs SPEAKER (explicit user intent)
│ ├── TempoPhase.kt # CONNECTING / RECONNECTING phase for timer & UX
│ └── ToneType.kt # CONNECTING / RECONNECTING tone types
│
├── ext/
│ ├── LongExt.kt # Time formatting helpers (e.g. elapsedSeconds → HH:mm:ss)
│ ├── IntentExt.kt # Intent parsing & Service starting helpers
│ ├── ContextExt.kt # Context & Permission helpers
│ └── NotificationExt.kt # Foreground Notification builders
│
├── model/
│ └── CallTempo.kt # Model representing call timer state (elapsed, remaining, timeout)
│
├── service/
│ └── CallService.kt # Foreground Service:
│ # - Call lifecycle
│ # - Timer & tempo handling
│ # - Tone playback coordination
│ # - Proximity coordination
│
├── state/
│ └── CallState.kt # Call state machine
│ # (Idle, Preparing, CreatingOffer,
│ # WaitingAnswer, ConnectedFinal,
│ # Reconnecting, FailedFinal, Ending)
│
├── ui/
│ ├── components/ # Reusable UI Widgets
│ │ ├── AudioPermissionDenied.kt
│ │ └── PermissionGateDialog.kt # Handles permission flow UX
│ ├── screen/
│ │ └── CallScreen.kt # Main Call UI (Pure state-driven)
│ ├── theme/ # Design System (Color, Type, Theme)
│ │ ├── Color.kt # App color definitions
│ │ ├── Theme.kt # Compose theme setup
│ │ └── Type.kt # Typography definitions
│ └── viewmodel/
│ └── CallViewModel.kt # Bridges CallService → UI using StateFlow
│
├── utils/
│ ├── CallTonePlayer.kt # SoundPool-based tones (connecting / reconnecting)
│ ├── FirestoreSignaling.kt # WebRTC signaling via Firestore
│ │ # (Offer / Answer / ICE exchange)
│ ├── ProximityController.kt # Proximity sensor screen-off handling (earpiece-only)
│ └── WebRtcManager.kt # WebRTC core:
│ # PeerConnection, ICE handling,
│ # audio routing, reconnect logic
│
└── MainActivity.kt # Android entry point; hosts Compose content
- Runs the call session inside a Foreground Service
- Manages the call lifecycle, timer (tempo), and coordinates audio tones and proximity sensors
- Owns:
- Call lifecycle & state propagation
- Reconnect state propagation
- Timer (elapsed call time)
- Tone playback coordination (SoundPool)
- Proximity control coordination (earpiece-only)
- Exposes:
state: StateFlow<CallState>elapsedSeconds: StateFlow<Long>isMuted: StateFlow<Boolean>isSpeakerOn: StateFlow<Boolean>isBluetoothActive: StateFlow<Boolean>isBluetoothAvailable: StateFlow<Boolean>isWiredActive: StateFlow<Boolean>
- The core WebRTC abstraction handling PeerConnection and ICE monitoring
- Implements auto-routing logic: Wired → Bluetooth SCO → Speaker → Earpiece
- Responsibilities:
- PeerConnection creation
- ICE server configuration
- Offer / Answer lifecycle
- ICE candidate exchange
- ICE state monitoring & reconnect detection
- Reconnect behavior:
- Tracks whether call was ever connected
- Emits
Reconnectingon ICE failure - Emits
Connectedwhen ICE recovers
- Audio routing:
- Wired → Speaker (if explicitly chosen) → Bluetooth SCO (AUTO) → Earpiece fallback
- Bluetooth is auto-routed, not toggled
- Located in ui/components, it manages the complex permission flow required for microphone access before a call starts
- Implements “phone-call like” UX by turning the screen off when the device is close to the user’s face
- Uses:
- Proximity sensor
(Sensor.TYPE_PROXIMITY) PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK(deprecated but still widely used in calling apps)
- Proximity sensor
- Enabled only when it makes sense (earpiece mode), and automatically disabled on other routes
- A coordination layer using Firebase Firestore to exchange SDP Offers, Answers, and ICE Candidates
- Stores:
- Offer
- Answer
- Caller & Callee ICE candidates
- Acts only as coordination layer, not media transport
- Bridges UI ↔ Service
- Keeps UI logic simple (“dumb UI”)
- Collects state via
StateFlow
- Call / Answer / End
- Mute & Speaker toggle
- Timer & call state rendering
- Bluetooth + wired indicator (from flows)
- Kotlin
- Jetpack Compose
- Coroutines & StateFlow
- Android Foreground Service
- SensorManager
- PowerManager
org.webrtc(Google SDK)PeerConnectionAudioTrackICE / SDP / RTP handling
- Firebase Firestore
- Used purely for signaling
- No media data flows through Firestore
AudioManagerSoundPoolJavaAudioDeviceModuleSensorManager + PowerManager(Proximity)- Hardware Echo Cancellation & Noise Suppression (when available)
- Bluetooth SCO routing (device dependent)
- Accompanist Permissions (mic permission)
- AndroidX Lifecycle & ViewModel
-
Signaling phase
- Caller creates SDP Offer
- Offer is published to Firestore
- Callee reads offer, creates SDP Answer
- Answer is sent back via Firestore
-
ICE negotiation
- Both peers exchange ICE candidates
- STUN is tried first
- TURN is used as fallback when NAT/firewall blocks direct connection
-
Media transport
- Audio flows directly peer-to-peer
- Signaling server is no longer involved
Firestore structure (conceptual):
rooms/{roomId}/offer
rooms/{roomId}/answer
rooms/{roomId}/callerCandidates/{autoId}
rooms/{roomId}/calleeCandidates/{autoId}
⚠️ For learning only.
Production apps must secure rules and authentication.
- Prepare Firebase project & Firestore
- Add
google-services.jsontoapp/ - Install app on two physical devices and use the same roomId
- One device taps Call and the other taps Answer
- Minimize app → call continues (Foreground Service)
- Connect Bluetooth earbuds/headset → audio will auto-route (device dependent)
-
Mute
audioTrack.setEnabled(false)
-
Speaker
- Controlled via
AudioRoute+AudioManager.isSpeakerphoneOn
- Controlled via
-
Bluetooth (Auto + Realtime Device Listener)
-
Bluetooth is not manually toggled from the UI.
-
Bluetooth device status is monitored in realtime, even while the call state is Idle (no need to press Call / Answer to refresh the state).
-
Detection mechanism:
- The app listens to audio device changes via:
AudioDeviceCallback/onAudioDevicesAdded()/onAudioDevicesRemoved() - This allows the UI to immediately reflect available / unavailable when earbuds or headsets are connected or disconnected from system quick settings.
- For actual SCO usage, the app listens to
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED
- The app listens to audio device changes via:
-
Indicator definitions (to avoid misleading UI):
isBluetoothAvailable= a Bluetooth audio device is detected (device presence)isBluetoothActive= Bluetooth SCO is actually connected and used for call audio
-
Auto routing behavior:
- When
isBluetoothAvailable == true:- The app attempts
startBluetoothSco()automatically (best-effort) - UI shows Active only after SCO is confirmed connected via broadcast
- The app attempts
- When a Bluetooth device is disconnected or turned off:
isBluetoothAvailableis updated tofalseautomaticallyisBluetoothActiveis forced tofalse- Audio route falls back to earpiece / speaker / wired headset based on the routing priority rules
- When
Note: Bluetooth behavior varies across OEMs. Some devices report Bluetooth connected (A2DP) while SCO is not yet active until an in-call session exists. This project intentionally separates Available vs Active to reflect real routing state.
-
-
Timer
- Based on
SystemClock.elapsedRealtime() - Safe across UI recreation
- Based on
JetCallLab includes call progress tones so the user gets audio feedback during non-connected phases.
- Plays CONNECTING tone while:
WaitingAnswerExchangingIce
- Plays RECONNECTING tone while:
Reconnecting
- Stops automatically when:
Connected / ConnectedFinalFailed / FailedFinalEnding / Idle
Audio ringtone is running well while waiting answer (connecting), and stops when the call becomes connected.
JetCallLab includes a ProximityController to mimic real calling apps behavior: turn off the screen when the phone is near the user’s face (to prevent accidental touches).
- Automatically turns off the screen using PROXIMITY_SCREEN_OFF_WAKE_LOCK only when the user uses the earpiece
- It is automatically disabled when the speaker, wired headset, or Bluetooth is active
- Register the proximity sensor
- When NEAR → acquire PROXIMITY_SCREEN_OFF_WAKE_LOCK
- When FAR → release the WakeLock
Proximity is enabled only for in-call earpiece mode, meaning:
✅ Enabled when:
inCall == true- speaker is off
- Bluetooth (SCO) is not active
- wired headset is not active
❌ Disabled when:
- Speaker is on (hands-free mode)
- Bluetooth SCO is active (earbuds/headset)
- Wired headset/jack/USB headset is present
This prevents “weird UX” such as screen turning off while the user is on speaker or using earbuds.
Why PROXIMITY_SCREEN_OFF_WAKE_LOCK?
Android marks it deprecated, but in practice it’s still the closest behavior to what calling apps do—as long as the device supports proximity wake locks.
If a device does not support proximity:
- the controller becomes a no-op
- calls still work normally
JetCallLab does not guarantee seamless reconnection, but it demonstrates how apps detect and react to network instability.
- Detects
ICE DISCONNECTED / FAILEDstate - Emits Reconnecting state with attempt count
- Returns to
Connectedonce the ICE layer recovers without an explicit ICE restart
- WiFi → airplane mode → WiFi
- WiFi → cellular → WiFi
- Temporary network loss during active call
- ICE enters DISCONNECTED / FAILED
- Call emits Reconnecting(attempt, elapsedSeconds)
- UI & foreground notification reflect reconnecting state
- When ICE becomes CONNECTED again:
- Audio resumes
- State transitions to Connected
- Call timer continues
This is best-effort recovery, relying on WebRTC ICE behavior. No explicit ICE restart is performed.
-
On
endCall():- Proximity listener is stopped
- WakeLock is released (if held)
- Cancel coroutines & timeouts
- Remove Firestore listeners
- Close & dispose:
- PeerConnection
- AudioSource & AudioTrack
- AudioDeviceModule
- Restore:
- Audio mode
- Speaker & Bluetooth state
- Abandon audio focus
- Stop foreground service
-
This prevents:
- Memory leaks
- Audio routing bugs
- Zombie background calls
- STUN works for simple NAT
- Corporate or restricted networks may block direct P2P
- TURN over TCP/TLS 443 has the highest success rate
- Even with TURN, some networks may still block calls
Intermittent connectivity on office Wi-Fi is expected behavior.
- Learning project, not production-grade
- No authentication or security rules for signaling
- No explicit ICE restart (restartIce())
- No PeerConnection rebuild on hard failure
- Reconnect success depends heavily on:
- Network type
- TURN availability
- OEM WebRTC behavior
- Audio echo can occur when devices are physically close
- Bluetooth SCO behavior varies a lot across OEM (some require manual user interaction)
- Bluetooth SCO auto-routing
- Bluetooth device connection status is detected in realtime (before call starts)
- Reconnect state & recovery indicator
- Proximity sensor integration (earpiece-only screen off)
- Call progress tones (connecting / reconnecting) using SoundPool
- In-call notification
- Incoming call notification
Private / learning project.
Use at your own discretion.











