This document describes what happens when a call arrives while the Android app is killed and the phone is locked.
sequenceDiagram
participant Backend
participant FCM as FCM Service
participant Native as NeonFirebaseMessagingService
participant Android as Android System
participant User
participant App as React Native App
participant CallKeep
participant Daily as Daily.co
Note over Backend,FCM: Phase 1: FCM Message Arrives
Backend->>FCM: Send FCM push (callId, caller info, roomUrl, token)
FCM->>Native: Deliver to native service
Native->>Native: Wake device (PowerManager.WakeLock)
Native->>Native: Parse & store call data to SharedPreferences
Note over Native,Android: Phase 2: Show Native UI
Native->>Android: Post high-priority notification
Native->>Android: TelecomManager.addNewIncomingCall()
Android->>User: Show full-screen incoming call UI
Note over User,App: Phase 3: User Answers
User->>Android: Tap "Answer"
Android->>Native: onAnswerCall(callUUID)
Android->>App: Launch app (cold start)
Note over App,CallKeep: Phase 4: App Initialization
App->>App: Load index.js, mount CallContext
App->>CallKeep: Setup CallKeep listeners
App->>App: Wait for authentication
Note over App,Backend: Phase 5: Cold-Start Rehydration
App->>App: getAndClaimPendingIncomingCall()
App->>Native: Read from SharedPreferences
Native-->>App: Return pending call data
App->>Backend: callsApi.acceptCall(callId)
Backend-->>App: Validation success
App->>App: setState({ incomingCall })
App->>App: router.push('/call')
Note over App,Daily: Phase 6: Join Call
App->>Daily: dailyCall.join({ url, token })
Daily-->>App: Connection established
App->>CallKeep: setCurrentCallActive(callUUID)
CallKeep->>Android: Update native UI to "Connected"
Note over User: User is in call ✅
File: plugins/android-fcm-service/NeonFirebaseMessagingService.kt
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val callId = remoteMessage.data["callId"]
val callerName = remoteMessage.data["callerName"]
// ...
}Actions:
- Acquires wake lock to wake device from Doze mode
- Logs:
"NeonFCMService: FCM message received: callId=..., type=incoming_call"
Storage: SharedPreferences (neon_call_prefs.pending_call)
val callData = JSONObject().apply {
put("callId", callId)
put("callerName", callerName)
put("callerPhone", callerPhone)
put("roomUrl", roomUrl)
put("token", token)
put("callerUserId", callerUserId)
put("timestamp", System.currentTimeMillis())
}
sharedPreferences.edit().putString(KEY_PENDING_CALL, callData.toString()).commit()Logs: "NeonFCMService: Call data stored to SharedPreferences"
A. Show Notification First (Proves user engagement to Android)
showCallNotification(callId, callerName, callerPhone)- Posts high-priority notification with full-screen intent
- Prevents FCM from downgrading future messages
- Logs:
"NeonFCMService: Call notification posted"
B. Show TelecomManager UI (Full-screen incoming call)
telecomManager.addNewIncomingCall(handle, extras)- Displays native phone-style incoming call screen
- Logs:
"NeonFCMService: Native call UI should now be visible" - Fallback: If TelecomManager fails, notification is already shown
User sees:
- ✅ Full-screen incoming call UI (if TelecomManager succeeds)
- ✅ High-priority notification (if TelecomManager fails or as backup)
- Displays:
callerName(e.g., "+16466832431")
User taps "Answer":
- CallKeep native listener fires:
onAnswerCall(callUUID) - Logs:
"[CallKeep] Call answered from native UI" - Android launches the app
File: index.js
import 'expo-router/entry';Logs: "Android Bundled 55ms index.js"
File: contexts/CallContext.tsx
useEffect(() => {
registerCallKeepListeners({
onAnswerCall: handleAnswerCall,
onEndCall: (callUUID) => { /* ... */ }
});
}, []);Logs: "[CallKeep] Setup complete"
The cold-start rehydration effect waits for authState.isAuthenticated to be true.
useEffect(() => {
if (!authState.isAuthenticated || coldStartHandledRef.current) {
return;
}
rehydratePendingCall();
}, [authState.isAuthenticated]);File: utils/pendingCallStore.ts
const pendingCall = await getAndClaimPendingIncomingCall();Checks 3 storage locations in order:
| Priority | Location | Typical Cold-Start State |
|---|---|---|
| 1 | In-memory Map | ❌ Empty (app was killed) |
| 2 | AsyncStorage | ❌ Empty (app was killed) |
| 3 | Native storage (SharedPreferences) | ✅ FOUND HERE |
Logs: "[ColdStart] Claimed pending call from Android native storage"
Important: Clears ALL storage locations atomically to prevent double-processing on app restart/reload.
File: contexts/CallContext.tsx
try {
await callsApi.acceptCall({ callId: pendingCall.callId });
} catch (validationError) {
// Call expired/canceled - clear and abort
await clearPendingCallByUUID(pendingCall.uuid);
return;
}Purpose: Ensures call is still active (not expired/canceled during delay)
Logs:
- Success:
"[ColdStart] Call validated, resuming" - Failure:
"[ColdStart] Pending call is no longer valid, clearing"
setState({ incomingCall });
router.push('/call');
setTimeout(async () => {
await acceptIncomingCallInternal(incomingCall);
}, 100);Logs: "[CallContext:acceptIncomingCallInternal] Joining room https://neonmobile.daily.co/..."
File: contexts/CallContext.tsx
await dailyCall.join({
url: pendingCall.roomUrl,
token: pendingCall.token
});State transitions: idle → joining → connected
RNCallKeep.setCurrentCallActive(callUUID);Logs: "[CallKeep] Setting call active"
| Phase | Failure Mode | Symptom | Logs |
|---|---|---|---|
| 1.1 | Native service doesn't run | Headless JS runs instead, "Activity doesn't exist" error | Only [FCM Headless], no [NeonFCMService] |
| 1.3B | TelecomManager fails | Only notification shows, no full-screen UI | "Call notification posted" but no TelecomManager logs |
| 3.3 | Authentication delay | Cold start waits, delay in joining | Long gap between "Setup complete" and "Claimed pending call" |
| 4.2 | Backend validation fails | Call expired during delay | "Pending call is no longer valid, clearing" |
| 4.3 | Network failure | Can't join Daily.co room | "Network request failed" |
| 5.1 | Daily.co connection failure | Call doesn't connect | Daily.co error logs |
| File | Purpose |
|---|---|
plugins/android-fcm-service/NeonFirebaseMessagingService.kt |
Native FCM handler, shows call UI |
plugins/withAndroidFCMService.js |
Expo config plugin, injects native service |
utils/pendingCallStore.ts |
Atomic claim logic for cold-start |
contexts/CallContext.tsx |
Main call state management & cold-start rehydration |
utils/pushNotifications.ts |
Headless JS fallback handler |
When investigating "Sometimes this call notification doesn't even come in":
Check logs for:
- ✅
[NeonFCMService]: FCM message received - ❌ Only
[FCM Headless]: Message received while app killed
If only Headless: Native service isn't running → App needs rebuild with updated plugin
Check logs for:
- ✅
[NeonFCMService]: Native call UI should now be visible ⚠️ [NeonFCMService]: Call notification posted(notification-only fallback)
If only notification: TelecomManager is failing → Check permissions or device restrictions
Measure gap between:
- FCM arrival:
[NeonFCMService]: FCM message received - Backend validation:
[ColdStart]: Validating pending call with backend
If > 20-30 seconds: Call might expire before validation completes → Authentication delay issue
Commit: a20615d
Problem: React Native Firebase's default service intercepted FCM messages before custom native service
Solution: Config plugin now removes RN Firebase's default service
File: utils/pendingCallStore.ts
Problem: Claiming from one storage location didn't clear others → app restart caused double-processing → second attempt cleared all data
Solution: When claiming from ANY storage, now clears ALL locations atomically
Last Updated: December 16, 2025