Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4702ee7
merge feat.voip-lib
diegolmello Jan 12, 2026
e532171
feat(voip): enhance call handling with UUID mapping and event listeners
diegolmello Jan 12, 2026
1299b0e
Base call UI
diegolmello Jan 12, 2026
d2cef3d
feat(voip): integrate Zustand for call state management and enhance C…
diegolmello Jan 13, 2026
b0b78cd
feat(voip): add simulateCall function for mock call handling in UI de…
diegolmello Jan 13, 2026
0d18314
refactor(CallView): update button handlers and improve UI responsiveness
diegolmello Jan 13, 2026
4b33a79
Add pause-shape-unfilled icon
diegolmello Jan 13, 2026
ece7e27
Base CallHeader
diegolmello Jan 14, 2026
c3dd2ae
toggleFocus
diegolmello Jan 14, 2026
1df1b29
collapse buttons
diegolmello Jan 14, 2026
8f9129e
Header components
diegolmello Jan 14, 2026
8a5de04
Hide header when no call
diegolmello Jan 14, 2026
a9ec70d
Timer
diegolmello Jan 14, 2026
d6229d9
Add use memo
diegolmello Jan 14, 2026
e718561
Add voice call item on sidebar
diegolmello Jan 14, 2026
26502cb
cleanup
diegolmello Jan 14, 2026
db29a47
Temp use @rocket.chat/media-signaling from .tgz
diegolmello Jan 14, 2026
2b16f4b
cleanup
diegolmello Jan 15, 2026
eae9137
Check module and permissions to enable voip
diegolmello Jan 15, 2026
bb2a8bb
Refactor stop method to use optional chaining for media signal listeners
diegolmello Jan 15, 2026
10593d6
voip push first test
diegolmello Jan 16, 2026
b6766f3
Add VoIP call handling with pending call management
diegolmello Jan 16, 2026
ac85af8
Remove pending store and create getInitialEvents on app/index
diegolmello Jan 20, 2026
9b28770
Attempt to make iOS calls work from cold state
diegolmello Jan 20, 2026
5c5e2be
lint and format
diegolmello Jan 20, 2026
01e42e2
Patch callkeep ios
diegolmello Jan 20, 2026
aa3ca88
Temp send iOS voip push token on gcm
diegolmello Jan 20, 2026
548e855
Temp fix require cycle
diegolmello Jan 20, 2026
abbb072
chore: format code and fix lint issues [skip ci]
diegolmello Jan 20, 2026
77cb36e
CallIDUUID module on android and voip push
diegolmello Jan 21, 2026
59f25eb
Add setCallUUID on useCallStore to persist calls accepted on native A…
diegolmello Jan 22, 2026
cd74d43
remove callkeep from notification
diegolmello Jan 23, 2026
9b71cf9
Android Incoming Call UI POC
diegolmello Jan 27, 2026
7348135
chore: format code and fix lint issues [skip ci]
diegolmello Jan 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions __mocks__/react-native-callkeep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default {
setup: jest.fn(),
canMakeMultipleCalls: jest.fn(),
displayIncomingCall: jest.fn(),
endCall: jest.fn(),
setCurrentCallActive: jest.fn(),
addEventListener: jest.fn((event, callback) => ({
remove: jest.fn()
}))
};
41 changes: 41 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
<!-- permissions related to jitsi call -->
<uses-permission android:name="android.permission.BLUETOOTH" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />
Comment on lines +25 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add android:required="false" to avoid filtering devices.

Without required="false", Play Store will filter out devices that lack these hardware features. Most VoIP apps should work on devices without dedicated audio output or microphone hardware (e.g., tablets using Bluetooth).

Proposed fix
-    <uses-feature android:name="android.hardware.audio.output" />
-    <uses-feature android:name="android.hardware.microphone" />
+    <uses-feature android:name="android.hardware.audio.output" android:required="false" />
+    <uses-feature android:name="android.hardware.microphone" android:required="false" />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
🤖 Prompt for AI Agents
In `@android/app/src/main/AndroidManifest.xml` around lines 25 - 26, Update the
two <uses-feature> entries for android.hardware.audio.output and
android.hardware.microphone so they don't cause Play Store device filtering:
modify the <uses-feature android:name="android.hardware.audio.output" /> and
<uses-feature android:name="android.hardware.microphone" /> elements to include
android:required="false" (i.e., set the required attribute to false for both
features) so devices without those hardware features are not excluded.


<!-- android 13 notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Expand Down Expand Up @@ -104,6 +116,35 @@
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${BugsnagAPIKey}" />

<activity
android:name="chat.rocket.reactnative.voip.IncomingCallActivity"
android:exported="false"
android:launchMode="singleInstance"
android:showOnLockScreen="true"
android:turnScreenOn="true"
android:showWhenLocked="true"
android:theme="@style/Theme.IncomingCall"
android:excludeFromRecents="true"
android:taskAffinity="chat.rocket.reactnative.voip" />

<service
android:name="chat.rocket.reactnative.voip.VoipForegroundService"
android:exported="false"
android:foregroundServiceType="phoneCall" />

<service android:name="io.wazo.callkeep.VoiceConnectionService"
android:label="Wazo"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true"
android:foregroundServiceType="microphone"
>
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>

<service android:name="io.wazo.callkeep.RNCallKeepBackgroundMessagingService" />
</application>

<queries>
Expand Down
Binary file modified android/app/src/main/assets/fonts/custom.ttf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import chat.rocket.reactnative.storage.MMKVKeyManager;
import chat.rocket.reactnative.storage.SecureStoragePackage;
import chat.rocket.reactnative.notification.VideoConfTurboPackage
import chat.rocket.reactnative.notification.PushNotificationTurboPackage
import chat.rocket.reactnative.notification.VoipTurboPackage
import chat.rocket.reactnative.notification.CallIdUUIDTurboPackage

/**
* Main Application class.
Expand All @@ -43,6 +45,8 @@ open class MainApplication : Application(), ReactApplication {
add(WatermelonDBJSIPackage())
add(VideoConfTurboPackage())
add(PushNotificationTurboPackage())
add(VoipTurboPackage())
add(CallIdUUIDTurboPackage())
add(SecureStoragePackage())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package chat.rocket.reactnative.notification

import com.facebook.react.bridge.ReactApplicationContext
import java.security.MessageDigest

/**
* CallIdUUID - Converts a callId string to a deterministic UUID v5.
* This is used by CallKeep which requires UUIDs, while the server sends random callId strings.
*
* The algorithm matches the iOS implementation in CallIdUUID.swift to ensure
* consistency across platforms.
*/
object CallIdUUID {

// Fixed namespace UUID for VoIP calls (RFC 4122 URL namespace)
// Using the standard URL namespace UUID: 6ba7b811-9dad-11d1-80b4-00c04fd430c8
private val NAMESPACE_UUID = byteArrayOf(
0x6b.toByte(), 0xa7.toByte(), 0xb8.toByte(), 0x11.toByte(),
0x9d.toByte(), 0xad.toByte(),
0x11.toByte(), 0xd1.toByte(),
0x80.toByte(), 0xb4.toByte(),
0x00.toByte(), 0xc0.toByte(), 0x4f.toByte(), 0xd4.toByte(), 0x30.toByte(), 0xc8.toByte()
)

/**
* Generates a UUID v5 from a callId string.
* Uses SHA-1 hash of namespace + callId, then formats as UUID v5.
*
* @param callId The call ID string to convert
* @return A deterministic UUID string in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
*/
@JvmStatic
fun generateUUIDv5(callId: String): String {
// Concatenate namespace UUID bytes with callId UTF-8 bytes
val callIdBytes = callId.toByteArray(Charsets.UTF_8)
val data = ByteArray(NAMESPACE_UUID.size + callIdBytes.size)
System.arraycopy(NAMESPACE_UUID, 0, data, 0, NAMESPACE_UUID.size)
System.arraycopy(callIdBytes, 0, data, NAMESPACE_UUID.size, callIdBytes.size)

// SHA-1 hash
val md = MessageDigest.getInstance("SHA-1")
val hash = md.digest(data)

// Set version (4 bits) to 5 (0101)
hash[6] = ((hash[6].toInt() and 0x0F) or 0x50).toByte()

// Set variant (2 bits) to 10
hash[8] = ((hash[8].toInt() and 0x3F) or 0x80).toByte()

// Format as UUID string (only use first 16 bytes)
return String.format(
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
hash[0].toInt() and 0xFF, hash[1].toInt() and 0xFF, hash[2].toInt() and 0xFF, hash[3].toInt() and 0xFF,
hash[4].toInt() and 0xFF, hash[5].toInt() and 0xFF,
hash[6].toInt() and 0xFF, hash[7].toInt() and 0xFF,
hash[8].toInt() and 0xFF, hash[9].toInt() and 0xFF,
hash[10].toInt() and 0xFF, hash[11].toInt() and 0xFF, hash[12].toInt() and 0xFF,
hash[13].toInt() and 0xFF, hash[14].toInt() and 0xFF, hash[15].toInt() and 0xFF
)
}
}

/**
* React Native TurboModule implementation for CallIdUUID.
* Exposes the CallIdUUID functionality to JavaScript.
*/
class CallIdUUIDModule(reactContext: ReactApplicationContext) : NativeCallIdUUIDSpec(reactContext) {

override fun getName() = NativeCallIdUUIDSpec.NAME

override fun toUUID(callId: String): String = CallIdUUID.generateUUIDv5(callId)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package chat.rocket.reactnative.notification

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider

class CallIdUUIDTurboPackage : BaseReactPackage() {

override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
if (name == NativeCallIdUUIDSpec.NAME) {
CallIdUUIDModule(reactContext)
} else {
null
}

override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
NativeCallIdUUIDSpec.NAME to ReactModuleInfo(
name = NativeCallIdUUIDSpec.NAME,
className = NativeCallIdUUIDSpec.NAME,
canOverrideExistingModule = false,
needsEagerInit = false,
isCxxModule = false,
isTurboModule = true
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package chat.rocket.reactnative.notification

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.turbomodule.core.interfaces.TurboModule

abstract class NativeCallIdUUIDSpec(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext), TurboModule {

companion object {
const val NAME = "CallIdUUID"
}

override fun getName(): String = NAME

@ReactMethod(isBlockingSynchronousMethod = true)
abstract fun toUUID(callId: String): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package chat.rocket.reactnative.notification

import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.turbomodule.core.interfaces.TurboModule

abstract class NativeVoipSpec(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext), TurboModule {

companion object {
const val NAME = "VoipModule"
}

override fun getName(): String = NAME

@ReactMethod
abstract fun getPendingVoipCall(promise: Promise)

@ReactMethod
abstract fun clearPendingVoipCall()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ class NotificationIntentHandler {

/**
* Handles a notification Intent from MainActivity.
* Processes both video conf and regular notification intents.
* Processes VoIP, video conf, and regular notification intents.
*/
@JvmStatic
fun handleIntent(context: Context, intent: Intent) {
// Handle video conf action first
// Handle VoIP action first
if (handleVoipIntent(context, intent)) {
return
}

// Handle video conf action
if (handleVideoConfIntent(context, intent)) {
return
}
Expand All @@ -30,6 +35,39 @@ class NotificationIntentHandler {
handleNotificationIntent(context, intent)
}

/**
* Handles VoIP call notification Intent.
* @return true if this was a VoIP intent, false otherwise
*/
@JvmStatic
private fun handleVoipIntent(context: Context, intent: Intent): Boolean {
if (!intent.getBooleanExtra("voipAction", false)) {
return false
}

val notificationId = intent.getIntExtra("notificationId", 0)
val event = intent.getStringExtra("event") ?: return true
val callId = intent.getStringExtra("callId") ?: ""
val callUUID = intent.getStringExtra("callUUID") ?: ""
val callerName = intent.getStringExtra("callerName") ?: ""
val host = intent.getStringExtra("host") ?: ""

Log.d(TAG, "Handling VoIP intent - event: $event, callId: $callId, callUUID: $callUUID")

VoipNotification.cancelById(context, notificationId)
VoipModule.storePendingVoipCall(context, callId, callUUID, callerName, host, event)

// Emit event to JS if app is running
if (event == "accept") {
VoipModule.emitCallAnswered(callUUID)
}

// Clear the voip flag to prevent re-processing
intent.removeExtra("voipAction")

return true
Comment on lines +43 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle malformed VoIP intents without leaving stale state.
If event is missing, the current early return skips cleanup. Also guard notificationId == 0 to avoid canceling an unintended ID.

🛠️ Suggested fix
-            val notificationId = intent.getIntExtra("notificationId", 0)
-            val event = intent.getStringExtra("event") ?: return true
+            val notificationId = intent.getIntExtra("notificationId", 0)
+            val event = intent.getStringExtra("event")
+            if (event.isNullOrEmpty()) {
+                if (notificationId != 0) {
+                    VoipNotification.cancelById(context, notificationId)
+                }
+                intent.removeExtra("voipAction")
+                return true
+            }
@@
-            VoipNotification.cancelById(context, notificationId)
+            if (notificationId != 0) {
+                VoipNotification.cancelById(context, notificationId)
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun handleVoipIntent(context: Context, intent: Intent): Boolean {
if (!intent.getBooleanExtra("voipAction", false)) {
return false
}
val notificationId = intent.getIntExtra("notificationId", 0)
val event = intent.getStringExtra("event") ?: return true
val callId = intent.getStringExtra("callId") ?: ""
val callUUID = intent.getStringExtra("callUUID") ?: ""
val callerName = intent.getStringExtra("callerName") ?: ""
val host = intent.getStringExtra("host") ?: ""
Log.d(TAG, "Handling VoIP intent - event: $event, callId: $callId, callUUID: $callUUID")
VoipNotification.cancelById(context, notificationId)
VoipModule.storePendingVoipCall(context, callId, callUUID, callerName, host, event)
// Emit event to JS if app is running
if (event == "accept") {
VoipModule.emitCallAnswered(callUUID)
}
// Clear the voip flag to prevent re-processing
intent.removeExtra("voipAction")
return true
private fun handleVoipIntent(context: Context, intent: Intent): Boolean {
if (!intent.getBooleanExtra("voipAction", false)) {
return false
}
val notificationId = intent.getIntExtra("notificationId", 0)
val event = intent.getStringExtra("event")
if (event.isNullOrEmpty()) {
if (notificationId != 0) {
VoipNotification.cancelById(context, notificationId)
}
intent.removeExtra("voipAction")
return true
}
val callId = intent.getStringExtra("callId") ?: ""
val callUUID = intent.getStringExtra("callUUID") ?: ""
val callerName = intent.getStringExtra("callerName") ?: ""
val host = intent.getStringExtra("host") ?: ""
Log.d(TAG, "Handling VoIP intent - event: $event, callId: $callId, callUUID: $callUUID")
if (notificationId != 0) {
VoipNotification.cancelById(context, notificationId)
}
VoipModule.storePendingVoipCall(context, callId, callUUID, callerName, host, event)
// Emit event to JS if app is running
if (event == "accept") {
VoipModule.emitCallAnswered(callUUID)
}
// Clear the voip flag to prevent re-processing
intent.removeExtra("voipAction")
return true
}
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt`
around lines 43 - 68, In handleVoipIntent, ensure malformed VoIP intents
(missing event) don't leave stale state: when event is null, still clear the
voip flag and cancel the notification only if notificationId != 0 (avoid
canceling ID 0), but skip calling
VoipModule.storePendingVoipCall/emitCallAnswered; always call
intent.removeExtra("voipAction") before returning and call
VoipNotification.cancelById(context, notificationId) only when notificationId !=
0; keep existing behavior for valid events (store pending call, cancel, emit on
"accept").

}

/**
* Handles video conference notification Intent.
* @return true if this was a video conf intent, false otherwise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import android.os.Bundle
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.google.gson.Gson

/**
* Custom Firebase Messaging Service for Rocket.Chat.
*
* Handles incoming FCM messages and routes them to CustomPushNotification
* for advanced processing (E2E decryption, MessagingStyle, direct reply, etc.)
* Handles incoming FCM messages and routes them to the appropriate handler:
* - VoipNotification for VoIP calls (notificationType: "voip")
* - CustomPushNotification for regular messages and video conferences
*/
class RCFirebaseMessagingService : FirebaseMessagingService() {

companion object {
private const val TAG = "RocketChat.FCM"
private val gson = Gson()
}

override fun onMessageReceived(remoteMessage: RemoteMessage) {
Expand All @@ -33,7 +36,20 @@ class RCFirebaseMessagingService : FirebaseMessagingService() {
}
}

// Process the notification
val voipPayload = parseVoipPayload(data)
if (voipPayload != null && voipPayload.isVoipIncomingCall()) {
Log.d(TAG, "Detected new VoIP payload format, routing to VoipNotification handler")
try {
val voipNotification = VoipNotification(this)
// TODO: no need for bundle, just use voipPayload
voipNotification.showIncomingCall(bundle, voipPayload)
} catch (e: Exception) {
Log.e(TAG, "Error processing VoIP notification", e)
}
return
}

// Process regular notifications via CustomPushNotification
try {
val notification = CustomPushNotification(this, bundle)
notification.onReceived()
Expand All @@ -42,6 +58,48 @@ class RCFirebaseMessagingService : FirebaseMessagingService() {
}
}

/**
* Parses the new VoIP payload format from FCM data map.
* Returns null if the payload doesn't match the new format.
*/
private fun parseVoipPayload(data: Map<String, String>): VoipPayload? {
val type = data["type"]
val hasEjson = data.containsKey("ejson") && !data["ejson"].isNullOrEmpty()

if (type != "incoming_call" || hasEjson) {
return null
}

return try {
VoipPayload(
callId = data["callId"],
calleeId = data["calleeId"],
caller = data["caller"],
host = data["host"],
type = data["type"]
)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse VoIP payload", e)
null
}
}
Comment on lines +65 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate required VoIP fields before constructing payload.
If any required keys are missing/blank, downstream handling may receive null/empty values and misbehave. Consider early-return guards.

🛠️ Proposed guardrails
-        val type = data["type"]
+        val type = data["type"] ?: return null
         val hasEjson = data.containsKey("ejson") && !data["ejson"].isNullOrEmpty()
 
         if (type != "incoming_call" || hasEjson) {
             return null
         }
 
+        val callId = data["callId"]?.takeIf { it.isNotBlank() } ?: return null
+        val calleeId = data["calleeId"]?.takeIf { it.isNotBlank() } ?: return null
+        val caller = data["caller"]?.takeIf { it.isNotBlank() } ?: return null
+        val host = data["host"]?.takeIf { it.isNotBlank() } ?: return null
+
         return try {
             VoipPayload(
-                callId = data["callId"],
-                calleeId = data["calleeId"],
-                caller = data["caller"],
-                host = data["host"],
-                type = data["type"]
+                callId = callId,
+                calleeId = calleeId,
+                caller = caller,
+                host = host,
+                type = type
             )
         } catch (e: Exception) {
             Log.e(TAG, "Failed to parse VoIP payload", e)
             null
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt`
around lines 65 - 85, parseVoipPayload currently constructs a VoipPayload even
when required fields may be missing; add early-return guards that validate
required keys (at least callId, calleeId, caller, host — type is already
checked) by verifying data contains each key and that none are null or blank
(use isNullOrEmpty) and return null if any are invalid before constructing
VoipPayload; update the parseVoipPayload function to perform these checks so
downstream code never receives a payload with empty critical fields.


/**
* Safely parses ejson string to Ejson object.
*/
private fun parseEjson(ejsonStr: String?): Ejson? {
if (ejsonStr.isNullOrEmpty() || ejsonStr == "{}") {
return null
}

return try {
gson.fromJson(ejsonStr, Ejson::class.java)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse ejson", e)
null
}
}

override fun onNewToken(token: String) {
Log.d(TAG, "FCM token refreshed")
// Token handling is done by expo-notifications JS layer
Expand Down
Loading