diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DirectCallActivity.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DirectCallActivity.kt index 0d3da53a37..29a3b8e5da 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DirectCallActivity.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DirectCallActivity.kt @@ -32,6 +32,7 @@ import io.getstream.result.Result import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.ui.components.call.activecall.CallContent import io.getstream.video.android.compose.ui.components.call.ringing.RingingCallContent +import io.getstream.video.android.core.Call import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.call.state.AcceptCall import io.getstream.video.android.core.call.state.CallAction @@ -45,7 +46,10 @@ import io.getstream.video.android.datastore.delegate.StreamUserDataStore import io.getstream.video.android.model.mapper.isValidCallId import io.getstream.video.android.model.mapper.toTypeAndId import io.getstream.video.android.util.StreamVideoInitHelper +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openapitools.client.models.CallRejectedEvent import java.util.UUID import javax.inject.Inject @@ -54,6 +58,7 @@ class DirectCallActivity : ComponentActivity() { @Inject lateinit var dataStore: StreamUserDataStore + private lateinit var call: Call override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -73,7 +78,7 @@ class DirectCallActivity : ComponentActivity() { } // Create call object - val call = StreamVideo.instance().call(type, id) + call = StreamVideo.instance().call(type, id) // Get list of members val members: List = intent.getStringArrayExtra(EXTRA_MEMBERS_ARRAY)?.asList() ?: emptyList() @@ -84,6 +89,18 @@ class DirectCallActivity : ComponentActivity() { // Ring the members val result = call.create(ring = true, memberIds = membersWithMe) + // Update the call + call.get() + + call.subscribe { + when (it) { + // Finish this activity if ever a call.reject is received + is CallRejectedEvent -> { + finish() + } + } + } + if (result is Result.Failure) { // Failed to recover the current state of the call // TODO: Automaticly call this in the SDK? @@ -101,20 +118,19 @@ class DirectCallActivity : ComponentActivity() { val onCallAction: (CallAction) -> Unit = { callAction -> when (callAction) { is ToggleCamera -> call.camera.setEnabled(callAction.isEnabled) - is ToggleMicrophone -> call.microphone.setEnabled(callAction.isEnabled) + is ToggleMicrophone -> call.microphone.setEnabled( + callAction.isEnabled, + ) is ToggleSpeakerphone -> call.speaker.setEnabled(callAction.isEnabled) is LeaveCall -> { call.leave() finish() } is DeclineCall -> { - // Not needed. this activity is only used for outgoing calls. + reject(call) } is CancelCall -> { - lifecycleScope.launch { - call.leave() - finish() - } + reject(call) } is AcceptCall -> { lifecycleScope.launch { @@ -131,8 +147,7 @@ class DirectCallActivity : ComponentActivity() { modifier = Modifier.background(color = VideoTheme.colors.appBackground), call = call, onBackPressed = { - call.leave() - finish() + reject(call) }, onAcceptedContent = { CallContent( @@ -142,8 +157,7 @@ class DirectCallActivity : ComponentActivity() { ) }, onRejectedContent = { - call.leave() - finish() + reject(call) }, onCallAction = onCallAction, ) @@ -152,6 +166,22 @@ class DirectCallActivity : ComponentActivity() { } } + override fun onStop() { + super.onStop() + if (::call.isInitialized) { + reject(call) + } + } + + private fun reject(call: Call) { + lifecycleScope.launch(Dispatchers.IO) { + call.reject() + withContext(Dispatchers.Main) { + finish() + } + } + } + companion object { const val EXTRA_CID: String = "EXTRA_CID" const val EXTRA_MEMBERS_ARRAY: String = "EXTRA_MEMBERS_ARRAY" diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt b/demo-app/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt index 29cde224a0..f939d3abd2 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt @@ -25,6 +25,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint @@ -135,8 +136,10 @@ class IncomingCallActivity : ComponentActivity() { ) }, onRejectedContent = { - call.leave() - finish() + LaunchedEffect(key1 = call) { + call.reject() + finish() + } }, onCallAction = onCallAction, ) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 37ba5cf949..c7187b6558 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -734,6 +734,7 @@ public abstract interface class io/getstream/video/android/core/StreamVideo : io public static synthetic fun call$default (Lio/getstream/video/android/core/StreamVideo;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/video/android/core/Call; public abstract fun cleanup ()V public abstract fun connectAsync (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun connectIfNotAlreadyConnected (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun createDevice (Lio/getstream/android/push/PushDevice;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun deleteDevice (Lio/getstream/video/android/model/Device;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getContext ()Landroid/content/Context; @@ -4036,6 +4037,7 @@ public class io/getstream/video/android/core/notifications/DefaultNotificationHa public fun getChannelId ()Ljava/lang/String; public fun getChannelName ()Ljava/lang/String; public fun getOngoingCallNotification (Lio/getstream/video/android/model/StreamCallId;)Landroid/app/Notification; + public fun getRingingCallNotification (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)Landroid/app/Notification; public fun onLiveCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public fun onNotification (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public fun onPermissionDenied ()V @@ -4076,8 +4078,10 @@ public abstract interface class io/getstream/video/android/core/notifications/No public static final field Companion Lio/getstream/video/android/core/notifications/NotificationHandler$Companion; public static final field INCOMING_CALL_NOTIFICATION_ID I public static final field INTENT_EXTRA_CALL_CID Ljava/lang/String; + public static final field INTENT_EXTRA_CALL_DISPLAY_NAME Ljava/lang/String; public static final field INTENT_EXTRA_NOTIFICATION_ID Ljava/lang/String; public abstract fun getOngoingCallNotification (Lio/getstream/video/android/model/StreamCallId;)Landroid/app/Notification; + public abstract fun getRingingCallNotification (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)Landroid/app/Notification; public abstract fun onLiveCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public abstract fun onNotification (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public abstract fun onRingingCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V @@ -4093,6 +4097,7 @@ public final class io/getstream/video/android/core/notifications/NotificationHan public static final field ACTION_REJECT_CALL Ljava/lang/String; public static final field INCOMING_CALL_NOTIFICATION_ID I public static final field INTENT_EXTRA_CALL_CID Ljava/lang/String; + public static final field INTENT_EXTRA_CALL_DISPLAY_NAME Ljava/lang/String; public static final field INTENT_EXTRA_NOTIFICATION_ID Ljava/lang/String; } @@ -5786,6 +5791,7 @@ public final class io/getstream/video/android/model/StreamCallId$Creator : andro } public final class io/getstream/video/android/model/StreamCallIdKt { + public static final fun streamCallDisplayName (Landroid/content/Intent;Ljava/lang/String;)Ljava/lang/String; public static final fun streamCallId (Landroid/content/Intent;Ljava/lang/String;)Lio/getstream/video/android/model/StreamCallId; } diff --git a/stream-video-android-core/src/main/AndroidManifest.xml b/stream-video-android-core/src/main/AndroidManifest.xml index 7fc99333d4..ae6934ca29 100644 --- a/stream-video-android-core/src/main/AndroidManifest.xml +++ b/stream-video-android-core/src/main/AndroidManifest.xml @@ -76,8 +76,8 @@ - + diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index b863b77df5..516ee8255e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -17,10 +17,8 @@ package io.getstream.video.android.core import android.content.Context -import android.content.Intent import androidx.core.content.ContextCompat -import io.getstream.video.android.core.notifications.NotificationHandler -import io.getstream.video.android.core.notifications.internal.service.OngoingCallService +import io.getstream.video.android.core.notifications.internal.service.CallService import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User import kotlinx.coroutines.flow.MutableStateFlow @@ -125,10 +123,10 @@ class ClientState(client: StreamVideo) { private fun maybeStartForegroundService(call: Call) { if (clientImpl.runForeGroundService) { val context = clientImpl.context - val serviceIntent = Intent(context, OngoingCallService::class.java) - serviceIntent.putExtra( - NotificationHandler.INTENT_EXTRA_CALL_CID, + val serviceIntent = CallService.buildStartIntent( + context, StreamCallId.fromCallCid(call.cid), + CallService.TRIGGER_ONGOING_CALL, ) ContextCompat.startForegroundService(context, serviceIntent) } @@ -137,7 +135,7 @@ class ClientState(client: StreamVideo) { private fun maybeStopForegroundService() { if (clientImpl.runForeGroundService) { val context = clientImpl.context - val serviceIntent = Intent(context, OngoingCallService::class.java) + val serviceIntent = CallService.buildStopIntent(context) context.stopService(serviceIntent) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt index a2bd07bfaa..7aadc31156 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt @@ -225,6 +225,7 @@ public interface StreamVideo : NotificationHandler { } public fun cleanup() + suspend fun connectIfNotAlreadyConnected() } private const val DEFAULT_QUERY_CALLS_SORT = "cid" diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt index d7c9400418..1cc8c52bdb 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt @@ -297,6 +297,14 @@ internal class StreamVideoImpl internal constructor( return sub } + override suspend fun connectIfNotAlreadyConnected() { + if (connectionModule.coordinatorSocket.connectionState.value != SocketState.NotConnected && + connectionModule.coordinatorSocket.connectionState.value != SocketState.Connecting + ) { + connectionModule.coordinatorSocket.connect() + } + } + /** * Observes the app lifecycle and attempts to reconnect/release the socket connection. */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt index 8acb208b7b..7905a2f184 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt @@ -29,17 +29,17 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.CallStyle import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person +import androidx.core.content.ContextCompat import io.getstream.android.push.permissions.DefaultNotificationPermissionHandler import io.getstream.android.push.permissions.NotificationPermissionHandler import io.getstream.log.TaggedLogger import io.getstream.log.taggedLogger import io.getstream.video.android.core.R -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_ACCEPT_CALL -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_INCOMING_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_LIVE_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_NOTIFICATION import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INCOMING_CALL_NOTIFICATION_ID import io.getstream.video.android.core.notifications.internal.DefaultStreamIntentResolver +import io.getstream.video.android.core.notifications.internal.service.CallService import io.getstream.video.android.model.StreamCallId public open class DefaultNotificationHandler( @@ -70,18 +70,63 @@ public open class DefaultNotificationHandler( } override fun onRingingCall(callId: StreamCallId, callDisplayName: String) { - intentResolver.searchIncomingCallPendingIntent(callId)?.let { fullScreenPendingIntent -> - intentResolver.searchAcceptCallPendingIntent(callId)?.let { acceptCallPendingIntent -> - intentResolver.searchRejectCallPendingIntent(callId)?.let { rejectCallPendingIntent -> - showIncomingCallNotification( - fullScreenPendingIntent, - acceptCallPendingIntent, - rejectCallPendingIntent, - callDisplayName, - ) - } - } ?: logger.e { "Couldn't find any activity for $ACTION_ACCEPT_CALL" } - } ?: logger.e { "Couldn't find any activity for $ACTION_INCOMING_CALL" } + val serviceIntent = CallService.buildStartIntent( + this.application, + callId, + CallService.TRIGGER_INCOMING_CALL, + callDisplayName, + ) + ContextCompat.startForegroundService(application.applicationContext, serviceIntent) + } + + override fun getRingingCallNotification(callId: StreamCallId, callDisplayName: String): Notification? { + val fullScreenPendingIntent = intentResolver.searchIncomingCallPendingIntent(callId) + val acceptCallPendingIntent = intentResolver.searchAcceptCallPendingIntent(callId) + val rejectCallPendingIntent = intentResolver.searchRejectCallPendingIntent(callId) + return if (fullScreenPendingIntent != null && acceptCallPendingIntent != null && rejectCallPendingIntent != null) { + getIncomingCallNotification( + fullScreenPendingIntent, + acceptCallPendingIntent, + rejectCallPendingIntent, + callDisplayName, + ) + } else { + logger.e { "Ringing call notification not shown, one of the intents is null." } + null + } + } + + private fun getIncomingCallNotification( + fullScreenPendingIntent: PendingIntent, + acceptCallPendingIntent: PendingIntent, + rejectCallPendingIntent: PendingIntent, + callDisplayName: String, + ): Notification { + val channelId = application.getString( + R.string.stream_video_incoming_call_notification_channel_id, + ) + maybeCreateChannel(channelId, application) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + description = application.getString(R.string.stream_video_incoming_call_notification_channel_description) + importance = NotificationManager.IMPORTANCE_HIGH + this.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + this.setShowBadge(true) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.setAllowBubbles(true) + } + } + return getNotification { + priority = NotificationCompat.PRIORITY_HIGH + setContentTitle("Incoming call") + setContentText(callDisplayName) + setChannelId(channelId) + setOngoing(false) + setContentIntent(fullScreenPendingIntent) + setFullScreenIntent(fullScreenPendingIntent, true) + setCategory(NotificationCompat.CATEGORY_CALL) + addCallActions(acceptCallPendingIntent, rejectCallPendingIntent, callDisplayName) + } } override fun onNotification(callId: StreamCallId, callDisplayName: String) { @@ -121,7 +166,12 @@ public open class DefaultNotificationHandler( val ongoingCallsChannelId = application.getString( R.string.stream_video_ongoing_call_notification_channel_id, ) - maybeCreateChannel(ongoingCallsChannelId, application) + maybeCreateChannel(ongoingCallsChannelId, application) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + description = + application.getString(R.string.stream_video_incoming_call_notification_channel_description) + } + } // Build notification return NotificationCompat.Builder(application, ongoingCallsChannelId) @@ -152,7 +202,12 @@ public open class DefaultNotificationHandler( .build() } - private fun maybeCreateChannel(channelId: String, context: Context) { + private fun maybeCreateChannel( + channelId: String, + context: Context, + configure: NotificationChannel.() -> Unit = { + }, + ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( channelId, @@ -160,9 +215,7 @@ public open class DefaultNotificationHandler( R.string.stream_video_ongoing_call_notification_channel_title, ), NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = application.getString(R.string.stream_video_ongoing_call_notification_channel_description) - } + ).apply(configure) val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -218,13 +271,20 @@ public open class DefaultNotificationHandler( notificationId: Int, builder: NotificationCompat.Builder.() -> Unit, ) { - val notification = NotificationCompat.Builder(application, getChannelId()) + val notification = getNotification(builder) + notificationManager.notify(notificationId, notification) + } + + private fun getNotification( + builder: NotificationCompat.Builder.() -> Unit, + ): Notification { + return NotificationCompat.Builder(application, getChannelId()) .setSmallIcon(android.R.drawable.presence_video_online) .setAutoCancel(true) .apply(builder) .build() - notificationManager.notify(notificationId, notification) } + private fun NotificationCompat.Builder.addCallActions( acceptCallPendingIntent: PendingIntent, rejectCallPendingIntent: PendingIntent, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt index d8a16935b4..cc7c00439a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt @@ -25,6 +25,7 @@ public interface NotificationHandler : NotificationPermissionHandler { fun onNotification(callId: StreamCallId, callDisplayName: String) fun onLiveCall(callId: StreamCallId, callDisplayName: String) fun getOngoingCallNotification(callId: StreamCallId): Notification? + fun getRingingCallNotification(callId: StreamCallId, callDisplayName: String): Notification? companion object { const val ACTION_NOTIFICATION = "io.getstream.video.android.action.NOTIFICATION" @@ -35,6 +36,8 @@ public interface NotificationHandler : NotificationPermissionHandler { const val ACTION_LEAVE_CALL = "io.getstream.video.android.action.LEAVE_CALL" const val ACTION_ONGOING_CALL = "io.getstream.video.android.action.ONGOING_CALL" const val INTENT_EXTRA_CALL_CID: String = "io.getstream.video.android.intent-extra.call_cid" + const val INTENT_EXTRA_CALL_DISPLAY_NAME: String = "io.getstream.video.android.intent-extra.call_displayname" + const val INTENT_EXTRA_NOTIFICATION_ID: String = "io.getstream.video.android.intent-extra.notification_id" const val INCOMING_CALL_NOTIFICATION_ID = 24756 diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt index 1ad0860482..c8a5e6da77 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt @@ -25,6 +25,11 @@ internal object NoOpNotificationHandler : NotificationHandler { override fun onNotification(callId: StreamCallId, callDisplayName: String) { /* NoOp */ } override fun onLiveCall(callId: StreamCallId, callDisplayName: String) { /* NoOp */ } override fun getOngoingCallNotification(callId: StreamCallId): Notification? = null + override fun getRingingCallNotification( + callId: StreamCallId, + callDisplayName: String, + ): Notification? = null + override fun onPermissionDenied() { /* NoOp */ } override fun onPermissionGranted() { /* NoOp */ } override fun onPermissionRationale() { /* NoOp */ } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiver.kt index 8e36c25e67..9662a54ad3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiver.kt @@ -22,8 +22,9 @@ import androidx.core.app.NotificationManagerCompat import io.getstream.log.taggedLogger import io.getstream.result.Result import io.getstream.video.android.core.Call +import io.getstream.video.android.core.notifications.NotificationHandler import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_REJECT_CALL -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_NOTIFICATION_ID +import io.getstream.video.android.core.notifications.internal.service.CallService /** * Used to process any pending intents that feature the [ACTION_REJECT_CALL] action. By consuming this @@ -40,7 +41,12 @@ internal class RejectCallBroadcastReceiver : GenericCallActionBroadcastReceiver( is Result.Success -> logger.d { "[onReceive] rejectCall, Success: $rejectResult" } is Result.Failure -> logger.d { "[onReceive] rejectCall, Failure: $rejectResult" } } - val notificationId = intent.getIntExtra(INTENT_EXTRA_NOTIFICATION_ID, 0) - NotificationManagerCompat.from(context).cancel(notificationId) + val serviceIntent = CallService.buildStopIntent(context) + context.stopService(serviceIntent) + + // As a second precaution cancel also the notification + NotificationManagerCompat.from( + context, + ).cancel(NotificationHandler.INCOMING_CALL_NOTIFICATION_ID) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt new file mode 100644 index 0000000000..daeab5f4e3 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -0,0 +1,328 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INCOMING_CALL_NOTIFICATION_ID +import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID +import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_DISPLAY_NAME +import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver +import io.getstream.video.android.model.StreamCallId +import io.getstream.video.android.model.streamCallDisplayName +import io.getstream.video.android.model.streamCallId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.openapitools.client.models.CallEndedEvent +import org.openapitools.client.models.CallRejectedEvent +import java.lang.IllegalArgumentException + +/** + * A foreground service that is running when there is an active call. + */ +internal class CallService : Service() { + private val logger by taggedLogger("CallService") + + // Data + private var callId: StreamCallId? = null + private var callDisplayName: String? = null + + // Service scope + private val serviceScope: CoroutineScope = CoroutineScope(Dispatchers.IO) + + // Camera handling receiver + private val toggleCameraBroadcastReceiver = ToggleCameraBroadcastReceiver() + + internal companion object { + const val TRIGGER_KEY = + "io.getstream.video.android.core.notifications.internal.service.CallService.call_trigger" + const val TRIGGER_INCOMING_CALL = "incomming_call" + const val TRIGGER_ONGOING_CALL = "ongoing_call" + + /** + * Build start intent. + * + * @param context the context. + * @param callId the call id. + * @param trigger one of [TRIGGER_INCOMING_CALL] or [TRIGGER_ONGOING_CALL] + * @param callDisplayName the display name. + */ + fun buildStartIntent( + context: Context, + callId: StreamCallId, + trigger: String, + callDisplayName: String? = null, + ): Intent { + val serviceIntent = Intent(context, CallService::class.java) + serviceIntent.putExtra(INTENT_EXTRA_CALL_CID, callId) + when (trigger) { + TRIGGER_INCOMING_CALL -> { + serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_INCOMING_CALL) + serviceIntent.putExtra(INTENT_EXTRA_CALL_DISPLAY_NAME, callDisplayName) + } + + TRIGGER_ONGOING_CALL -> { + serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_ONGOING_CALL) + } + + else -> { + throw IllegalArgumentException( + "Unknown $trigger, must be one of $TRIGGER_INCOMING_CALL or $TRIGGER_ONGOING_CALL", + ) + } + } + return serviceIntent + } + + /** + * Build stop intent. + * + * @param context the context. + */ + fun buildStopIntent(context: Context) = Intent(context, CallService::class.java) + } + + override fun onTimeout(startId: Int) { + super.onTimeout(startId) + logger.w { "Timeout received from the system, service will stop." } + stopService() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + + // Leave the call + callId?.let { + StreamVideo.instanceOrNull()?.call(it.type, it.id)?.leave() + logger.i { "Left ongoing call." } + } + + // Stop the service + stopService() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + logger.i { "Starting CallService. $intent" } + callId = intent?.streamCallId(INTENT_EXTRA_CALL_CID) + callDisplayName = intent?.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME) + val trigger = intent?.getStringExtra(TRIGGER_KEY) + val streamVideo = StreamVideo.instanceOrNull() + val started = if (callId != null && streamVideo != null && trigger != null) { + val notificationData: Pair = + when (trigger) { + TRIGGER_ONGOING_CALL -> Pair( + streamVideo.getOngoingCallNotification( + callId!!, + ), + callId.hashCode(), + ) + + TRIGGER_INCOMING_CALL -> Pair( + streamVideo.getRingingCallNotification( + callId!!, + callDisplayName!!, + ), + INCOMING_CALL_NOTIFICATION_ID, + ) + + else -> Pair(null, callId.hashCode()) + } + val notification = notificationData.first + if (notification != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val foregroundServiceType = + when (trigger) { + TRIGGER_ONGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + TRIGGER_INCOMING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + else -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } + ServiceCompat.startForeground( + this@CallService, + callId.hashCode(), + notification, + foregroundServiceType, + ) + } else { + startForeground(callId.hashCode(), notification) + } + true + } else { + // Service not started no notification + logger.e { "Could not get notification for ongoing call" } + false + } + } else { + // Service not started, no call Id or stream video + logger.e { "Call id or streamVideo or trigger are not available." } + false + } + + if (!started) { + logger.w { "Foreground service did not start!" } + stopService() + } else { + if (trigger == TRIGGER_INCOMING_CALL) { + updateRingingCall(streamVideo!!, callId!!) + initializeCallAndSocket(streamVideo, callId!!) + } + observeCallState(callId!!, streamVideo!!) + registerToggleCameraBroadcastReceiver() + } + return START_NOT_STICKY + } + + @OptIn(DelicateCoroutinesApi::class) + private fun updateRingingCall(streamVideo: StreamVideo, callId: StreamCallId) { + serviceScope.launch { + val call = streamVideo.call(callId.type, callId.id) + streamVideo.state.addRingingCall(call) + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun observeCallState(callId: StreamCallId, streamVideo: StreamVideo) { + // Ringing state + serviceScope.launch { + val call = streamVideo.call(callId.type, callId.id) + call.state.ringingState.collect { + logger.i { "Ringing state: $it" } + when (it) { + is RingingState.RejectedByAll -> { + stopService() + } + else -> { + // Do nothing + } + } + } + } + + // Call state + serviceScope.launch { + val call = streamVideo.call(callId.type, callId.id) + call.subscribe { + logger.i { "Received event in service: $it" } + when (it) { + is CallRejectedEvent -> { + // When call is rejected by the caller + stopService() + } + + is CallEndedEvent -> { + // When call ends for any reason + stopService() + } + } + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun initializeCallAndSocket( + streamVideo: StreamVideo, + callId: StreamCallId, + ) { + // Update call + serviceScope.launch { + val call = streamVideo.call(callId.type, callId.id) + val update = call.get() + if (update.isFailure) { + update.errorOrNull()?.let { + logger.e { it.message } + } ?: let { + logger.e { "Failed to update call." } + } + stopService() // Failed to update call + return@launch + } + } + + // Monitor coordinator socket + serviceScope.launch { + streamVideo.connectIfNotAlreadyConnected() + } + } + + override fun onDestroy() { + stopService() + super.onDestroy() + } + + // This service does not return a Binder + override fun onBind(intent: Intent?): IBinder? = null + + // Internal logic + /** + * Handle all aspects of stopping the service. + */ + private fun stopService() { + // Cancel the notification + val notificationManager = NotificationManagerCompat.from(this) + callId?.let { + val notificationId = callId.hashCode() + notificationManager.cancel(notificationId) + } + + // Optionally cancel any incoming call notification + notificationManager.cancel(INCOMING_CALL_NOTIFICATION_ID) + + // Stop + unregisterToggleCameraBroadcastReceiver() + + // Stop any jobs + serviceScope.cancel() + + // Optionally (no-op if already stopping) + stopSelf() + } + private fun registerToggleCameraBroadcastReceiver() { + try { + registerReceiver( + toggleCameraBroadcastReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + }, + ) + } catch (e: Exception) { + logger.e(e) { "Unable to register ToggleCameraBroadcastReceiver." } + } + } + + private fun unregisterToggleCameraBroadcastReceiver() { + try { + unregisterReceiver(toggleCameraBroadcastReceiver) + } catch (e: Exception) { + logger.e(e) { "Unable to unregister ToggleCameraBroadcastReceiver." } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/OngoingCallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/OngoingCallService.kt deleted file mode 100644 index 5253ad05ab..0000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/OngoingCallService.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.core.notifications.internal.service - -import android.app.Service -import android.content.Intent -import android.content.IntentFilter -import android.os.IBinder -import androidx.core.app.NotificationManagerCompat -import io.getstream.log.taggedLogger -import io.getstream.video.android.core.StreamVideo -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID -import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver -import io.getstream.video.android.model.StreamCallId -import io.getstream.video.android.model.streamCallId - -/** - * A foreground service that is running when there is an active call. - */ -internal class OngoingCallService : Service() { - private val logger by taggedLogger("OngoingCallService") - private var callId: StreamCallId? = null - private val toggleCameraBroadcastReceiver = ToggleCameraBroadcastReceiver() - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - callId = intent?.streamCallId(INTENT_EXTRA_CALL_CID) - val streamVideo = StreamVideo.instanceOrNull() - val started = if (callId != null && streamVideo != null) { - val notification = streamVideo.getOngoingCallNotification(callId!!) - if (notification != null) { - startForeground(callId.hashCode(), notification) - true - } else { - // Service not started no notification - logger.e { "Could not get notification for ongoing call" } - false - } - } else { - // Service not started, no call Id or stream video - logger.e { "Call id or streamVideo are not available." } - false - } - - if (started) { - registerToggleCameraBroadcastReceiver() - } else { - logger.w { "Foreground service did not start!" } - stopSelf() - } - - return START_NOT_STICKY - } - - private fun registerToggleCameraBroadcastReceiver() { - try { - registerReceiver( - toggleCameraBroadcastReceiver, - IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - addAction(Intent.ACTION_USER_PRESENT) - }, - ) - } catch (e: Exception) { - logger.e(e) { "Unable to register ToggleCameraBroadcastReceiver." } - } - } - - override fun onDestroy() { - callId?.let { - val notificationId = callId.hashCode() - NotificationManagerCompat.from(this).cancel(notificationId) - } - - unregisterToggleCameraBroadcastReceiver() - - super.onDestroy() - } - - private fun unregisterToggleCameraBroadcastReceiver() { - try { - unregisterReceiver(toggleCameraBroadcastReceiver) - } catch (e: Exception) { - logger.e(e) { "Unable to unregister ToggleCameraBroadcastReceiver." } - } - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - callId?.let { - StreamVideo.instanceOrNull()?.call(it.type, it.id)?.leave() - logger.i { "Left ongoing call." } - } - } - - // This service does not return a Binder - override fun onBind(intent: Intent?): IBinder? = null -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/StreamCallId.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/StreamCallId.kt index 099ea54e76..66211ee95b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/StreamCallId.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/StreamCallId.kt @@ -83,3 +83,5 @@ public fun Intent.streamCallId(key: String): StreamCallId? = when { getParcelableExtra(key) as? StreamCallId } + +public fun Intent.streamCallDisplayName(key: String): String = this.getStringExtra(key) ?: "." diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt index 0d49c7f5a5..de702b016d 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt @@ -147,7 +147,6 @@ public fun IncomingCallContent( } else { VideoTheme.dimens.avatarAppbarPadding } - detailsContent?.invoke(this, participants, topPadding) ?: IncomingCallDetails( modifier = Modifier .align(Alignment.CenterHorizontally)