Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ class CallService : Service(), CallRepository.Listener {
when (intent.action) {
ACTION_INCOMING_CALL -> {
registerCall(intent, true)
headlessJSManager.ensureReactContext()
}
ACTION_OUTGOING_CALL -> {
registerCall(intent, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class CallingxModuleImpl(
const val CALL_END_ACTION = "io.getstream.CALL_END"
const val CALL_REGISTRATION_FAILED_ACTION = "io.getstream.CALL_REGISTRATION_FAILED"
const val CALL_OPTIMISTIC_ACCEPT_ACTION = "io.getstream.ACCEPT_CALL_OPTIMISTIC"
// Published directly to CallEventBus (not a broadcast), so no manifest intent-filter entry.
const val CALL_RING_PUSH_ACTION = "io.getstream.CALL_RING_PUSH"
// Background task name
const val HEADLESS_TASK_NAME = "HandleCallBackgroundState"
}
Expand Down Expand Up @@ -142,9 +144,8 @@ class CallingxModuleImpl(
// event and clear it immediately after getting initial events
val events = delayedEvents
debugLog(TAG, "[module] getInitialEvents: Getting initial events: $events")
delayedEvents = WritableNativeArray()
canSendEvents = true
CallEventBus.markJsReady()
delayedEvents = WritableNativeArray()
return events
}

Expand Down Expand Up @@ -363,11 +364,6 @@ class CallingxModuleImpl(
}
}

fun registerBackgroundTaskAvailable() {
debugLog(TAG, "[module] registerBackgroundTaskAvailable: Headless task registered")
}


fun fulfillAnswerCallAction(callId: String, didFail: Boolean) {
// no-op: Android Telecom doesn't require explicit action fulfillment
}
Expand Down Expand Up @@ -517,6 +513,12 @@ class CallingxModuleImpl(
CALL_AUDIO_ENDPOINTS_CHANGED_ACTION -> {
sendJSEvent("didChangeAudioRoute", params)
}
CALL_RING_PUSH_ACTION -> {
extras.keySet().forEach { key ->
params.putString(key, extras.getString(key))
}
sendJSEvent("ringCallPushReceived", params)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,43 @@ import com.facebook.react.jstasks.HeadlessJsTaskEventListener
class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventListener {

private var activeTaskId: Int? = null
private var isStarting: Boolean = false
private var pendingReactInstanceListener: ReactInstanceEventListener? = null
@Volatile
private var released: Boolean = false

companion object {
private const val TAG = "[Callingx] HeadlessTaskManager"
}

private fun hasReactContext(): Boolean = reactContext != null

// ensures the React context is running by booting it via the headless task if not already present
fun ensureReactContext() {
if (hasReactContext()) {
debugLog(TAG, "[headless] ensureReactContext: React context already running, skipping boot")
return
}
debugLog(
TAG,
"[headless] ensureReactContext: booting React context via keep-alive headless task"
)
startHeadlessTask(CallingxModuleImpl.HEADLESS_TASK_NAME, Bundle(), 0)
}

public fun startHeadlessTask(taskName: String, data: Bundle, timeout: Long) {
debugLog(TAG, "[headless] startHeadlessTask: Starting headless task: $taskName, $data, $timeout")
if (activeTaskId != null) {
Log.w(TAG, "[headless] startHeadlessTask: Task already starting or active, ignoring new task request")
debugLog(
TAG,
"[headless] startHeadlessTask entry: activeTaskId=$activeTaskId isStarting=$isStarting"
)
if (activeTaskId != null || isStarting) {
Log.w(
TAG,
"[headless] startHeadlessTask: Task already starting or active, ignoring new task request"
)
return
}
isStarting = true

if (UiThreadUtil.isOnUiThread()) {
startTask(HeadlessJsTaskConfig(taskName, Arguments.fromBundle(data), timeout, true))
Expand Down Expand Up @@ -64,13 +90,19 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis
}

private fun invokeStartTask(reactContext: ReactContext, taskConfig: HeadlessJsTaskConfig) {
if (released) {
debugLog(TAG, "[headless] invokeStartTask: released, skipping")
return
}
debugLog(TAG, "[headless] invokeStartTask: Invoking start task")
val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactContext)
headlessJsTaskContext.addTaskEventListener(this)

UiThreadUtil.runOnUiThread {
val taskId = headlessJsTaskContext.startTask(taskConfig)
activeTaskId = taskId
debugLog(TAG, "[headless] invokeStartTask: Task started: $taskId")
isStarting = false
}
}

Expand All @@ -79,27 +111,61 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis
val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(context)
if (headlessJsTaskContext.isTaskRunning(taskId)) {
headlessJsTaskContext.finishTask(taskId)
debugLog(TAG, "Stopped task: $taskId")
debugLog(TAG, "[headless] stopTask: Task finished $taskId")
}
}
}

fun release() {
released = true
stopHeadlessTask()
isStarting = false
// Proactively unregister the pending React-context init listener. Otherwise the callback
// would fire after CallService is destroyed, invokeStartTask a stale task on this dead
// manager, and register `this` as a task listener on the live ReactContext (leak).
pendingReactInstanceListener?.let { listener ->
removeReactInstanceEventListener(listener)
pendingReactInstanceListener = null
}
// Defer cleanup to the back of the main-thread queue so any finish callback already
// posted by finishTask() drains first — otherwise we'd unregister the listener before
// it fires and lose the finish log.
UiThreadUtil.runOnUiThread {
activeTaskId = null
reactContext?.let { context ->
val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(context)
headlessJsTaskContext.removeTaskEventListener(this)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

reactContext?.let { context ->
val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(context)
headlessJsTaskContext.removeTaskEventListener(this)
private fun removeReactInstanceEventListener(listener: ReactInstanceEventListener) {
try {
if (ReactNativeFeatureFlags.enableBridgelessArchitecture()) {
reactHost?.removeReactInstanceEventListener(listener)
} else {
reactNativeHost.reactInstanceManager.removeReactInstanceEventListener(listener)
}
} catch (t: Throwable) {
// Best-effort — RN may be in a torn-down state.
debugLog(TAG, "[headless] failed to remove react-instance listener: ${t.message}")
}
}

override fun onHeadlessJsTaskStart(taskId: Int) {
debugLog(TAG, "[headless] onHeadlessJsTaskStart: Task started: $taskId")
}

override fun onHeadlessJsTaskFinish(taskId: Int) {
debugLog(TAG, "[headless] onHeadlessJsTaskFinish: Task finished: $taskId")
if (taskId != activeTaskId) {
debugLog(
TAG,
"[headless] onHeadlessJsTaskFinish: IGNORED foreign taskId=$taskId (our=$activeTaskId)"
)
return
}
debugLog(TAG, "[headless] onHeadlessJsTaskFinish Task finished: $taskId state cleared: activeTaskId=null isStarting=false")
activeTaskId = null
isStarting = false
}

/**
Expand Down Expand Up @@ -135,30 +201,32 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis
}

private fun createReactContextAndScheduleTask(taskConfig: HeadlessJsTaskConfig) {
val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
if (released) {
debugLog(TAG, "[headless] onReactContextInitialized fired after release, ignoring")
removeReactInstanceEventListener(this)
pendingReactInstanceListener = null
return
}
debugLog(TAG, "createReactContextAndScheduleTask: React context initialized")
invokeStartTask(context, taskConfig)
removeReactInstanceEventListener(this)
pendingReactInstanceListener = null
}
}
pendingReactInstanceListener = listener

if (ReactNativeFeatureFlags.enableBridgelessArchitecture()) {
val reactHost = checkNotNull(reactHost)
reactHost.addReactInstanceEventListener(
object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
debugLog(TAG, "createReactContextAndScheduleTask: React context initialized")
invokeStartTask(context, taskConfig)
reactHost.removeReactInstanceEventListener(this)
}
}
)
reactHost.addReactInstanceEventListener(listener)
reactHost.start()
} else {
val reactInstanceManager = reactNativeHost.reactInstanceManager
reactInstanceManager.addReactInstanceEventListener(
object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
debugLog(TAG, "createReactContextAndScheduleTask: React context initialized")
invokeStartTask(context, taskConfig)
reactInstanceManager.removeReactInstanceEventListener(this)
}
}
)
reactInstanceManager.createReactContextInBackground()
reactInstanceManager.addReactInstanceEventListener(listener)
if (!reactInstanceManager.hasStartedCreatingInitialContext()) {
reactInstanceManager.createReactContextInBackground()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.getstream.rn.callingx

import android.content.Context
import android.os.Bundle
import com.google.firebase.messaging.RemoteMessage
import io.getstream.rn.callingx.utils.LifecycleListener
import io.getstream.rn.callingx.utils.SettingsStore
Expand Down Expand Up @@ -79,6 +80,9 @@ object StreamMessagingHelper {
return
}

val extras = Bundle().apply { data.forEach { (key, value) -> putString(key, value) } }
CallEventBus.publish(CallEvent(CallingxModuleImpl.CALL_RING_PUSH_ACTION, extras))
Comment thread
greenfrvr marked this conversation as resolved.

if (
SettingsStore.shouldSkipIncomingPushInForeground(context) &&
LifecycleListener.isInForeground
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package io.getstream.rn.callingx

import android.os.Bundle

data class CallEvent(val action: String, val extras: Bundle)
public data class CallEvent(val action: String, val extras: Bundle)

object CallEventBus {
public object CallEventBus {

interface Listener {
fun onCallEvent(event: CallEvent)
Expand Down Expand Up @@ -44,18 +44,13 @@ object CallEventBus {
@JvmStatic
@Synchronized
fun drainPendingEvents(): List<CallEvent> {
isJsReady = true
if (pendingEvents.isEmpty()) {
return emptyList()
}
val copy = pendingEvents.toList()
pendingEvents.clear()
return copy
}

@JvmStatic
@Synchronized
fun markJsReady() {
isJsReady = true
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,6 @@ class CallingxModule(reactContext: ReactApplicationContext) :
impl.stopBackgroundTask(taskName, promise)
}

override fun registerBackgroundTaskAvailable() {
impl.registerBackgroundTaskAvailable()
}


override fun fulfillAnswerCallAction(callId: String, didFail: Boolean) {
impl.fulfillAnswerCallAction(callId, didFail)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,6 @@ class CallingxModule(private val reactContext: ReactApplicationContext) :
impl.stopBackgroundTask(taskName, promise)
}

@ReactMethod
fun registerBackgroundTaskAvailable() {
impl.registerBackgroundTaskAvailable()
}

@ReactMethod
fun fulfillAnswerCallAction(callId: String, didFail: Boolean) {
impl.fulfillAnswerCallAction(callId, didFail)
Expand Down
12 changes: 0 additions & 12 deletions packages/react-native-callingx/ios/Callingx.mm
Original file line number Diff line number Diff line change
Expand Up @@ -690,18 +690,6 @@ - (void)stopBackgroundTask:(NSString *)taskName
}
#endif

#pragma mark - registerBackgroundTaskAvailable

#ifdef RCT_NEW_ARCH_ENABLED
- (void)registerBackgroundTaskAvailable {
// Not implemented on iOS - background tasks work differently on iOS
}
#else
RCT_EXPORT_METHOD(registerBackgroundTaskAvailable) {
// Not implemented on iOS - background tasks work differently on iOS
}
#endif

#pragma mark - canPostNotifications

#ifdef RCT_NEW_ARCH_ENABLED
Expand Down
Loading