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
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@

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

<!-- VoIP foreground service for keeping audio calls alive in the background. -->
<service
android:name="chat.rocket.reactnative.voip.VoipCallService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone" />

<!-- react-native-webrtc ships MediaProjectionService (foregroundServiceType=mediaProjection)
for screen sharing. We don't use screen sharing, and Android 15+ forbids starting
restricted foreground service types from BOOT_COMPLETED receivers (expo-notifications
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package chat.rocket.reactnative.voip

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import chat.rocket.reactnative.MainActivity

/**
* Foreground service that keeps the VoIP call alive when the app moves to the background.
* Required because Android terminates background processes without a foreground service,
* which would drop the active audio session.
*
* Started on call accept, stopped on hangup.
*/
class VoipCallService : Service() {

companion object {
private const val TAG = "RocketChat.VoipCallService"
private const val CHANNEL_ID = "voip-call-service"
private const val CHANNEL_NAME = "VoIP Call"
private const val NOTIFICATION_ID = 1

private const val ACTION_START = "chat.rocket.reactnative.voip.START_SERVICE"
private const val ACTION_STOP = "chat.rocket.reactnative.voip.STOP_SERVICE"
const val EXTRA_CALL_ID = "callId"

private var isRunning = false

@JvmStatic
fun startService(context: android.content.Context, callId: String) {
val intent = Intent(context, VoipCallService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_CALL_ID, callId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}

@JvmStatic
fun stopService(context: android.content.Context) {
val intent = Intent(context, VoipCallService::class.java).apply {
action = ACTION_STOP
}
context.stopService(intent)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

override fun onCreate() {
super.onCreate()
createNotificationChannel()
Log.d(TAG, "VoipCallService created")
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
Log.d(TAG, "Stopping VoipCallService")
stopSelf(startId)
return START_NOT_STICKY
}
ACTION_START -> {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "unknown"
Log.d(TAG, "Starting VoipCallService for callId: $callId")
Comment thread
diegolmello marked this conversation as resolved.
if (!isRunning) {
isRunning = true
startForegroundWithNotification(callId)
} else {
Log.d(TAG, "Service already running, skipping duplicate start")
}
return START_NOT_STICKY
}
else -> {
Log.w(TAG, "Unknown action: ${intent?.action}")
stopSelf(startId)
return START_NOT_STICKY
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

private fun startForegroundWithNotification(callId: String) {
val notification = buildNotification(callId)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
)
} else {
startForeground(NOTIFICATION_ID, notification)
}

Log.d(TAG, "Started foreground with notification for callId: $callId")
}

private fun buildNotification(callId: String): Notification {
// Pending intent: tapping the notification opens the app.
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
)

return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("VoIP Call")
.setContentText("Call in progress")
.setSmallIcon(getApplicationInfo().icon)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "VoIP call in progress"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager?.createNotificationChannel(channel)
}
}

override fun onBind(intent: Intent?): IBinder? = null

override fun onDestroy() {
isRunning = false
Log.d(TAG, "VoipCallService destroyed")
super.onDestroy()
}
}
Loading