Skip to content

Instantly share code, notes, and snippets.

@konecnyna
Created December 16, 2025 19:49
Show Gist options
  • Select an option

  • Save konecnyna/478eb95891216fed46986ab7798626fa to your computer and use it in GitHub Desktop.

Select an option

Save konecnyna/478eb95891216fed46986ab7798626fa to your computer and use it in GitHub Desktop.
Neon Mobile: Incoming Call Flow Documentation (Android Lock Screen)

Neon Mobile: Incoming Call Flow (Phone Locked, App Killed)

Overview

This document describes what happens when a call arrives while the Android app is killed and the phone is locked.

Flow Diagram

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 ✅
Loading

Detailed Flow

Phase 1: FCM Message Arrives

1.1 Native Service Receives FCM

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"

1.2 Store Call Data

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"

1.3 Show Native UI (Two-Layer Approach)

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

Phase 2: User Sees & Answers Call

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

Phase 3: App Launches (Cold Start)

3.1 Bundle Loads

File: index.js

import 'expo-router/entry';

Logs: "Android Bundled 55ms index.js"

3.2 CallContext Mounts

File: contexts/CallContext.tsx

useEffect(() => {
    registerCallKeepListeners({
        onAnswerCall: handleAnswerCall,
        onEndCall: (callUUID) => { /* ... */ }
    });
}, []);

Logs: "[CallKeep] Setup complete"

3.3 Wait for Authentication

The cold-start rehydration effect waits for authState.isAuthenticated to be true.

useEffect(() => {
    if (!authState.isAuthenticated || coldStartHandledRef.current) {
        return;
    }
    rehydratePendingCall();
}, [authState.isAuthenticated]);

Phase 4: Cold-Start Rehydration

4.1 Claim Pending Call

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.

4.2 Validate with Backend

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"

4.3 Navigate & Join

setState({ incomingCall });
router.push('/call');

setTimeout(async () => {
    await acceptIncomingCallInternal(incomingCall);
}, 100);

Logs: "[CallContext:acceptIncomingCallInternal] Joining room https://neonmobile.daily.co/..."


Phase 5: Call Connects

5.1 Join Daily.co Room

File: contexts/CallContext.tsx

await dailyCall.join({ 
    url: pendingCall.roomUrl, 
    token: pendingCall.token 
});

State transitions: idlejoiningconnected

5.2 Update Native UI

RNCallKeep.setCurrentCallActive(callUUID);

Logs: "[CallKeep] Setting call active"

5.3 ✅ User is in call


What Can Go Wrong?

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

Key Files Reference

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

Diagnostic Questions

When investigating "Sometimes this call notification doesn't even come in":

1. Is the native service running?

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

2. Is TelecomManager working?

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

3. What's the timing?

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


Recent Fixes

Fix 1: FCM Service Conflict

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

Fix 2: Cold-Start Race Condition

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment