Skip to content

Commit 6fe17e4

Browse files
santhoshvaigreenfrvroliverlazjdimovska
authored
feat: implement our own keep call alive foreground service instead of notifee (#2082)
### 💡 Overview Previously we used Notifee for the foreground service to keep the call alive when app goes to background. Now this foreground service is added to our SDK. <img width="640" height="400" alt="image" src="https://github.com/user-attachments/assets/83750dd9-0602-47d5-835e-952d6d64277d" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an SDK-managed keep-alive service for Android calls, enabling better background call persistence with system notifications. * Added support for Android 13+ notification permissions, ensuring proper permission requests before displaying call notifications. * **Improvements** * Enhanced Android notification channel configuration for better call state management. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Artsiom Grintsevich <[email protected]> Co-authored-by: GitHub Actions Bot <> Co-authored-by: Oliver Lazoroski <[email protected]> Co-authored-by: jdimovska <[email protected]>
1 parent af9580f commit 6fe17e4

File tree

13 files changed

+434
-262
lines changed

13 files changed

+434
-262
lines changed

packages/react-native-sdk/android/src/main/AndroidManifest.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
package="com.streamvideo.reactnative">
33

44
<uses-permission android:name="android.permission.INTERNET" />
5-
<uses-permission android:name="android.permission.DEVICE_POWER" />
65
<uses-permission android:name="android.permission.WAKE_LOCK" />
6+
7+
<application>
8+
<service
9+
android:name="com.streamvideo.reactnative.keepalive.StreamCallKeepAliveHeadlessService"
10+
android:exported="false"
11+
android:stopWithTask="true"
12+
android:foregroundServiceType="mediaPlayback|camera|microphone" />
13+
</application>
714
</manifest>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
3+
<uses-permission android:name="android.permission.INTERNET" />
4+
<uses-permission android:name="android.permission.WAKE_LOCK" />
5+
6+
<application>
7+
<service
8+
android:name="com.streamvideo.reactnative.keepalive.StreamCallKeepAliveHeadlessService"
9+
android:exported="false"
10+
android:stopWithTask="true"
11+
android:foregroundServiceType="mediaPlayback|camera|microphone" />
12+
</application>
213
</manifest>

packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import android.os.Build
1515
import android.os.PowerManager
1616
import android.util.Base64
1717
import android.util.Log
18+
import androidx.core.content.ContextCompat
1819
import com.facebook.react.bridge.Arguments
1920
import com.facebook.react.bridge.Promise
2021
import com.facebook.react.bridge.ReactApplicationContext
@@ -23,8 +24,8 @@ import com.facebook.react.bridge.ReactMethod
2324
import com.facebook.react.bridge.WritableMap
2425
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
2526
import com.oney.WebRTCModule.WebRTCModule
27+
import com.streamvideo.reactnative.keepalive.StreamCallKeepAliveHeadlessService
2628
import com.streamvideo.reactnative.util.CallAlivePermissionsHelper
27-
import com.streamvideo.reactnative.util.CallAliveServiceChecker
2829
import com.streamvideo.reactnative.util.PiPHelper
2930
import com.streamvideo.reactnative.util.RingtoneUtil
3031
import com.streamvideo.reactnative.util.YuvFrame
@@ -115,11 +116,47 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) :
115116
promise.resolve(false)
116117
return
117118
}
118-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
119-
val isForegroundServiceDeclared = CallAliveServiceChecker.isForegroundServiceDeclared(reactApplicationContext)
120-
promise.resolve(isForegroundServiceDeclared)
121-
} else {
119+
// Service is declared in the SDK's own AndroidManifest and merged by default.
120+
// Permissions are expected to be provided by the app (or via Expo config plugin).
121+
promise.resolve(true)
122+
}
123+
124+
125+
@ReactMethod
126+
fun startKeepCallAliveService(
127+
callCid: String,
128+
channelId: String,
129+
channelName: String,
130+
title: String,
131+
body: String,
132+
smallIconName: String?,
133+
promise: Promise
134+
) {
135+
try {
136+
val intent = StreamCallKeepAliveHeadlessService.buildStartIntent(
137+
reactApplicationContext,
138+
callCid,
139+
channelId,
140+
channelName,
141+
title,
142+
body,
143+
smallIconName
144+
)
145+
ContextCompat.startForegroundService(reactApplicationContext, intent)
122146
promise.resolve(true)
147+
} catch (e: Exception) {
148+
promise.reject(NAME, "Failed to start keep call alive foreground service", e)
149+
}
150+
}
151+
152+
@ReactMethod
153+
fun stopKeepCallAliveService(promise: Promise) {
154+
try {
155+
val intent = StreamCallKeepAliveHeadlessService.buildStopIntent(reactApplicationContext)
156+
val stopped = reactApplicationContext.stopService(intent)
157+
promise.resolve(stopped)
158+
} catch (e: Exception) {
159+
promise.reject(NAME, "Failed to stop keep call alive foreground service", e)
123160
}
124161
}
125162

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.streamvideo.reactnative.keepalive
2+
3+
import android.app.Notification
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.PendingIntent
7+
import android.content.Context
8+
import android.content.Intent
9+
import android.content.pm.PackageManager
10+
import android.os.Build
11+
import androidx.core.app.NotificationCompat
12+
13+
internal object KeepAliveNotification {
14+
private const val DEFAULT_CHANNEL_DESCRIPTION = "Stream call keep-alive"
15+
16+
fun ensureChannel(
17+
context: Context,
18+
channelId: String,
19+
channelName: String
20+
) {
21+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
22+
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
23+
val existing = manager.getNotificationChannel(channelId)
24+
if (existing != null) return
25+
26+
val channel = NotificationChannel(
27+
channelId,
28+
channelName,
29+
NotificationManager.IMPORTANCE_LOW
30+
).apply {
31+
description = DEFAULT_CHANNEL_DESCRIPTION
32+
setShowBadge(false)
33+
}
34+
manager.createNotificationChannel(channel)
35+
}
36+
37+
fun buildOngoingNotification(
38+
context: Context,
39+
channelId: String,
40+
title: String,
41+
body: String,
42+
smallIconName: String?
43+
): Notification {
44+
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
45+
val pendingIntentFlags =
46+
PendingIntent.FLAG_UPDATE_CURRENT or
47+
PendingIntent.FLAG_IMMUTABLE
48+
val contentIntent = if (launchIntent != null) {
49+
PendingIntent.getActivity(context, 0, launchIntent, pendingIntentFlags)
50+
} else {
51+
// Fallback: empty intent to avoid crash if launch activity is missing for some reason
52+
PendingIntent.getActivity(context, 0, Intent(), pendingIntentFlags)
53+
}
54+
55+
val iconResId = resolveSmallIconResId(context, smallIconName)
56+
return NotificationCompat.Builder(context, channelId)
57+
.setContentTitle(title)
58+
.setContentText(body)
59+
.setOngoing(true)
60+
.setOnlyAlertOnce(true)
61+
.setCategory(NotificationCompat.CATEGORY_CALL)
62+
.setContentIntent(contentIntent)
63+
.setSmallIcon(iconResId)
64+
.build()
65+
}
66+
67+
private fun resolveSmallIconResId(context: Context, smallIconName: String?): Int {
68+
val resources = context.resources
69+
val packageName = context.packageName
70+
if (!smallIconName.isNullOrBlank()) {
71+
val id = resources.getIdentifier(smallIconName, "drawable", packageName)
72+
if (id != 0) return id
73+
}
74+
// Default to the app icon
75+
return try {
76+
val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
77+
appInfo.icon
78+
} catch (_: PackageManager.NameNotFoundException) {
79+
android.R.drawable.ic_dialog_info
80+
}
81+
}
82+
}
83+
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.streamvideo.reactnative.keepalive
2+
3+
import android.Manifest
4+
import android.content.Intent
5+
import android.content.pm.PackageManager
6+
import android.content.pm.ServiceInfo
7+
import android.os.Build
8+
import androidx.annotation.RequiresApi
9+
import androidx.core.app.ServiceCompat
10+
import androidx.core.content.ContextCompat
11+
import com.facebook.react.HeadlessJsTaskService
12+
import com.facebook.react.bridge.Arguments
13+
import com.facebook.react.jstasks.HeadlessJsTaskConfig
14+
15+
/**
16+
* Foreground service that runs a React Native HeadlessJS task to keep a call alive.
17+
*
18+
*/
19+
class StreamCallKeepAliveHeadlessService : HeadlessJsTaskService() {
20+
21+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
22+
val safeIntent = intent ?: Intent()
23+
val channelId = safeIntent.getStringExtra(EXTRA_CHANNEL_ID) ?: DEFAULT_CHANNEL_ID
24+
val channelName = safeIntent.getStringExtra(EXTRA_CHANNEL_NAME) ?: DEFAULT_CHANNEL_NAME
25+
val title = safeIntent.getStringExtra(EXTRA_TITLE) ?: DEFAULT_TITLE
26+
val body = safeIntent.getStringExtra(EXTRA_BODY) ?: DEFAULT_BODY
27+
val smallIconName = safeIntent.getStringExtra(EXTRA_SMALL_ICON_NAME)
28+
29+
KeepAliveNotification.ensureChannel(this, channelId, channelName)
30+
val notification = KeepAliveNotification.buildOngoingNotification(
31+
context = this,
32+
channelId = channelId,
33+
title = title,
34+
body = body,
35+
smallIconName = smallIconName
36+
)
37+
38+
startForegroundCompat(notification)
39+
40+
// Ensure HeadlessJS task is started
41+
return super.onStartCommand(safeIntent, flags, startId)
42+
}
43+
44+
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
45+
val callCid = intent?.getStringExtra(EXTRA_CALL_CID) ?: return null
46+
val data = Arguments.createMap().apply {
47+
putString("callCid", callCid)
48+
}
49+
// We intentionally allow long-running work (the JS task can return a never-resolving Promise).
50+
return HeadlessJsTaskConfig(
51+
TASK_NAME,
52+
data,
53+
0, // timeout (0 = no timeout)
54+
true // allowedInForeground
55+
)
56+
}
57+
58+
override fun onDestroy() {
59+
super.onDestroy()
60+
stopForeground(STOP_FOREGROUND_REMOVE)
61+
}
62+
63+
@RequiresApi(Build.VERSION_CODES.R)
64+
private fun computeForegroundServiceTypes(): Int {
65+
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
66+
67+
val hasCameraPermission =
68+
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
69+
if (hasCameraPermission) {
70+
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
71+
}
72+
73+
val hasMicrophonePermission =
74+
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
75+
if (hasMicrophonePermission) {
76+
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
77+
}
78+
79+
return types
80+
}
81+
82+
private fun startForegroundCompat(notification: android.app.Notification) {
83+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
84+
val types = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) computeForegroundServiceTypes() else ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
85+
startForeground(NOTIFICATION_ID, notification, types)
86+
} else {
87+
startForeground(NOTIFICATION_ID, notification)
88+
}
89+
}
90+
91+
companion object {
92+
const val TASK_NAME = "StreamVideoKeepCallAlive"
93+
94+
const val EXTRA_CALL_CID = "callCid"
95+
const val EXTRA_CHANNEL_ID = "channelId"
96+
const val EXTRA_CHANNEL_NAME = "channelName"
97+
const val EXTRA_TITLE = "title"
98+
const val EXTRA_BODY = "body"
99+
const val EXTRA_SMALL_ICON_NAME = "smallIconName"
100+
101+
private const val NOTIFICATION_ID = 6061
102+
103+
private const val DEFAULT_CHANNEL_ID = "stream_call_foreground_service"
104+
private const val DEFAULT_CHANNEL_NAME = "Call in progress"
105+
private const val DEFAULT_TITLE = "Call in progress"
106+
private const val DEFAULT_BODY = "Tap to return to the call"
107+
108+
fun buildStartIntent(
109+
context: android.content.Context,
110+
callCid: String,
111+
channelId: String,
112+
channelName: String,
113+
title: String,
114+
body: String,
115+
smallIconName: String?
116+
): Intent {
117+
return Intent(context, StreamCallKeepAliveHeadlessService::class.java).apply {
118+
putExtra(EXTRA_CALL_CID, callCid)
119+
putExtra(EXTRA_CHANNEL_ID, channelId)
120+
putExtra(EXTRA_CHANNEL_NAME, channelName)
121+
putExtra(EXTRA_TITLE, title)
122+
putExtra(EXTRA_BODY, body)
123+
if (!smallIconName.isNullOrBlank()) {
124+
putExtra(EXTRA_SMALL_ICON_NAME, smallIconName)
125+
}
126+
}
127+
}
128+
129+
fun buildStopIntent(context: android.content.Context): Intent {
130+
return Intent(context, StreamCallKeepAliveHeadlessService::class.java)
131+
}
132+
}
133+
}
134+

0 commit comments

Comments
 (0)