diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index d2ffcbb5b48..c924c3f7d0c 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -31,6 +31,7 @@ import io.getstream.video.android.BuildConfig import io.getstream.video.android.app import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoBuilder +import io.getstream.video.android.core.call.CallType import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi import io.getstream.video.android.core.logging.LoggingLevel import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver @@ -39,6 +40,7 @@ import io.getstream.video.android.core.notifications.NotificationConfig import io.getstream.video.android.core.notifications.handlers.CompatibilityStreamNotificationHandler import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry import io.getstream.video.android.core.notifications.internal.service.DefaultCallConfigurations +import io.getstream.video.android.core.notifications.internal.telecom.TelecomConfig import io.getstream.video.android.core.socket.common.token.TokenProvider import io.getstream.video.android.core.sounds.enableRingingCallVibrationConfig import io.getstream.video.android.data.services.stream.GetAuthDataResponse @@ -206,14 +208,17 @@ object StreamVideoInitHelper { loggingLevel: LoggingLevel, ): StreamVideo { val callServiceConfigRegistry = CallServiceConfigRegistry() - callServiceConfigRegistry.register( - DefaultCallConfigurations.getLivestreamGuestCallServiceConfig(), - ) + callServiceConfigRegistry.apply { + register(DefaultCallConfigurations.getLivestreamGuestCallServiceConfig()) + register(CallType.AudioCall.name) { enableTelecom(true) } + } + return StreamVideoBuilder( context = context, apiKey = apiKey, user = user, token = token, + connectionTimeoutInMs = 12_000L, loggingLevel = loggingLevel, ensureSingleInstance = false, callServiceConfigRegistry = callServiceConfigRegistry, @@ -292,6 +297,9 @@ object StreamVideoInitHelper { callUpdatesAfterLeave = true, appName = "Stream Video Demo App", audioProcessing = NoiseCancellation(context), + telecomConfig = TelecomConfig( + context.packageName, + ), ).build() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f8fdd4cbc20..901f52d4da1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -227,6 +227,8 @@ play-services-mlkit-barcode-scanning = { group = "com.google.android.gms", name androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraCore" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraCore" } zxing-core = { group = "com.google.zxing", name = "core", version = "3.5.2" } +#jetpack telecom +androidx-telecom = { group = "androidx.core", name = "core-telecom", version = "1.0.1" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } 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 51438784016..83216067fda 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7476,6 +7476,7 @@ public final class io/getstream/video/android/core/ClientState { public final fun getCallConfigRegistry ()Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry; public final fun getConnection ()Lkotlinx/coroutines/flow/StateFlow; public final fun getRingingCall ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getTelecomIntegrationType ()Lio/getstream/video/android/core/notifications/internal/telecom/TelecomIntegrationType; public final fun getUser ()Lkotlinx/coroutines/flow/StateFlow; public final fun handleError (Lio/getstream/result/Error;)V public final fun handleEvent (Lio/getstream/android/video/generated/models/VideoEvent;)V @@ -8057,7 +8058,8 @@ public final class io/getstream/video/android/core/StreamVideoBuilder { public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZ)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZ)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZ)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;)V + public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun build ()Lio/getstream/video/android/core/StreamVideo; } @@ -12088,17 +12090,19 @@ public final class io/getstream/video/android/core/notifications/internal/receiv public final class io/getstream/video/android/core/notifications/internal/service/CallServiceConfig { public fun ()V - public fun (ZILjava/util/Map;Ljava/lang/Class;)V - public synthetic fun (ZILjava/util/Map;Ljava/lang/Class;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZILjava/util/Map;Ljava/lang/Class;Z)V + public synthetic fun (ZILjava/util/Map;Ljava/lang/Class;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()I public final fun component3 ()Ljava/util/Map; public final fun component4 ()Ljava/lang/Class; - public final fun copy (ZILjava/util/Map;Ljava/lang/Class;)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; - public static synthetic fun copy$default (Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;ZILjava/util/Map;Ljava/lang/Class;ILjava/lang/Object;)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; + public final fun component5 ()Z + public final fun copy (ZILjava/util/Map;Ljava/lang/Class;Z)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; + public static synthetic fun copy$default (Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;ZILjava/util/Map;Ljava/lang/Class;ZILjava/lang/Object;)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; public fun equals (Ljava/lang/Object;)Z public final fun getAudioUsage ()I public final fun getCallServicePerType ()Ljava/util/Map; + public final fun getEnableTelecom ()Z public final fun getRunCallServiceInForeground ()Z public final fun getServiceClass ()Ljava/lang/Class; public fun hashCode ()I @@ -12108,6 +12112,7 @@ public final class io/getstream/video/android/core/notifications/internal/servic public final class io/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder { public fun ()V public final fun build ()Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; + public final fun enableTelecom (Z)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder; public final fun setAudioUsage (I)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder; public final fun setRunCallServiceInForeground (Z)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder; public final fun setServiceClass (Ljava/lang/Class;)Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder; @@ -12142,6 +12147,87 @@ public final class io/getstream/video/android/core/notifications/internal/servic public final fun getLivestreamGuestCallServiceConfig ()Ljava/util/Map; } +public final class io/getstream/video/android/core/notifications/internal/telecom/TelecomCallController { + public fun (Landroid/content/Context;)V + public final fun getContext ()Landroid/content/Context; + public final fun leaveCall (Lio/getstream/video/android/core/Call;)V + public final fun onAnswer (Lio/getstream/video/android/core/Call;)V +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/TelecomConfig { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig; + public static synthetic fun copy$default (Lio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getSchema ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissions { + public fun ()V + public final fun canUseTelecom (Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Landroid/content/Context;)Z + public final fun getRequiredPermissionsArray (Lio/getstream/video/android/core/notifications/internal/telecom/TelecomIntegrationType;)[Ljava/lang/String; + public final fun supportsTelecom (Landroid/content/Context;)Z +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Activate$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Activate; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Activate; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Answer$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Answer; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Answer; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Disconnect$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Disconnect; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Disconnect; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Hold$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Hold; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$Hold; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$SwitchAudioEndpoint$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$SwitchAudioEndpoint; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$SwitchAudioEndpoint; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$ToggleMute$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$ToggleMute; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$ToggleMute; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$TransferCall$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$TransferCall; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction$TransferCall; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public final class io/getstream/video/android/core/notifications/medianotifications/MediaNotificationConfig { public fun (Lio/getstream/video/android/core/notifications/medianotifications/MediaNotificationContent;Lio/getstream/video/android/core/notifications/medianotifications/MediaNotificationVisuals;Landroid/app/PendingIntent;)V public final fun component1 ()Lio/getstream/video/android/core/notifications/medianotifications/MediaNotificationContent; diff --git a/stream-video-android-core/build.gradle.kts b/stream-video-android-core/build.gradle.kts index 52ac0c59b88..023864370a9 100644 --- a/stream-video-android-core/build.gradle.kts +++ b/stream-video-android-core/build.gradle.kts @@ -184,6 +184,9 @@ dependencies { implementation(libs.stream.push.delegate) api(libs.stream.push.permissions) + //jetpack telecom + implementation(libs.androidx.telecom) + // datastore api(libs.androidx.datastore) diff --git a/stream-video-android-core/src/main/AndroidManifest.xml b/stream-video-android-core/src/main/AndroidManifest.xml index e4f05d5b8e5..1997ca33883 100644 --- a/stream-video-android-core/src/main/AndroidManifest.xml +++ b/stream-video-android-core/src/main/AndroidManifest.xml @@ -115,5 +115,6 @@ android:name=".notifications.internal.service.AudioCallService" android:foregroundServiceType="microphone|phoneCall|shortService" android:exported="false" /> + \ No newline at end of file diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index acc61566e88..0f128989eed 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -82,6 +82,7 @@ import io.getstream.video.android.core.model.SortField import io.getstream.video.android.core.model.UpdateUserPermissionsData import io.getstream.video.android.core.model.VideoTrack import io.getstream.video.android.core.model.toIceServer +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController import io.getstream.video.android.core.socket.common.scope.ClientScope import io.getstream.video.android.core.socket.common.scope.UserScope import io.getstream.video.android.core.utils.AtomicUnitCall @@ -767,7 +768,7 @@ public class Call( private var reconnectJob: Job? = null private suspend fun schedule(key: String, block: suspend () -> Unit) { - logger.d { "[schedule] #reconnect; no args" } // noob 4 + logger.d { "[schedule] #reconnect; no args" } streamSingleFlightProcessorImpl.run(key, block) } @@ -810,6 +811,9 @@ public class Call( client.state.removeRingingCall(this) } + TelecomCallController(client.context) + .leaveCall(this) + (client as StreamVideoClient).onCallCleanUp(this) cleanup() } @@ -1368,6 +1372,15 @@ public class Call( return clientImpl.reject(type, id, reason) } + // For debugging + internal suspend fun reject( + source: String = "n/a", + reason: RejectReason? = null, + ): Result { + logger.d { "[reject] source: $source" } + return reject(reason) + } + fun processAudioSample(audioSample: AudioSamples) { soundInputProcessor.processSoundInput(audioSample.data) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index d3a88bc1558..478d1c136f9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -106,6 +106,8 @@ import io.getstream.video.android.core.model.Reaction import io.getstream.video.android.core.model.RejectReason import io.getstream.video.android.core.model.ScreenSharingSession import io.getstream.video.android.core.model.VisibilityOnScreenState +import io.getstream.video.android.core.notifications.IncomingNotificationData +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository import io.getstream.video.android.core.permission.PermissionRequest import io.getstream.video.android.core.pinning.PinType import io.getstream.video.android.core.pinning.PinUpdateAtTime @@ -686,9 +688,18 @@ public class CallState( private val pendingParticipantsJoined = ConcurrentHashMap() + /** + * We re-create notification more than 1 times, so we don't want to + * overwrite to the notifications builder properties once it is already set + */ internal val atomicNotification: AtomicReference = AtomicReference(null) + @InternalStreamVideoApi + internal var jetpackTelecomRepository: JetpackTelecomRepository? = null + + internal var incomingNotificationData = IncomingNotificationData(emptyMap()) + fun handleEvent(event: VideoEvent) { logger.d { "[handleEvent] ${event::class.java.name.split(".").last()}" } when (event) { @@ -1137,8 +1148,10 @@ public class CallState( val acceptedBy = _acceptedBy.value val isAcceptedByMe = _acceptedBy.value.contains(client.userId) val createdBy = _createdBy.value - val hasActiveCall = client.state.activeCall.value != null && client.state.activeCall.value?.id == call.id - val hasRingingCall = client.state.ringingCall.value != null && client.state.ringingCall.value?.id == call.id + val hasActiveCall = + client.state.activeCall.value != null && client.state.activeCall.value?.id == call.id + val hasRingingCall = + client.state.ringingCall.value != null && client.state.ringingCall.value?.id == call.id val userIsParticipant = _session.value?.participants?.find { it.user.id == client.userId } != null val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size 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 a894cc784fe..692d900f138 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 @@ -16,9 +16,7 @@ package io.getstream.video.android.core -import android.content.Intent import androidx.compose.runtime.Stable -import androidx.core.content.ContextCompat import io.getstream.android.video.generated.models.CallCreatedEvent import io.getstream.android.video.generated.models.CallRingEvent import io.getstream.android.video.generated.models.ConnectedEvent @@ -26,9 +24,10 @@ import io.getstream.android.video.generated.models.VideoEvent import io.getstream.log.taggedLogger import io.getstream.result.Error import io.getstream.video.android.core.notifications.internal.service.CallService +import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher +import io.getstream.video.android.core.notifications.internal.telecom.TelecomIntegrationType import io.getstream.video.android.core.socket.coordinator.state.VideoSocketState import io.getstream.video.android.core.utils.safeCallWithDefault -import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -85,6 +84,7 @@ class ClientState(private val client: StreamVideo) { public val activeCall: StateFlow = _activeCall public val callConfigRegistry = (client as StreamVideoClient).callServiceConfigRegistry + private val serviceLauncher = ServiceLauncher(client.context) /** * Returns true if there is an active or ringing call @@ -97,6 +97,18 @@ class ClientState(private val client: StreamVideo) { activeOrRingingCall } + /** + * Hardcoded to [TelecomIntegrationType.JETPACK_TELECOM] for now. + * May switch to another later if needed. + */ + fun getTelecomIntegrationType(): TelecomIntegrationType? { + return if (streamVideoClient.telecomConfig != null) { + TelecomIntegrationType.JETPACK_TELECOM + } else { + null + } + } + /** * Handles the events for the client state. * Most event logic happens in the Call instead of the client @@ -216,17 +228,20 @@ class ClientState(private val client: StreamVideo) { * This depends on the flag in [StreamVideoBuilder] called `runForegroundServiceForCalls` */ internal fun maybeStartForegroundService(call: Call, trigger: String) { - val callConfig = streamVideoClient.callServiceConfigRegistry.get(call.type) - if (callConfig.runCallServiceInForeground) { - val context = streamVideoClient.context - val serviceIntent = CallService.buildStartIntent( - context, - StreamCallId.fromCallCid(call.cid), + when (trigger) { + CallService.TRIGGER_ONGOING_CALL -> serviceLauncher.showOnGoingCall( + call, trigger, - "maybeStartForegroundService, trigger: $trigger", - callServiceConfiguration = callConfig, + streamVideoClient, ) - ContextCompat.startForegroundService(context, serviceIntent) + + CallService.TRIGGER_OUTGOING_CALL -> serviceLauncher.showOutgoingCall( + call, + trigger, + streamVideoClient, + ) + + else -> {} } } @@ -238,29 +253,9 @@ class ClientState(private val client: StreamVideo) { if (callConfig.runCallServiceInForeground) { val context = streamVideoClient.context - val serviceIntent = CallService.buildStopIntent( - context, - call, - callConfig, - ) logger.d { "Building stop intent for call_id: ${call.cid}" } - serviceIntent.let { intent: Intent -> - val bundle = intent.extras - val keys = bundle?.keySet() - if (keys != null) { - val sb = StringBuilder() - for (key in keys) { - val itemInBundle = bundle[key] - val text = "key:$key, value=$itemInBundle" - sb.append(text) - sb.append("\n") - } - if (sb.toString().isNotEmpty()) { - logger.d { " [maybeStopForegroundService], stop intent extras: $sb" } - } - } - } - context.startService(serviceIntent) + val serviceLauncher = ServiceLauncher(context) + serviceLauncher.stopService(call) } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt new file mode 100644 index 00000000000..4ec18be970b --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2024 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 + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController +import io.getstream.video.android.model.StreamCallId + +internal class ExternalCallRejectionHandler() { + private val logger by taggedLogger("CallRejectionHandler") + + suspend fun onRejectCall(source: ExternalCallRejectionSource, call: Call, context: Context, intent: Intent = Intent()) { + when ( + val rejectResult = call.reject( + source = "ExternalCallRejectionHandler.$source", + RejectReason.Decline, + ) + ) { + is Result.Success -> { + val userId = StreamVideo.instanceOrNull()?.userId + userId?.let { + val set = mutableSetOf(it) + call.state.updateRejectedBy(set) + call.state.updateRejectActionBundle(intent.extras ?: Bundle()) + } + logger.d { "[onRejectCall] source:$source rejectCall, Success: $rejectResult" } + } + is Result.Failure -> { + logger.d { "[onRejectCall] source:$source, rejectCall, Failure: $rejectResult" } + } + } + logger.d { "[onRejectCall] source:$source, #ringing; callId: ${call.id}, action: ${intent.action}" } + + val serviceLauncher = ServiceLauncher(context) + serviceLauncher.removeIncomingCall( + context, + StreamCallId.fromCallCid(call.cid), + StreamVideo.instance().state.callConfigRegistry.get(call.type), + ) + when (source) { + ExternalCallRejectionSource.NOTIFICATION -> { + TelecomCallController(context) + .leaveCall(call) + } + ExternalCallRejectionSource.WEARABLE -> { + /** + * Following the same logic from StreamCallActivity.reject(Call, RejectReason?, + * onSuccess: (suspend (Call) -> Unit)?, + * onError: (suspend (Exception) -> Unit)?,) + */ + call.leave() + } + } + } +} + +internal enum class ExternalCallRejectionSource { + NOTIFICATION, WEARABLE +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index cfa9de560d2..cc9dad27aed 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -32,6 +32,7 @@ import io.getstream.video.android.core.notifications.internal.StreamNotification import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry import io.getstream.video.android.core.notifications.internal.storage.DeviceTokenStorage +import io.getstream.video.android.core.notifications.internal.telecom.TelecomConfig import io.getstream.video.android.core.permission.android.DefaultStreamPermissionCheck import io.getstream.video.android.core.permission.android.StreamPermissionCheck import io.getstream.video.android.core.socket.common.scope.ClientScope @@ -142,6 +143,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val enableStatsReporting: Boolean = true, @InternalStreamVideoApi private val enableStereoForSubscriber: Boolean = true, + private val telecomConfig: TelecomConfig? = null, ) { private val context: Context = context.applicationContext private val scope = UserScope(ClientScope()) @@ -269,6 +271,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( enableStatsCollection = enableStatsReporting, vibrationConfig = vibrationConfig, enableStereoForSubscriber = enableStereoForSubscriber, + telecomConfig = telecomConfig, ) if (user.type == UserType.Guest) { @@ -335,6 +338,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( legacyCallConfig.runCallServiceInForeground, ) setAudioUsage(legacyCallConfig.audioUsage) + enableTelecom(legacyCallConfig.enableTelecom) } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index bb14f789c98..8413d696f91 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -97,8 +97,10 @@ import io.getstream.video.android.core.model.toRequest import io.getstream.video.android.core.notifications.NotificationHandler import io.getstream.video.android.core.notifications.internal.StreamNotificationManager import io.getstream.video.android.core.notifications.internal.service.ANY_MARKER -import io.getstream.video.android.core.notifications.internal.service.CallService import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry +import io.getstream.video.android.core.notifications.internal.service.ServiceIntentBuilder +import io.getstream.video.android.core.notifications.internal.service.StopServiceParam +import io.getstream.video.android.core.notifications.internal.telecom.TelecomConfig import io.getstream.video.android.core.permission.android.DefaultStreamPermissionCheck import io.getstream.video.android.core.permission.android.StreamPermissionCheck import io.getstream.video.android.core.socket.ErrorResponse @@ -173,6 +175,7 @@ internal class StreamVideoClient internal constructor( internal val enableCallUpdatesAfterLeave: Boolean = false, internal val enableStatsCollection: Boolean = true, internal val enableStereoForSubscriber: Boolean = true, + internal val telecomConfig: TelecomConfig? = null, ) : StreamVideo, NotificationHandler by streamNotificationManager { private var locationJob: Deferred>? = null @@ -223,11 +226,13 @@ internal class StreamVideoClient internal constructor( val runCallServiceInForeground = callConfig.runCallServiceInForeground if (runCallServiceInForeground) { safeCall { - val serviceIntent = CallService.buildStopIntent( + val serviceIntent = ServiceIntentBuilder().buildStopIntent( context = context, - callServiceConfiguration = callConfig, + StopServiceParam(callServiceConfiguration = callConfig), ) - context.stopService(serviceIntent) + serviceIntent.let { + context.stopService(serviceIntent) + } } } activeCall?.leave() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt index b27b750c43e..b10bfcce78e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt @@ -626,7 +626,7 @@ public class RtcSession internal constructor( setMuteState(isEnabled = true, TrackType.TRACK_TYPE_VIDEO) val streamId = buildTrackId(TrackType.TRACK_TYPE_VIDEO) - val track = publisher?.publishStream( // noob 9 + val track = publisher?.publishStream( streamId, TrackType.TRACK_TYPE_VIDEO, call.mediaManager.camera.resolution.value, @@ -1048,7 +1048,7 @@ public class RtcSession internal constructor( call.state.getOrCreateParticipant(it) } call.state.replaceParticipants(participantStates) - sfuConnectionModule.socketConnection.whenConnected { // noob 3 + sfuConnectionModule.socketConnection.whenConnected { logger.d { "JoinCallResponseEvent sfuConnectionModule.socketConnection.whenConnected" } if (publisher == null) { publisher = createPublisher(event.publishOptions) 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 019990a0ce2..94d12010840 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 @@ -48,7 +48,7 @@ import io.getstream.video.android.core.notifications.NotificationHandler.Compani import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_NOTIFICATION import io.getstream.video.android.core.notifications.dispatchers.DefaultNotificationDispatcher import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher -import io.getstream.video.android.core.notifications.internal.service.CallService +import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher import io.getstream.video.android.core.notifications.medianotifications.MediaNotificationConfig import io.getstream.video.android.core.notifications.medianotifications.MediaNotificationContent import io.getstream.video.android.core.notifications.medianotifications.MediaNotificationVisuals @@ -88,6 +88,8 @@ public open class DefaultNotificationHandler( private val logger by taggedLogger("Call:NotificationHandler") val intentResolver = DefaultStreamIntentResolver(application, DefaultNotificationIntentBundleResolver()) + private val serviceLauncher = ServiceLauncher(application) + protected val notificationManager: NotificationManagerCompat by lazy { NotificationManagerCompat.from(application).also { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -111,11 +113,15 @@ public open class DefaultNotificationHandler( payload: Map, ) { logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" } - CallService.showIncomingCall( + val streamVideo = StreamVideo.instance() + serviceLauncher.showIncomingCall( application, callId, callDisplayName, - StreamVideo.instance().state.callConfigRegistry.get(callId.type), + streamVideo.state.callConfigRegistry.get(callId.type), + isVideo = isVideoCall(callId, payload), + payload = payload, + streamVideo, notification = getRingingCallNotification( RingingState.Incoming(), callId, @@ -933,6 +939,14 @@ public open class DefaultNotificationHandler( ) } + internal fun isVideoCall(callId: StreamCallId, payload: Map): Boolean { + if (payload.containsKey("video")) { + return payload["video"] == true + } + val call = StreamVideo.instanceOrNull()?.call(callId.type, callId.id) + return call?.isVideoEnabled() == true + } + companion object { internal val PENDING_INTENT_FLAG: Int by lazy { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingNotificationData.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingNotificationData.kt new file mode 100644 index 00000000000..08b1494ea08 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingNotificationData.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2014-2024 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 + +import android.app.PendingIntent + +internal data class IncomingNotificationData(val pendingIntentMap: Map) + +internal sealed class IncomingNotificationAction { + data object Accept : IncomingNotificationAction() + data object Reject : IncomingNotificationAction() +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 3525f39cac7..84ebc3e4a12 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt @@ -49,6 +49,8 @@ import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver +import io.getstream.video.android.core.notifications.IncomingNotificationAction +import io.getstream.video.android.core.notifications.IncomingNotificationData import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_LIVE_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_MISSED_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_NOTIFICATION @@ -56,7 +58,7 @@ import io.getstream.video.android.core.notifications.StreamIntentResolver import io.getstream.video.android.core.notifications.dispatchers.DefaultNotificationDispatcher import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher import io.getstream.video.android.core.notifications.extractor.DefaultNotificationContentExtractor -import io.getstream.video.android.core.notifications.internal.service.CallService +import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher import io.getstream.video.android.core.utils.isAppInForeground import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId @@ -146,6 +148,7 @@ constructor( NotificationPermissionHandler by notificationPermissionHandler { private val logger by taggedLogger("Video:StreamNotificationHandler") + private val serviceLauncher = ServiceLauncher(application) // START REGION : On push arrived override fun onRingingCall( @@ -154,11 +157,15 @@ constructor( payload: Map, ) { logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" } - CallService.showIncomingCall( + val streamVideo = StreamVideo.instance() + serviceLauncher.showIncomingCall( application, callId, callDisplayName, - StreamVideo.instance().state.callConfigRegistry.get(callId.type), + streamVideo.state.callConfigRegistry.get(callId.type), + isVideo = isVideoCall(callId, payload), + payload = payload, + streamVideo, notification = getRingingCallNotification( RingingState.Incoming(), callId, @@ -307,6 +314,19 @@ constructor( payload = payload, ) + val streamVideo = StreamVideo.instanceOrNull() + streamVideo?.let { streamVideoInstance -> + val call = streamVideoInstance.call(callId.type, callId.id) + val map = HashMap() + acceptCallPendingIntent?.let { pendingIntent -> + map[IncomingNotificationAction.Accept] = pendingIntent + } + rejectCallPendingIntent?.let { pendingIntent -> + map[IncomingNotificationAction.Reject] = pendingIntent + } + call.state.incomingNotificationData = IncomingNotificationData(map) + } + if (fullScreenPendingIntent != null && acceptCallPendingIntent != null && rejectCallPendingIntent != null) { getIncomingCallNotification( fullScreenPendingIntent, @@ -1149,4 +1169,12 @@ constructor( internal fun clearMediaSession(callId: StreamCallId?) = safeCall { callId?.let { mediaSessionController.clear(it) } } + + internal fun isVideoCall(callId: StreamCallId, payload: Map): Boolean { + if (payload.containsKey("video")) { + return payload["video"] == true + } + val call = StreamVideo.instanceOrNull()?.call(callId.type, callId.id) + return call?.isVideoEnabled() == true + } } 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 ef4b8bedb03..a4fecf8bb66 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 @@ -18,15 +18,11 @@ package io.getstream.video.android.core.notifications.internal.receivers import android.content.Context import android.content.Intent -import android.os.Bundle import io.getstream.log.taggedLogger -import io.getstream.result.Result import io.getstream.video.android.core.Call -import io.getstream.video.android.core.StreamVideo -import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.core.ExternalCallRejectionHandler +import io.getstream.video.android.core.ExternalCallRejectionSource import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_REJECT_CALL -import io.getstream.video.android.core.notifications.internal.service.CallService -import io.getstream.video.android.model.StreamCallId /** * Used to process any pending intents that feature the [ACTION_REJECT_CALL] action. By consuming this @@ -37,27 +33,14 @@ internal class RejectCallBroadcastReceiver : GenericCallActionBroadcastReceiver( val logger by taggedLogger("Call:RejectReceiver") override val action = ACTION_REJECT_CALL + private val externalCallRejectionHandler = ExternalCallRejectionHandler() override suspend fun onReceive(call: Call, context: Context, intent: Intent) { - when (val rejectResult = call.reject(RejectReason.Decline)) { - is Result.Success -> { - val userId = StreamVideo.instanceOrNull()?.userId - userId?.let { - val set = mutableSetOf(it) - call.state.updateRejectedBy(set) - call.state.updateRejectActionBundle(intent.extras ?: Bundle()) - } - logger.d { "[onReceive] rejectCall, Success: $rejectResult" } - } - is Result.Failure -> { - logger.d { "[onReceive] rejectCall, Failure: $rejectResult" } - } - } - logger.d { "[onReceive] #ringing; callId: ${call.id}, action: ${intent.action}" } - CallService.removeIncomingCall( + externalCallRejectionHandler.onRejectCall( + ExternalCallRejectionSource.NOTIFICATION, + call, context, - StreamCallId.fromCallCid(call.cid), - StreamVideo.instance().state.callConfigRegistry.get(call.type), + intent, ) } } 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 index 4d937be869f..b560e624e10 100644 --- 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 @@ -18,10 +18,8 @@ package io.getstream.video.android.core.notifications.internal.service import android.Manifest import android.annotation.SuppressLint -import android.app.ActivityManager import android.app.Notification import android.app.Service -import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -39,10 +37,8 @@ import io.getstream.android.video.generated.models.CallAcceptedEvent import io.getstream.android.video.generated.models.CallEndedEvent import io.getstream.android.video.generated.models.CallRejectedEvent import io.getstream.android.video.generated.models.LocalCallMissedEvent -import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call -import io.getstream.video.android.core.R import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideo @@ -58,8 +54,6 @@ import io.getstream.video.android.core.notifications.internal.receivers.ToggleCa import io.getstream.video.android.core.socket.common.scope.ClientScope import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer import io.getstream.video.android.core.utils.safeCall -import io.getstream.video.android.core.utils.safeCallWithDefault -import io.getstream.video.android.core.utils.safeCallWithResult import io.getstream.video.android.core.utils.startForegroundWithServiceType import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.streamCallDisplayName @@ -187,6 +181,7 @@ internal open class CallService : Service() { // Call sounds private var callSoundAndVibrationPlayer: CallSoundAndVibrationPlayer? = null + private val serviceNotificationRetriever = ServiceNotificationRetriever() internal companion object { private const val TAG = "CallServiceCompanion" @@ -197,186 +192,6 @@ internal open class CallService : Service() { const val TRIGGER_OUTGOING_CALL = "outgoing_call" const val TRIGGER_ONGOING_CALL = "ongoing_call" const val EXTRA_STOP_SERVICE = "io.getstream.video.android.core.stop_service" - - /** - * Build start intent. - * - * @param context the context. - * @param callId the call id. - * @param trigger one of [TRIGGER_INCOMING_CALL], [TRIGGER_OUTGOING_CALL] or [TRIGGER_ONGOING_CALL] - * @param callDisplayName the display name. - */ - fun buildStartIntent( - context: Context, - callId: StreamCallId, - trigger: String, - callDisplayName: String? = null, - callServiceConfiguration: CallServiceConfig = DefaultCallConfigurations.default, - ): Intent { - val serviceClass = callServiceConfiguration.serviceClass - StreamLog.i(TAG) { "Resolved service class: $serviceClass" } - val serviceIntent = Intent(context, serviceClass) - 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_OUTGOING_CALL -> { - serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_OUTGOING_CALL) - } - - TRIGGER_ONGOING_CALL -> { - serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_ONGOING_CALL) - } - - TRIGGER_REMOVE_INCOMING_CALL -> { - serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_REMOVE_INCOMING_CALL) - } - - else -> { - throw IllegalArgumentException( - "Unknown $trigger, must be one of: $TRIGGER_INCOMING_CALL, $TRIGGER_OUTGOING_CALL, $TRIGGER_ONGOING_CALL", - ) - } - } - StreamLog.d(TAG) { "[buildStartIntent], call_id:${callId.cid}" } - return serviceIntent - } - - /** - * Build stop intent. - * - * @param context the context. - */ - fun buildStopIntent( - context: Context, - call: Call? = null, - callServiceConfiguration: CallServiceConfig = DefaultCallConfigurations.default, - ) = safeCallWithDefault(Intent(context, CallService::class.java)) { - val serviceClass = callServiceConfiguration.serviceClass - - val intent = if (isServiceRunning(context, serviceClass)) { - Intent(context, serviceClass) - } else { - Intent(context, CallService::class.java) - } - call?.let { - StreamLog.d(TAG) { "[buildStopIntent], call_id:${call.cid}" } - val streamCallId = StreamCallId(call.type, call.id, call.cid) - intent.putExtra(INTENT_EXTRA_CALL_CID, streamCallId) - } - intent.putExtra(EXTRA_STOP_SERVICE, true) - } - - fun showIncomingCall( - context: Context, - callId: StreamCallId, - callDisplayName: String?, - callServiceConfiguration: CallServiceConfig = DefaultCallConfigurations.default, - notification: Notification?, - ) { - StreamLog.d(TAG) { - "[showIncomingCall] callId: ${callId.id}, callDisplayName: $callDisplayName, notification: ${notification != null}" - } - val hasActiveCall = StreamVideo.instanceOrNull()?.state?.activeCall?.value != null - StreamLog.d(TAG) { "[showIncomingCall] hasActiveCall: $hasActiveCall" } - safeCallWithResult { - val result = if (!hasActiveCall) { - StreamLog.d(TAG) { "[showIncomingCall] Starting foreground service" } - ContextCompat.startForegroundService( - context, - buildStartIntent( - context, - callId, - TRIGGER_INCOMING_CALL, - callDisplayName, - callServiceConfiguration, - ), - ) - ComponentName(context, CallService::class.java) - } else { - StreamLog.d(TAG) { "[showIncomingCall] Starting regular service" } - context.startService( - buildStartIntent( - context, - callId, - TRIGGER_INCOMING_CALL, - callDisplayName, - callServiceConfiguration, - ), - ) - } - result!! - }.onError { - // Show notification - StreamLog.e(TAG) { "Could not start service, showing notification only: $it" } - val hasPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - StreamLog.i(TAG) { "Has permission: $hasPermission" } - StreamLog.i(TAG) { "Notification: $notification" } - if (hasPermission && notification != null) { - StreamLog.d(TAG) { - "[showIncomingCall] Showing notification fallback with ID: ${ - callId.getNotificationId( - NotificationType.Incoming, - ) - }" - } - StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher()?.notify( - callId, - callId.getNotificationId(NotificationType.Incoming), - notification, - ) - } else { - StreamLog.w(TAG) { - "[showIncomingCall] Cannot show notification - hasPermission: $hasPermission, notification: ${notification != null}" - } - } - } - } - - fun removeIncomingCall( - context: Context, - callId: StreamCallId, - config: CallServiceConfig = DefaultCallConfigurations.default, - ) { - safeCallWithResult { - context.startService( - buildStartIntent( - context, - callId, - TRIGGER_REMOVE_INCOMING_CALL, - "showIncomingCall, trigger:$TRIGGER_REMOVE_INCOMING_CALL", - callServiceConfiguration = config, - ), - )!! - }.onError { - NotificationManagerCompat.from( - context, - ).cancel(callId.getNotificationId(NotificationType.Incoming)) - } - } - - private fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean = - safeCallWithDefault(true) { - val activityManager = context.getSystemService( - Context.ACTIVITY_SERVICE, - ) as ActivityManager - val runningServices = activityManager.getRunningServices(Int.MAX_VALUE) - for (service in runningServices) { - if (serviceClass.name == service.service.className) { - StreamLog.w(TAG) { "Service is running: $serviceClass" } - return true - } - } - StreamLog.w(TAG) { "Service is NOT running: $serviceClass" } - return false - } } private fun shouldStopServiceFromIntent(intent: Intent?): Boolean { @@ -596,69 +411,13 @@ internal open class CallService : Service() { streamCallId: StreamCallId, intentCallDisplayName: String?, ): Pair { - logger.d { - "[getNotificationPair] trigger: $trigger, callId: ${streamCallId.id}, callDisplayName: $intentCallDisplayName" - } - val notificationData: Pair = when (trigger) { - TRIGGER_ONGOING_CALL -> { - logger.d { "[getNotificationPair] Creating ongoing call notification" } - Pair( - first = streamVideo.getOngoingCallNotification( - callId = streamCallId, - callDisplayName = intentCallDisplayName, - payload = emptyMap(), - ), - second = streamCallId.hashCode(), - ) - } - - TRIGGER_INCOMING_CALL -> { - logger.d { "[getNotificationPair] Creating incoming call notification" } - val shouldHaveContentIntent = streamVideo.state.activeCall.value == null - logger.d { "[getNotificationPair] shouldHaveContentIntent: $shouldHaveContentIntent" } - Pair( - first = streamVideo.getRingingCallNotification( - ringingState = RingingState.Incoming(), - callId = streamCallId, - callDisplayName = intentCallDisplayName, - shouldHaveContentIntent = shouldHaveContentIntent, - payload = emptyMap(), - ), - second = streamCallId.getNotificationId(NotificationType.Incoming), - ) - } - - TRIGGER_OUTGOING_CALL -> { - logger.d { "[getNotificationPair] Creating outgoing call notification" } - Pair( - first = streamVideo.getRingingCallNotification( - ringingState = RingingState.Outgoing(), - callId = streamCallId, - callDisplayName = getString( - R.string.stream_video_outgoing_call_notification_title, - ), - payload = emptyMap(), - ), - second = streamCallId.getNotificationId( - NotificationType.Incoming, - ), // Same for incoming and outgoing - ) - } - - TRIGGER_REMOVE_INCOMING_CALL -> { - logger.d { "[getNotificationPair] Removing incoming call notification" } - Pair(null, streamCallId.getNotificationId(NotificationType.Incoming)) - } - - else -> { - logger.w { "[getNotificationPair] Unknown trigger: $trigger" } - Pair(null, streamCallId.hashCode()) - } - } - logger.d { - "[getNotificationPair] Generated notification: ${notificationData.first != null}, notificationId: ${notificationData.second}" - } - return notificationData + return serviceNotificationRetriever.getNotificationPair( + applicationContext, + trigger, + streamVideo, + streamCallId, + intentCallDisplayName, + ) } private fun maybePromoteToForegroundService( @@ -845,7 +604,10 @@ internal open class CallService : Service() { is RingingState.RejectedByAll -> { ClientScope().launch { - call.reject(RejectReason.Decline) + call.reject( + source = "RingingState.RejectedByAll", + RejectReason.Decline, + ) } callSoundAndVibrationPlayer?.stopCallSound() stopService() @@ -1060,7 +822,10 @@ internal open class CallService : Service() { if (ringingState is RingingState.Outgoing) { // If I'm calling, end the call for everyone serviceScope.launch { - call.reject(RejectReason.Custom("Android Service Task Removed")) + call.reject( + "CallService.EndCall", + RejectReason.Custom("Android Service Task Removed"), + ) logger.i { "[onTaskRemoved] Ended outgoing call for all users." } } } else if (ringingState is RingingState.Incoming) { @@ -1070,7 +835,7 @@ internal open class CallService : Service() { if (memberCount == 2) { // ...and I'm the only one being called, end the call for both users serviceScope.launch { - call.reject() + call.reject(source = "memberCount == 2") logger.i { "[onTaskRemoved] Ended incoming call for both users." } } } else { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt index 20e858e85b7..c7b21f4ce80 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt @@ -46,6 +46,7 @@ public data class CallServiceConfig( Pair(ANY_MARKER, CallService::class.java), ), val serviceClass: Class<*> = CallService::class.java, + val enableTelecom: Boolean = false, ) /** diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder.kt index 72fc10645bc..b76b7cc4bf9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigBuilder.kt @@ -22,6 +22,7 @@ class CallServiceConfigBuilder { private var serviceClass: Class<*> = CallService::class.java private var runCallServiceInForeground: Boolean = true private var audioUsage: Int = AudioAttributes.USAGE_VOICE_COMMUNICATION + private var enableTelecom: Boolean = false fun setServiceClass(serviceClass: Class<*>): CallServiceConfigBuilder = apply { this.serviceClass = serviceClass @@ -35,11 +36,16 @@ class CallServiceConfigBuilder { this.audioUsage = audioUsage } + fun enableTelecom(enableTelecom: Boolean): CallServiceConfigBuilder = apply { + this.enableTelecom = enableTelecom + } + fun build(): CallServiceConfig { return CallServiceConfig( serviceClass = serviceClass, runCallServiceInForeground = runCallServiceInForeground, audioUsage = audioUsage, + enableTelecom = enableTelecom, ) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry.kt index f37848cba24..a8ec4407d19 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry.kt @@ -160,6 +160,7 @@ class CallServiceConfigRegistry { setServiceClass(existingConfig.serviceClass) setRunCallServiceInForeground(existingConfig.runCallServiceInForeground) setAudioUsage(existingConfig.audioUsage) + enableTelecom(existingConfig.enableTelecom) updater() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt new file mode 100644 index 00000000000..068c797eed5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2014-2024 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.Manifest +import android.app.Notification +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL +import io.getstream.video.android.core.utils.safeCallWithResult +import io.getstream.video.android.model.StreamCallId + +internal class IncomingCallPresenter(private val serviceIntentBuilder: ServiceIntentBuilder) { + private val logger by taggedLogger("IncomingCallPresenter") + + fun showIncomingCall( + context: Context, + callId: StreamCallId, + callDisplayName: String?, + callServiceConfiguration: CallServiceConfig, + notification: Notification?, + ): ShowIncomingCallResult { + logger.d { + "[showIncomingCall] callId: ${callId.id}, callDisplayName: $callDisplayName, notification: ${notification != null}" + } + val hasActiveCall = StreamVideo.instanceOrNull()?.state?.activeCall?.value != null + logger.d { "[showIncomingCall] hasActiveCall: $hasActiveCall" } + var showIncomingCallResult = ShowIncomingCallResult.ERROR + safeCallWithResult { + if (!hasActiveCall) { + logger.d { "[showIncomingCall] Starting foreground service" } + ContextCompat.startForegroundService( + context, + serviceIntentBuilder.buildStartIntent( + context, + StartServiceParam( + callId, + TRIGGER_INCOMING_CALL, + callDisplayName, + callServiceConfiguration, + ), + ), + ) + ComponentName(context, CallService::class.java) + showIncomingCallResult = ShowIncomingCallResult.FG_SERVICE + } else { + logger.d { "[showIncomingCall] Starting regular service" } + context.startService( + serviceIntentBuilder.buildStartIntent( + context, + StartServiceParam( + callId, + TRIGGER_INCOMING_CALL, + callDisplayName, + callServiceConfiguration, + ), + ), + ) + showIncomingCallResult = ShowIncomingCallResult.SERVICE + } + }.onError { + // Show notification + logger.e { "Could not start service, showing notification only: $it" } + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + logger.i { "Has permission: $hasPermission" } + logger.i { "Notification: $notification" } + if (hasPermission && notification != null) { + logger.d { + "[showIncomingCall] Showing notification fallback with ID: ${callId.getNotificationId( + NotificationType.Incoming, + )}" + } + StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher()?.notify( + callId, + callId.getNotificationId(NotificationType.Incoming), + notification, + ) + showIncomingCallResult = ShowIncomingCallResult.ONLY_NOTIFICATION + } else { + logger.w { + "[showIncomingCall] Cannot show notification - hasPermission: $hasPermission, notification: ${notification != null}" + } + } + } + return showIncomingCallResult + } +} + +internal enum class ShowIncomingCallResult { + FG_SERVICE, SERVICE, ONLY_NOTIFICATION, ERROR +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/JetpackTelecomRepositoryProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/JetpackTelecomRepositoryProvider.kt new file mode 100644 index 00000000000..d1a5b6089a4 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/JetpackTelecomRepositoryProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-2024 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.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.telecom.CallsManager +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.internal.telecom.IncomingCallTelecomAction +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.model.StreamCallId + +internal class JetpackTelecomRepositoryProvider(private val context: Context) { + + @RequiresApi(Build.VERSION_CODES.O) + fun get(callId: StreamCallId): JetpackTelecomRepository { + val callsManager = CallsManager(context).apply { + registerAppWithTelecom( + capabilities = CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING and + CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING, + ) + } + + val streamVideo = StreamVideo.instance() + val incomingCallTelecomAction = + IncomingCallTelecomAction(streamVideo) + return JetpackTelecomRepository(callsManager, callId, incomingCallTelecomAction) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt new file mode 100644 index 00000000000..1731575e12b --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2014-2024 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.ActivityManager +import android.content.Context +import android.content.Intent +import io.getstream.log.taggedLogger +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.service.CallService.Companion.EXTRA_STOP_SERVICE +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_KEY +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_ONGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_OUTGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL +import io.getstream.video.android.core.utils.safeCallWithDefault +import io.getstream.video.android.model.StreamCallId + +internal class ServiceIntentBuilder { + + private val logger by taggedLogger("TelecomIntentBuilder") + + fun buildStartIntent(context: Context, startService: StartServiceParam): Intent { + val serviceClass = startService.callServiceConfiguration.serviceClass + logger.i { "Resolved service class: $serviceClass" } + val serviceIntent = Intent(context, serviceClass) + serviceIntent.putExtra(INTENT_EXTRA_CALL_CID, startService.callId) + + when (startService.trigger) { + TRIGGER_INCOMING_CALL -> { + serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_INCOMING_CALL) + serviceIntent.putExtra(INTENT_EXTRA_CALL_DISPLAY_NAME, startService.callDisplayName) + } + + TRIGGER_OUTGOING_CALL -> { + serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_OUTGOING_CALL) + } + + TRIGGER_ONGOING_CALL -> { + serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_ONGOING_CALL) + } + + TRIGGER_REMOVE_INCOMING_CALL -> { + serviceIntent.putExtra(TRIGGER_KEY, TRIGGER_REMOVE_INCOMING_CALL) + } + + else -> { + throw IllegalArgumentException( + "Unknown ${startService.trigger}, must be one of: $TRIGGER_INCOMING_CALL, $TRIGGER_OUTGOING_CALL, $TRIGGER_ONGOING_CALL", + ) + } + } + return serviceIntent + } + + fun buildStopIntent(context: Context, stopServiceParam: StopServiceParam): Intent = + safeCallWithDefault(Intent(context, CallService::class.java)) { + val serviceClass = stopServiceParam.callServiceConfiguration.serviceClass + + val intent = if (isServiceRunning(context, serviceClass)) { + Intent(context, serviceClass) + } else { + Intent(context, CallService::class.java) + } + stopServiceParam.call?.let { call -> + logger.d { "[buildStopIntent], call_id:${call.cid}" } + val streamCallId = StreamCallId(call.type, call.id, call.cid) + intent.putExtra(INTENT_EXTRA_CALL_CID, streamCallId) + } + intent.putExtra(EXTRA_STOP_SERVICE, true) + } + + private fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean = + safeCallWithDefault(true) { + val activityManager = context.getSystemService( + Context.ACTIVITY_SERVICE, + ) as ActivityManager + val runningServices = activityManager.getRunningServices(Int.MAX_VALUE) + for (service in runningServices) { + if (serviceClass.name == service.service.className) { + logger.w { "Service is running: $serviceClass" } + return true + } + } + logger.w { "Service is NOT running: $serviceClass" } + return false + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt new file mode 100644 index 00000000000..460aef60b1e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2014-2024 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 + +/* + * Copyright (c) 2014-2024 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. + */ + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.os.Bundle +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.VideoPushDelegate.Companion.DEFAULT_CALL_TEXT +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL +import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper +import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCallAction +import io.getstream.video.android.core.utils.safeCallWithResult +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class ServiceLauncher(val context: Context) { + + private val logger by taggedLogger("ServiceTriggers") + private val serviceIntentBuilder = ServiceIntentBuilder() + private val incomingCallPresenter = IncomingCallPresenter(serviceIntentBuilder) + private val telecomHelper = TelecomHelper() + private val telecomPermissions = TelecomPermissions() + private val jetpackTelecomRepositoryProvider = JetpackTelecomRepositoryProvider(context) + + @SuppressLint("MissingPermission", "NewApi") + fun showIncomingCall( + context: Context, + callId: StreamCallId, + callDisplayName: String?, + callServiceConfiguration: CallServiceConfig, + isVideo: Boolean, + payload: Map, + streamVideo: StreamVideo, + notification: Notification?, + ) { + val result = incomingCallPresenter.showIncomingCall( + context, + callId, + callDisplayName, + callServiceConfiguration, + notification, + ) + logger.d { "[showIncomingCall] service start result: $result" } + if (telecomPermissions.canUseTelecom(callServiceConfiguration, context)) { + if (telecomHelper.canUseJetpackTelecom()) { + when (result) { + ShowIncomingCallResult.FG_SERVICE -> { + updateIncomingCallNotification(notification, streamVideo, callId) + + val jetpackTelecomRepository = jetpackTelecomRepositoryProvider.get(callId) + + val appSchema = (streamVideo as StreamVideoClient).telecomConfig?.schema + val addressUri = "$appSchema:${callId.id}".toUri() + val formattedCallDisplayName = callDisplayName?.takeIf { it.isNotBlank() } ?: DEFAULT_CALL_TEXT + + val call = streamVideo.call(callId.type, callId.id) + + call.state.jetpackTelecomRepository = (jetpackTelecomRepository) + + call.scope.launch { + jetpackTelecomRepository.registerCall( + formattedCallDisplayName, + addressUri, + true, + isVideo, + ) + } + } + else -> {} + } + } + } + } + + fun showOnGoingCall(call: Call, trigger: String, streamVideo: StreamVideo) { + val client = streamVideo as StreamVideoClient + val callConfig = client.callServiceConfigRegistry.get(call.type) + if (!callConfig.runCallServiceInForeground) { + return + } + val callId = StreamCallId.fromCallCid(call.cid) + val context = client.context + val serviceIntent = ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam( + callId, + trigger, + callServiceConfiguration = callConfig, + ), + ) + ContextCompat.startForegroundService(context, serviceIntent) + } + + @SuppressLint("NewApi") + fun showOutgoingCall(call: Call, trigger: String, streamVideo: StreamVideo) { + val callConfig = (streamVideo as StreamVideoClient).callServiceConfigRegistry.get(call.type) + if (!callConfig.runCallServiceInForeground) { + return + } + val callId = StreamCallId.fromCallCid(call.cid) + val serviceIntent = ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam( + callId, + trigger, + callServiceConfiguration = callConfig, + ), + ) + + ContextCompat.startForegroundService(context, serviceIntent) + + /** + * TODO We don't have api to directly render text as display name. Need more research + */ + val callDisplayName = "NOT SET YET" + + val telecomPermissions = TelecomPermissions() + val telecomHelper = TelecomHelper() + if (telecomPermissions.canUseTelecom(callConfig, context)) { + if (telecomHelper.canUseJetpackTelecom()) { + val jetpackTelecomRepository = jetpackTelecomRepositoryProvider.get(callId) + + val appSchema = streamVideo.telecomConfig?.schema + val addressUri = "$appSchema:${callId.id}".toUri() + val formattedCallDisplayName = + callDisplayName?.takeIf { it.isNotBlank() } ?: DEFAULT_CALL_TEXT + + call.state.jetpackTelecomRepository = jetpackTelecomRepository + + call.scope.launch(Dispatchers.Default) { + launch { + jetpackTelecomRepository.registerCall( + formattedCallDisplayName, + addressUri, + false, + call.isVideoEnabled(), + ) + } + launch { + delay(2000L) + val result = (jetpackTelecomRepository.currentCall.value as? TelecomCall.Registered)?.processAction( + TelecomCallAction.Activate, + ) + logger.d { "Telecom is activated: $result" } + } + } + } + } + } + + /** + * Because we need to retrieve the notification + * in [io.getstream.video.android.core.notifications.internal.telecom.connection.SuccessIncomingTelecomConnection] + */ + private fun updateIncomingCallNotification( + notification: Notification?, + streamVideo: StreamVideo, + callId: StreamCallId, + ) { + notification?.let { + streamVideo.call(callId.type, callId.id) + .state.updateNotification(notification) + } + } + + fun removeIncomingCall( + context: Context, + callId: StreamCallId, + config: CallServiceConfig = DefaultCallConfigurations.default, + ) { + safeCallWithResult { + context.startService( + serviceIntentBuilder.buildStartIntent( + context, + StartServiceParam( + callId, + TRIGGER_REMOVE_INCOMING_CALL, + callServiceConfiguration = config, + ), + ), + )!! + }.onError { + NotificationManagerCompat.from(context) + .cancel(callId.getNotificationId(NotificationType.Incoming)) + } + } + + fun stopService(call: Call) { + stopCallServiceInternal(call) + } + + private fun stopCallServiceInternal(call: Call) { + val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient + streamVideo?.let { streamVideoClient -> + val callConfig = streamVideoClient.callServiceConfigRegistry.get(call.type) + if (callConfig.runCallServiceInForeground) { + val context = streamVideoClient.context + + val serviceIntent = serviceIntentBuilder.buildStopIntent( + context, + StopServiceParam(call, callConfig), + ) + logger.d { "Building stop intent for call_id: ${call.cid}" } + serviceIntent.extras?.let { + logBundle(it) + } + context.startService(serviceIntent) + } + } + } + + private fun logBundle(bundle: Bundle) { + val keys = bundle.keySet() + if (keys != null) { + val sb = StringBuilder() + for (key in keys) { + val itemInBundle = bundle[key] + val text = "key:$key, value=$itemInBundle" + sb.append(text) + sb.append("\n") + } + if (sb.toString().isNotEmpty()) { + logger.d { " [maybeStopForegroundService], stop intent extras: $sb" } + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt new file mode 100644 index 00000000000..3dcd0abc473 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2014-2024 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.content.Context +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.R +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationConfig +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_ONGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_OUTGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL +import io.getstream.video.android.model.StreamCallId + +internal class ServiceNotificationRetriever { + private val logger by taggedLogger("ServiceNotificationRetriever") + + open fun getNotificationPair( + context: Context, + trigger: String, + streamVideo: StreamVideoClient, + streamCallId: StreamCallId, + intentCallDisplayName: String?, + ): Pair { + logger.d { + "[getNotificationPair] trigger: $trigger, callId: ${streamCallId.id}, callDisplayName: $intentCallDisplayName" + } + val notificationData: Pair = when (trigger) { + TRIGGER_ONGOING_CALL -> { + logger.d { "[getNotificationPair] Creating ongoing call notification" } + Pair( + first = streamVideo.getOngoingCallNotification( + callId = streamCallId, + callDisplayName = intentCallDisplayName, + payload = emptyMap(), + ), + second = streamCallId.hashCode(), + ) + } + + TRIGGER_INCOMING_CALL -> { + logger.d { "[getNotificationPair] Creating incoming call notification" } + val shouldHaveContentIntent = streamVideo.state.activeCall.value == null + logger.d { "[getNotificationPair] shouldHaveContentIntent: $shouldHaveContentIntent" } + Pair( + first = streamVideo.getRingingCallNotification( + ringingState = RingingState.Incoming(), + callId = streamCallId, + callDisplayName = intentCallDisplayName, + shouldHaveContentIntent = shouldHaveContentIntent, + payload = emptyMap(), + ), + second = streamCallId.getNotificationId(NotificationType.Incoming), + ) + } + + TRIGGER_OUTGOING_CALL -> { + logger.d { "[getNotificationPair] Creating outgoing call notification" } + Pair( + first = streamVideo.getRingingCallNotification( + ringingState = RingingState.Outgoing(), + callId = streamCallId, + callDisplayName = context.getString( + R.string.stream_video_outgoing_call_notification_title, + ), + payload = emptyMap(), + ), + second = streamCallId.getNotificationId( + NotificationType.Incoming, // TODO Rahul, should we change it to outgoing? + ), // Same for incoming and outgoing + ) + } + + TRIGGER_REMOVE_INCOMING_CALL -> { + logger.d { "[getNotificationPair] Removing incoming call notification" } + Pair(null, streamCallId.getNotificationId(NotificationType.Incoming)) + } + + else -> { + logger.w { "[getNotificationPair] Unknown trigger: $trigger" } + Pair(null, streamCallId.hashCode()) + } + } + logger.d { + "[getNotificationPair] Generated notification: ${notificationData.first != null}, notificationId: ${notificationData.second}" + } + return notificationData + } + + fun notificationConfig(): NotificationConfig { + val client = StreamVideo.instanceOrNull() as StreamVideoClient + return client.streamNotificationManager.notificationConfig + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceParam.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceParam.kt new file mode 100644 index 00000000000..087dc7b374b --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceParam.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2014-2024 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 io.getstream.video.android.core.Call +import io.getstream.video.android.model.StreamCallId + +internal data class StartServiceParam( + val callId: StreamCallId, + val trigger: String, + val callDisplayName: String? = null, + val callServiceConfiguration: CallServiceConfig = DefaultCallConfigurations.default, +) + +internal data class StopServiceParam( + val call: Call? = null, + val callServiceConfiguration: CallServiceConfig = DefaultCallConfigurations.default, +) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/IncomingCallTelecomAction.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/IncomingCallTelecomAction.kt new file mode 100644 index 00000000000..c4818d30415 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/IncomingCallTelecomAction.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import io.getstream.video.android.core.ExternalCallRejectionHandler +import io.getstream.video.android.core.ExternalCallRejectionSource +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.IncomingNotificationAction +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.launch + +internal class IncomingCallTelecomAction(private val streamVideo: StreamVideo) { + + fun onAnswer(callId: StreamCallId) { + val pendingIntentMap = streamVideo.call(callId.type, callId.id) + .state.incomingNotificationData.pendingIntentMap + + pendingIntentMap[IncomingNotificationAction.Accept]?.send() + } + + fun onDisconnect(callId: StreamCallId) { + val call = streamVideo.call(callId.type, callId.id) + when (call.state.ringingState.value) { + is RingingState.Outgoing -> { + call.scope.launch { + val externalCallRejectionHandler = ExternalCallRejectionHandler() + externalCallRejectionHandler.onRejectCall( + ExternalCallRejectionSource.WEARABLE, + call, + streamVideo.context, + ) + } + } + + is RingingState.Active -> { + streamVideo.call(callId.type, callId.id).leave() + } + is RingingState.Incoming -> { + val pendingIntentMap = streamVideo.call(callId.type, callId.id) + .state.incomingNotificationData.pendingIntentMap + + pendingIntentMap[IncomingNotificationAction.Reject]?.send() + } + else -> {} + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomCallController.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomCallController.kt new file mode 100644 index 00000000000..7b85bd2ab16 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomCallController.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import android.content.Context +import android.telecom.DisconnectCause +import io.getstream.android.video.generated.models.OwnCapability +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.InteractionSource +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCallAction + +/** + * Valid disconnected cause: [DisconnectCause.LOCAL, DisconnectCause.REMOTE, DisconnectCause.MISSED, or DisconnectCause.REJECTED] + */ +class TelecomCallController(val context: Context) { + private val telecomPermissions = TelecomPermissions() + private val telecomHelper = TelecomHelper() + + fun leaveCall(call: Call) { + performAction(call) { + if (it is TelecomCall.Registered) { + it.processAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.LOCAL, + ), + InteractionSource.PHONE, + ), + ) + } + } + } + + fun onAnswer(call: Call) { + performAction(call) { + if (it is TelecomCall.Registered) { + it.processAction(TelecomCallAction.Answer(!isVideoCall(call))) + } + } + } + + private fun isVideoCall(call: Call): Boolean { + return call.hasCapability(OwnCapability.SendVideo) || call.isVideoEnabled() + } + + private fun performAction(call: Call, block: (TelecomCall) -> Unit) { + val callConfig = (call.client as StreamVideoClient).callServiceConfigRegistry.get(call.type) + if (telecomPermissions.canUseTelecom(callConfig, context)) { + if (telecomHelper.canUseJetpackTelecom()) { + val telecomCall = + call.state.jetpackTelecomRepository?.currentCall?.value + telecomCall?.let { + block(telecomCall) + } + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomConfig.kt new file mode 100644 index 00000000000..faad3a77244 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomConfig.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import io.getstream.video.android.core.internal.InternalStreamVideoApi + +data class TelecomConfig(val schema: String) + +@InternalStreamVideoApi +enum class TelecomIntegrationType { + JETPACK_TELECOM, +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomHelper.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomHelper.kt new file mode 100644 index 00000000000..bcc462a123c --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomHelper.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import android.os.Build +import io.getstream.video.android.core.StreamVideo + +internal class TelecomHelper { + + fun canUseJetpackTelecom(): Boolean { + val integrationTypeIsJetpack = (StreamVideo.instanceOrNull())?.state?.getTelecomIntegrationType() == TelecomIntegrationType.JETPACK_TELECOM + return integrationTypeIsJetpack && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissions.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissions.kt new file mode 100644 index 00000000000..563d6d79304 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissions.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.telecom.TelecomManager +import androidx.core.content.ContextCompat +import io.getstream.log.TaggedLogger +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig + +class TelecomPermissions { + + private val logger: TaggedLogger by taggedLogger("TelecomPermissions") + + private fun getRequiredPermissionsList(): List { + val permissions = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + with(permissions) { + add(android.Manifest.permission.MANAGE_OWN_CALLS) + } + } + return permissions + } + + private fun getRequiredPermissionsList(telecomIntegrationType: TelecomIntegrationType): List { + val permissions = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + with(permissions) { + add(android.Manifest.permission.MANAGE_OWN_CALLS) + } + } + return permissions + } + + private fun getRequiredPermissionsArray(): Array { + return getRequiredPermissionsList().toTypedArray() + } + + fun getRequiredPermissionsArray(telecomIntegrationType: TelecomIntegrationType): Array { + return getRequiredPermissionsList(telecomIntegrationType).toTypedArray() + } + + private fun hasPermissions(context: Context): Boolean { + return getRequiredPermissionsArray().all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } + + private fun optedForTelecom() = (StreamVideo.instanceOrNull() as? StreamVideoClient)?.telecomConfig != null + + fun canUseTelecom(callServiceConfig: CallServiceConfig, context: Context): Boolean { + return callServiceConfig.enableTelecom && optedForTelecom() && supportsTelecom(context) && hasPermissions(context) + } + + fun supportsTelecom(context: Context): Boolean { + val pm = context.packageManager + val hasTelephony = pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) + val hasDefaultDialer = getSafeTelecomManager(context)?.defaultDialerPackage?.isNotEmpty() == true + return hasTelephony && hasDefaultDialer + } + + private fun getSafeTelecomManager(context: Context): TelecomManager? { + try { + val telecomManager = context.getSystemService( + Context.TELECOM_SERVICE, + ) as? TelecomManager + return telecomManager + } catch (e: AssertionError) { + // Paparazzi/Robolectric throws AssertionError: Unsupported Service: telecom + return null + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt new file mode 100644 index 00000000000..183bd5a8e45 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2014-2024 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.telecom.jetpack + +import android.Manifest +import android.net.Uri +import android.os.Build +import android.telecom.DisconnectCause +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlResult +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallsManager +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.notifications.internal.telecom.IncomingCallTelecomAction +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class JetpackTelecomRepository( + private val callsManager: CallsManager, + private val callId: StreamCallId, + private val incomingCallTelecomAction: IncomingCallTelecomAction, +) { + private val logger by taggedLogger("JetpackTelecomRepository") + + // Keeps track of the current TelecomCall state + private val _currentCall: MutableStateFlow = MutableStateFlow(TelecomCall.None) + val currentCall = _currentCall.asStateFlow() + + /** + * Register a new call with the provided attributes. + * Use the [currentCall] StateFlow to receive status updates and process call related actions. + */ + @RequiresPermission(Manifest.permission.MANAGE_OWN_CALLS) + @RequiresApi(Build.VERSION_CODES.O) + suspend fun registerCall( + displayName: String, + address: Uri, + isIncoming: Boolean, + isVideoCall: Boolean, + ) { + logger.d { "[registerCall]" } + // For simplicity we don't support multiple calls + if (_currentCall.value is TelecomCall.Registered) { + logger.e { "[registerCall] There cannot be more than one call at the same time." } + return + } + + // Create the call attributes + val attributes = CallAttributesCompat( + displayName = displayName, + address = address, + direction = if (isIncoming) { + CallAttributesCompat.DIRECTION_INCOMING + } else { + CallAttributesCompat.DIRECTION_OUTGOING + }, + callType = if (isVideoCall) { + CallAttributesCompat.CALL_TYPE_VIDEO_CALL + } else { + CallAttributesCompat.CALL_TYPE_AUDIO_CALL + }, + callCapabilities = ( + CallAttributesCompat.SUPPORTS_SET_INACTIVE + or CallAttributesCompat.SUPPORTS_STREAM + or CallAttributesCompat.SUPPORTS_TRANSFER + ), + ) + + // Creates a channel to send actions to the call scope. + val actionSource = Channel() + // Register the call and handle actions in the scope + try { + logger.d { "[registerCall] addCall" } + callsManager.addCall( + attributes, + onIsCallAnswered, // Watch needs to know if it can answer the call + onIsCallDisconnected, + onIsCallActive, + onIsCallInactive, + ) { + // Consume the actions to interact with the call inside the scope + launch { + processCallActions(actionSource.consumeAsFlow()) + } + + // Update the state to registered with default values while waiting for Telecom updates + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + errorCode = null, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) + logger.d { "[registerCall] _currentCall set to Registered" } + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) + } + } + } + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) + } + } + } + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) + } + } + } + } + } catch (ex: Exception) { + logger.e(ex) { "[registerCall] exception: ${ex.message}" } + } finally { + logger.d { "[registerCall] finally" } + _currentCall.value = TelecomCall.None + } + } + + /** + * Collect the action source to handle client actions inside the call scope + */ + @RequiresApi(Build.VERSION_CODES.O) + private suspend fun CallControlScope.processCallActions(actionSource: Flow) { + actionSource.collect { action -> + logger.d { "[processCallActions]: action: $action" } + when (action) { + is TelecomCallAction.Answer -> { + doAnswer(action.isAudioCall) + } + + is TelecomCallAction.Disconnect -> { + doDisconnect(action) + } + + is TelecomCallAction.SwitchAudioEndpoint -> { + doSwitchEndpoint(action) + } + + is TelecomCallAction.TransferCall -> { + val call = _currentCall.value as? TelecomCall.Registered + val endpoints = call?.availableCallEndpoints?.firstOrNull { + it.identifier == action.endpointId + } + requestEndpointChange( + endpoint = endpoints ?: return@collect, + ) + } + + TelecomCallAction.Hold -> { + when (val result = setInactive()) { + is CallControlResult.Success -> { + onIsCallInactive() + } + + is CallControlResult.Error -> { + updateCurrentCall { + copy(errorCode = result.errorCode) + } + } + } + } + + TelecomCallAction.Activate -> { + when (val result = setActive()) { + is CallControlResult.Success -> { + logger.d { "[processCallActions] CallControlResult.Success" } + onIsCallActive() + } + + is CallControlResult.Error -> { + logger.d { "[processCallActions] CallControlResult.Error errorCode: ${result.errorCode}" } + updateCurrentCall { + copy(errorCode = result.errorCode) + } + } + } + } + + is TelecomCallAction.ToggleMute -> { + // We cannot programmatically mute the telecom stack. Instead we just update + // the state of the call and this will start/stop audio capturing. + updateCurrentCall { + copy(isMuted = !isMuted) + } + } + } + } + } + + /** + * Update the current state of our call applying the transform lambda only if the call is + * registered. Otherwise keep the current state + */ + private fun updateCurrentCall(transform: TelecomCall.Registered.() -> TelecomCall) { + _currentCall.update { call -> + if (call is TelecomCall.Registered) { + call.transform() + } else { + call + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private suspend fun CallControlScope.doSwitchEndpoint( + action: TelecomCallAction.SwitchAudioEndpoint, + ) { + logger.d { "[doSwitchEndpoint], action: $action" } + // TODO once availableCallEndpoints is a state flow we can just get the value + val endpoints = (_currentCall.value as TelecomCall.Registered).availableCallEndpoints + + // Switch to the given endpoint or fallback to the best possible one. + val newEndpoint = endpoints.firstOrNull { it.identifier == action.endpointId } + + if (newEndpoint != null) { + requestEndpointChange(newEndpoint).also { + logger.d { "[doSwitchEndpoint] Endpoint ${newEndpoint.name} changed: $it " } + } + } + } + + private suspend fun CallControlScope.doDisconnect(action: TelecomCallAction.Disconnect) { + logger.d { "[doDisconnect] action: $action " } + disconnect(action.cause) + updateCurrentCall { + TelecomCall.Unregistered(id, callAttributes, action.cause) + } + } + + private suspend fun CallControlScope.doAnswer(isAudioCall: Boolean) { + val callType = if (isAudioCall) { + CallAttributesCompat.CALL_TYPE_AUDIO_CALL + } else { + CallAttributesCompat.CALL_TYPE_VIDEO_CALL + } + val result = answer(callType) + + when (result) { + is CallControlResult.Success -> { + onIsCallAnswered(callType) + } + + is CallControlResult.Error -> { + updateCurrentCall { + TelecomCall.Unregistered( + id = id, + callAttributes = callAttributes, + disconnectCause = DisconnectCause(DisconnectCause.BUSY), + ) + } + } + } + } + + /** + * Can the call be successfully answered?? + * TIP: We would check the connection/call state to see if we can answer a call + * Example you may need to wait for another call to hold. + **/ + val onIsCallAnswered: suspend(type: Int) -> Unit = { + logger.d { "[onIsCallAnswered]" } + updateCurrentCall { + copy(isActive = true, isOnHold = false) + } + incomingCallTelecomAction.onAnswer(callId) + } + + /** + * Can the call perform a disconnect + */ + private val onIsCallDisconnected: suspend (cause: DisconnectCause) -> Unit = { cause -> + logger.d { "[onIsCallDisconnected] with cause $cause" } + updateCurrentCall { + TelecomCall.Unregistered(id, callAttributes, cause) + } + incomingCallTelecomAction.onDisconnect(callId) + } + + /** + * Check is see if we can make the call active. + * Other calls and state might stop us from activating the call + */ + val onIsCallActive: suspend () -> Unit = { + logger.d { "[onIsCallActive]" } + updateCurrentCall { + copy( + errorCode = null, + isActive = true, + isOnHold = false, + ) + } + } + + /** + * Check to see if we can make the call inactivate + */ + val onIsCallInactive: suspend () -> Unit = { + logger.d { "[onIsCallInactive]" } + updateCurrentCall { + copy( + errorCode = null, + isOnHold = true, + ) + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCall.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCall.kt new file mode 100644 index 00000000000..777c4ec1302 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCall.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014-2024 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.telecom.jetpack + +import android.os.ParcelUuid +import android.telecom.DisconnectCause +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallEndpointCompat +import kotlinx.coroutines.channels.Channel + +/** + * Custom representation of a call state. + */ +internal sealed class TelecomCall { + + /** + * There is no current or past calls in the stack + */ + object None : TelecomCall() + + /** + * Represents a registered call with the telecom stack with the values provided by the + * Telecom SDK + */ + data class Registered( + val id: ParcelUuid, + val callAttributes: CallAttributesCompat, + val isActive: Boolean, + val isOnHold: Boolean, + val isMuted: Boolean, + val errorCode: Int?, + val currentCallEndpoint: CallEndpointCompat?, + val availableCallEndpoints: List, + internal val actionSource: Channel, + ) : TelecomCall() { + + /** + * @return true if it's an incoming registered call, false otherwise + */ + fun isIncoming() = callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING + + /** + * Sends an action to the call session. It will be processed if it's still registered. + * + * @return true if the action was sent, false otherwise + */ + fun processAction(action: TelecomCallAction): Boolean { + return actionSource.trySend(action).isSuccess + } + } + + /** + * Represent a previously registered call that was disconnected + */ + data class Unregistered( + val id: ParcelUuid, + val callAttributes: CallAttributesCompat, + val disconnectCause: DisconnectCause, + ) : TelecomCall() +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction.kt new file mode 100644 index 00000000000..16c0b2d53fd --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/TelecomCallAction.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2014-2024 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.telecom.jetpack + +import android.os.ParcelUuid +import android.os.Parcelable +import android.telecom.DisconnectCause +import kotlinx.parcelize.Parcelize + +/** + * Simple interface to represent related call actions to communicate with the registered call scope + * in the [JetpackTelecomRepository.registerCall] + * + * Note: we are using [Parcelize] to make the actions parcelable so they can be directly used in the + * call notification. + */ +internal sealed interface TelecomCallAction : Parcelable { + @Parcelize + data class Answer(val isAudioCall: Boolean) : TelecomCallAction + + @Parcelize + data class Disconnect( + val cause: DisconnectCause, + val source: InteractionSource, + ) : TelecomCallAction + + @Parcelize + object Hold : TelecomCallAction + + @Parcelize + object Activate : TelecomCallAction + + @Parcelize + data class ToggleMute(val isMute: Boolean) : TelecomCallAction + + @Parcelize + data class SwitchAudioEndpoint(val endpointId: ParcelUuid) : TelecomCallAction + + @Parcelize + data class TransferCall(val endpointId: ParcelUuid) : TelecomCallAction +} + +internal enum class InteractionSource { + PHONE, WEARABLE +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/CallSoundAndVibrationPlayer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/CallSoundAndVibrationPlayer.kt index 3b3d89ea1ad..4a1d272f721 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/CallSoundAndVibrationPlayer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/CallSoundAndVibrationPlayer.kt @@ -76,7 +76,7 @@ internal class CallSoundAndVibrationPlayer(private val context: Context) { } } } catch (e: Exception) { - logger.d { "[playCallSound] Error playing call sound: ${e.message}" } + logger.e(e) { "[playCallSound] Error playing call sound: ${e.message}" } } } @@ -162,7 +162,7 @@ internal class CallSoundAndVibrationPlayer(private val context: Context) { if (mediaPlayer.isPlaying == true) mediaPlayer.stop() } } catch (e: Exception) { - logger.d { "[stopCallSound] Error stopping call sound: ${e.message}" } + logger.e(e) { "[stopCallSound] Error stopping call sound: ${e.message}" } } finally { abandonAudioFocus() } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiverTest.kt new file mode 100644 index 00000000000..c390e8ae315 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/receivers/RejectCallBroadcastReceiverTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2014-2024 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.receivers + +import android.content.Context +import android.content.Intent +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.ExternalCallRejectionHandler +import io.getstream.video.android.core.ExternalCallRejectionSource +import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_REJECT_CALL +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RejectCallBroadcastReceiverTest { + + private lateinit var context: Context + private lateinit var intent: Intent + private lateinit var call: Call + private lateinit var rejectionHandler: ExternalCallRejectionHandler + private lateinit var receiver: RejectCallBroadcastReceiver + + @Before + fun setup() { + mockkConstructor(ExternalCallRejectionHandler::class) + + context = mockk(relaxed = true) + intent = mockk(relaxed = true) + call = mockk(relaxed = true) + rejectionHandler = mockk(relaxed = true) + + // When a new ExternalCallRejectionHandler() is created, return our mock + coEvery { + anyConstructed().onRejectCall(any(), any(), any(), any()) + } just Runs + + receiver = RejectCallBroadcastReceiver() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `action constant should be ACTION_REJECT_CALL`() { + assertEquals(ACTION_REJECT_CALL, receiver.action) + } + + @Test + fun `onReceive should call ExternalCallRejectionHandler with NOTIFICATION source`() = runTest { + receiver.onReceive(call, context, intent) + + coVerify { + anyConstructed().onRejectCall( + ExternalCallRejectionSource.NOTIFICATION, + call, + context, + intent, + ) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt index 93a25aaa4c9..a2d646903b8 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt @@ -29,7 +29,6 @@ import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationHandler 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.NotificationType import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_KEY import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_ONGOING_CALL @@ -42,14 +41,11 @@ import io.mockk.MockKAnnotations import io.mockk.clearAllMocks import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -169,206 +165,6 @@ class CallServiceTest { assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE, audioService.serviceType) } - // Test intent building - @Test - fun `buildStartIntent creates correct intent for incoming call`() { - // When - val intent = CallService.buildStartIntent( - context = context, - callId = testCallId, - trigger = TRIGGER_INCOMING_CALL, - callDisplayName = "John Doe", - ) - - // Then - assertEquals(CallService::class.java.name, intent.component?.className) - assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) - assertEquals("John Doe", intent.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME)) - assertEquals(TRIGGER_INCOMING_CALL, intent.getStringExtra(TRIGGER_KEY)) - } - - @Test - fun `buildStartIntent creates correct intent for outgoing call`() { - // When - val intent = CallService.buildStartIntent( - context = context, - callId = testCallId, - trigger = TRIGGER_OUTGOING_CALL, - ) - - // Then - assertEquals(CallService::class.java.name, intent.component?.className) - assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) - assertEquals(TRIGGER_OUTGOING_CALL, intent.getStringExtra(TRIGGER_KEY)) - assertNull(intent.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME)) - } - - @Test - fun `buildStartIntent creates correct intent for ongoing call`() { - // When - val intent = CallService.buildStartIntent( - context = context, - callId = testCallId, - trigger = TRIGGER_ONGOING_CALL, - ) - - // Then - assertEquals(CallService::class.java.name, intent.component?.className) - assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) - assertEquals(TRIGGER_ONGOING_CALL, intent.getStringExtra(TRIGGER_KEY)) - } - - @Test - fun `buildStartIntent creates correct intent for remove incoming call`() { - // When - val intent = CallService.buildStartIntent( - context = context, - callId = testCallId, - trigger = TRIGGER_REMOVE_INCOMING_CALL, - ) - - // Then - assertEquals(CallService::class.java.name, intent.component?.className) - assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) - assertEquals(TRIGGER_REMOVE_INCOMING_CALL, intent.getStringExtra(TRIGGER_KEY)) - } - - @Test - fun `buildStartIntent throws exception for invalid trigger`() { - // When & Then - assertThrows(IllegalArgumentException::class.java) { - CallService.buildStartIntent( - context = context, - callId = testCallId, - trigger = "invalid_trigger", - ) - } - } - - @Test - fun `buildStartIntent uses custom service class from configuration`() { - // Given - val customConfig = CallServiceConfig( - serviceClass = LivestreamCallService::class.java, - ) - - // When - val intent = CallService.buildStartIntent( - context = context, - callId = testCallId, - trigger = TRIGGER_INCOMING_CALL, - callServiceConfiguration = customConfig, - ) - - // Then - assertEquals(LivestreamCallService::class.java.name, intent.component?.className) - } - - @Test - fun `buildStopIntent creates correct intent`() { - // When - val intent = CallService.buildStopIntent(context) - - // Then - assertNotNull(intent) - assertEquals(CallService::class.java.name, intent.component?.className) - } - - @Test - fun `buildStopIntent uses custom service class from configuration`() { - // Given - val customConfig = CallServiceConfig( - serviceClass = LivestreamCallService::class.java, - ) - - // When - val intent = CallService.buildStopIntent( - context = context, - callServiceConfiguration = customConfig, - ) - - // Then - assertNotNull(intent) - // Note: The actual implementation has some complex logic for running services - // so we just verify the intent is created - } - - // Test notification generation logic - @Test - fun `getNotificationPair returns correct data for ongoing call`() { - // Given - every { - mockStreamVideoClient.getOngoingCallNotification(any(), any(), payload = any()) - } returns mockNotification - - // When - val result = callService.getNotificationPair( - trigger = TRIGGER_ONGOING_CALL, - streamVideo = mockStreamVideoClient, - streamCallId = testCallId, - intentCallDisplayName = "John Doe", - ) - - // Then - assertEquals(mockNotification, result.first) - assertEquals(testCallId.hashCode(), result.second) - } - - @Test - fun `getNotificationPair returns correct data for incoming call`() { - // Given - val mockState = mockk() - every { mockStreamVideoClient.state } returns mockState - every { mockState.activeCall } returns mockk { - every { value } returns null - } - every { - mockStreamVideoClient.getRingingCallNotification(any(), any(), any(), any(), any()) - } returns mockNotification - - // When - val result = callService.getNotificationPair( - trigger = TRIGGER_INCOMING_CALL, - streamVideo = mockStreamVideoClient, - streamCallId = testCallId, - intentCallDisplayName = "John Doe", - ) - - // Then - assertEquals(mockNotification, result.first) - assertEquals(testCallId.getNotificationId(NotificationType.Incoming), result.second) - } - - @Test - fun `getNotificationPair returns null notification for remove incoming call`() { - // When - val result = callService.getNotificationPair( - trigger = TRIGGER_REMOVE_INCOMING_CALL, - streamVideo = mockStreamVideoClient, - streamCallId = testCallId, - intentCallDisplayName = null, - ) - - // Then - assertNull(result.first) - assertEquals(testCallId.getNotificationId(NotificationType.Incoming), result.second) - } - - @Test - fun `getNotificationPair returns null notification for unknown trigger`() { - // When - val result = callService.getNotificationPair( - trigger = "unknown_trigger", - streamVideo = mockStreamVideoClient, - streamCallId = testCallId, - intentCallDisplayName = null, - ) - - // Then - assertNull(result.first) - assertEquals(testCallId.hashCode(), result.second) - } - // Test intent extra handling @Test fun `intent extras are properly set and retrieved`() { @@ -401,46 +197,6 @@ class CallServiceTest { assertNull(intent.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME)) } - // Test service configuration integration - @Test - fun `service respects configuration for different call types`() { - // Given - val livestreamConfig = CallServiceConfig( - serviceClass = LivestreamCallService::class.java, - runCallServiceInForeground = true, - ) - - // When - val intent = CallService.buildStartIntent( - context = context, - callId = StreamCallId(type = "livestream", id = "test-123"), - trigger = TRIGGER_INCOMING_CALL, - callServiceConfiguration = livestreamConfig, - ) - - // Then - assertEquals(LivestreamCallService::class.java.name, intent.component?.className) - } - - // Test error handling - @Test - fun `service handles missing StreamVideo instance gracefully in notification generation`() { - // Given - Using a real CallService instance but with mocked dependencies - val service = CallService() - - // When - Call getNotificationPair with minimal valid parameters - val result = service.getNotificationPair( - trigger = TRIGGER_REMOVE_INCOMING_CALL, // This trigger doesn't need StreamVideo methods - streamVideo = mockStreamVideoClient, - streamCallId = testCallId, - intentCallDisplayName = null, - ) - - // Then - Should handle gracefully and return expected result - assertNull(result.first) // No notification for remove trigger - assertEquals(testCallId.getNotificationId(NotificationType.Incoming), result.second) - } - // Test constants consistency @Test fun `notification ID constants are consistent`() { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt new file mode 100644 index 00000000000..d031cd6acbb --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2014-2024 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.Manifest +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher +import io.getstream.video.android.model.StreamCallId +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class IncomingCallPresenterTest { + + private lateinit var context: Context + private lateinit var serviceIntentBuilder: ServiceIntentBuilder + private lateinit var presenter: IncomingCallPresenter + private lateinit var callServiceConfig: CallServiceConfig + private lateinit var callId: StreamCallId + private lateinit var notification: Notification + private lateinit var streamVideoClient: StreamVideoClient + + @Before + fun setup() { + context = mockk(relaxed = true) + serviceIntentBuilder = mockk(relaxed = true) + callServiceConfig = CallServiceConfig(enableTelecom = true) + callId = StreamCallId("default", "123") + notification = mockk(relaxed = true) + streamVideoClient = mockk(relaxed = true) + + mockkObject(StreamVideo) + mockkStatic(ContextCompat::class) + + every { StreamVideo.instanceOrNull() } returns streamVideoClient + every { StreamVideo.instance() } returns streamVideoClient + + presenter = IncomingCallPresenter(serviceIntentBuilder) + } + + @After + fun tearDown() { + unmockkAll() + } + + // region 1️⃣ Foreground service branch (no active call) + + @Test + fun `when no active call should start foreground service and return FG_SERVICE`() { + // Given no active call + every { StreamVideo.instanceOrNull()?.state?.activeCall?.value } returns null + every { + ContextCompat.startForegroundService(context, any()) + } returns mockk(relaxed = true) + + // When + val result = presenter.showIncomingCall( + context = context, + callId = callId, + callDisplayName = "Caller", + callServiceConfiguration = callServiceConfig, + notification = notification, + ) + + // Then + verify { ContextCompat.startForegroundService(context, any()) } + Assert.assertEquals(ShowIncomingCallResult.FG_SERVICE, result) + } + + // endregion + + // region 2️⃣ Normal service branch (active call exists) + + @Test + fun `when active call exists should start normal service and return SERVICE`() { + every { StreamVideo.instanceOrNull()?.state?.activeCall?.value } returns mockk(relaxed = true) + + val intent = mockk(relaxed = true) + every { serviceIntentBuilder.buildStartIntent(any(), any()) } returns intent + + val result = presenter.showIncomingCall( + context = context, + callId = callId, + callDisplayName = "TestCaller", + callServiceConfiguration = callServiceConfig, + notification = notification, + ) + + verify { context.startService(any()) } + Assert.assertEquals(ShowIncomingCallResult.SERVICE, result) + } + + // endregion + + // region 3️⃣ Error branch (service start fails → fallback to notification) + + @Test + fun `when service start fails and permission granted should show notification`() { + every { streamVideoClient.state.activeCall.value } returns null + + val notificationDispatcher = mockk(relaxed = true) + every { streamVideoClient.getStreamNotificationDispatcher() } returns notificationDispatcher + + // Force exception inside safeCallWithResult + every { + ContextCompat.startForegroundService(context, any()) + } throws RuntimeException("service fail") + + // Mock permission granted + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + } returns PackageManager.PERMISSION_GRANTED + + val result = presenter.showIncomingCall( + context, + callId, + "Caller", + callServiceConfig, + notification, + ) + + verify { + notificationDispatcher.notify( + callId, + callId.getNotificationId(NotificationType.Incoming), + notification, + ) + } + + Assert.assertEquals(ShowIncomingCallResult.ONLY_NOTIFICATION, result) + } + + // endregion + + // region 4️⃣ Error branch (service start fails, no permission) + + @Test + fun `when service start fails and no permission should return ERROR`() { + every { streamVideoClient.state.activeCall.value } returns null + + every { + ContextCompat.startForegroundService(context, any()) + } throws RuntimeException("fail") + + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + } returns PackageManager.PERMISSION_DENIED + + val result = presenter.showIncomingCall( + context, + callId, + "Caller", + callServiceConfig, + notification, + ) + + verify(exactly = 0) { + streamVideoClient.getStreamNotificationDispatcher().notify(any(), any(), any()) + } + + Assert.assertEquals(ShowIncomingCallResult.ERROR, result) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt new file mode 100644 index 00000000000..01633a882ed --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2014-2024 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.content.Context +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.service.CallService.Companion.TRIGGER_INCOMING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_KEY +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_ONGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_OUTGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL +import io.getstream.video.android.model.StreamCallId +import io.getstream.video.android.model.streamCallDisplayName +import io.getstream.video.android.model.streamCallId +import io.mockk.MockKAnnotations +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ServiceIntentBuilderTest { + + private lateinit var context: Context + private lateinit var callService: CallService + private lateinit var testCallId: StreamCallId + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + context = RuntimeEnvironment.getApplication() + callService = CallService() + testCallId = StreamCallId(type = "default", id = "test-call-123") + } + + @Test + fun `buildStartIntent creates correct intent for outgoing call`() { + // When + + val intent = ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam( + callId = testCallId, + trigger = TRIGGER_OUTGOING_CALL, + ), + ) + + // Then + assertEquals(CallService::class.java.name, intent.component?.className) + assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) + assertEquals(TRIGGER_OUTGOING_CALL, intent.getStringExtra(TRIGGER_KEY)) + assertNull(intent.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME)) + } + + @Test + fun `buildStartIntent creates correct intent for ongoing call`() { + // When + val intent = ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam( + callId = testCallId, + trigger = TRIGGER_ONGOING_CALL, + ), + ) + + // Then + assertEquals(CallService::class.java.name, intent.component?.className) + assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) + assertEquals(TRIGGER_ONGOING_CALL, intent.getStringExtra(TRIGGER_KEY)) + } + + @Test + fun `buildStartIntent creates correct intent for remove incoming call`() { + // When + val intent = ServiceIntentBuilder().buildStartIntent( + context = context, + StartServiceParam(testCallId, TRIGGER_REMOVE_INCOMING_CALL), + ) + + // Then + assertEquals(CallService::class.java.name, intent.component?.className) + assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) + assertEquals(TRIGGER_REMOVE_INCOMING_CALL, intent.getStringExtra(TRIGGER_KEY)) + } + + @Test + fun `buildStartIntent throws exception for invalid trigger`() { + // When & Then + assertThrows(IllegalArgumentException::class.java) { + ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam(testCallId, "invalid_trigger"), + ) + } + } + + @Test + fun `buildStartIntent uses custom service class from configuration`() { + // Given + val customConfig = CallServiceConfig( + serviceClass = LivestreamCallService::class.java, + ) + + // When + val intent = ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam( + callId = testCallId, + trigger = TRIGGER_INCOMING_CALL, + callServiceConfiguration = customConfig, + ), + ) + + // Then + assertEquals(LivestreamCallService::class.java.name, intent.component?.className) + } + + @Test + fun `buildStopIntent creates correct intent`() { + // When + val intent = ServiceIntentBuilder().buildStopIntent(context, StopServiceParam()) + + // Then + assertNotNull(intent) + assertEquals(CallService::class.java.name, intent.component?.className) + } + +// + @Test + fun `buildStopIntent uses custom service class from configuration`() { + // Given + val customConfig = CallServiceConfig( + serviceClass = LivestreamCallService::class.java, + ) + + // When + val intent = ServiceIntentBuilder().buildStopIntent( + context, + StopServiceParam(callServiceConfiguration = customConfig), + ) + + // Then + assertNotNull(intent) + // Note: The actual implementation has some complex logic for running services + // so we just verify the intent is created + } + + @Test + fun `service respects configuration for different call types`() { + // Given + val livestreamConfig = CallServiceConfig( + serviceClass = LivestreamCallService::class.java, + runCallServiceInForeground = true, + ) + + // When + val intent = ServiceIntentBuilder().buildStartIntent( + context, + StartServiceParam( + callId = StreamCallId(type = "livestream", id = "test-123"), + trigger = TRIGGER_INCOMING_CALL, + callServiceConfiguration = livestreamConfig, + ), + ) + + // Then + assertEquals(LivestreamCallService::class.java.name, intent.component?.className) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt new file mode 100644 index 00000000000..3f3dd9c2b11 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2014-2024 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.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.telecom.TelecomManager +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper +import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.model.StreamCallId +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test + +/** + * Focus on verifying key behaviors: + * Whether Telecom integration starts under correct conditions. + * Whether it’s skipped when conditions fail. + * Whether service launchers are called correctly for showIncomingCall() and showOutgoingCall(). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class ServiceLauncherTest { + private lateinit var context: Context + private lateinit var telecomPermissions: TelecomPermissions + private lateinit var telecomHelper: TelecomHelper + private lateinit var incomingCallPresenter: IncomingCallPresenter + private lateinit var streamVideo: StreamVideoClient + private lateinit var serviceLauncher: ServiceLauncher + private lateinit var notification: Notification + private lateinit var callServiceConfig: CallServiceConfig + private lateinit var callId: StreamCallId + private lateinit var jetpackTelecomRepositoryProvider: JetpackTelecomRepositoryProvider + private lateinit var jetpackTelecomRepository: JetpackTelecomRepository + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + context = mockk(relaxed = true) + telecomPermissions = mockk(relaxed = true) + telecomHelper = mockk(relaxed = true) + incomingCallPresenter = mockk(relaxed = true) + streamVideo = mockk(relaxed = true) + notification = mockk(relaxed = true) + callServiceConfig = CallServiceConfig(enableTelecom = true) + callId = StreamCallId("default", "123") + jetpackTelecomRepositoryProvider = mockk(relaxed = true) + jetpackTelecomRepository = mockk(relaxed = true) + + mockkStatic(ContextCompat::class) + mockkObject(StreamVideo) + mockkConstructor(JetpackTelecomRepository::class) + mockkConstructor(JetpackTelecomRepositoryProvider::class) + mockkConstructor(IncomingCallPresenter::class) + mockkConstructor(TelecomPermissions::class) + mockkConstructor(TelecomHelper::class) + + every { + ContextCompat.checkSelfPermission( + context, + any(), + ) + } returns PackageManager.PERMISSION_GRANTED + every { anyConstructed().canUseTelecom(any(), any()) } returns true + every { anyConstructed().canUseJetpackTelecom() } returns true + every { + anyConstructed().showIncomingCall( + any(), + any(), + any(), + any(), + any(), + ) + } returns ShowIncomingCallResult.FG_SERVICE + every { + anyConstructed().get(any()) + } returns jetpackTelecomRepository + + every { StreamVideo.instanceOrNull() } returns streamVideo + every { StreamVideo.instance() } returns streamVideo + every { jetpackTelecomRepositoryProvider.get(any()) } returns jetpackTelecomRepository + + serviceLauncher = ServiceLauncher(context) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + // region showIncomingCall() + + @Test + fun `showIncomingCall starts telecom registration when all conditions pass`() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val testScope = TestScope(testDispatcher) + + val call = mockk(relaxed = true) + every { streamVideo.call(any(), any()) } returns call + every { call.state } returns mockk(relaxed = true) + every { call.scope } returns testScope + + mockkStatic(ContextCompat::class) + every { + ContextCompat.getSystemService( + context, + TelecomManager::class.java, + ) + } returns mockk() + + serviceLauncher.showIncomingCall( + context = context, + callId = callId, + callDisplayName = "Test Caller", + callServiceConfiguration = callServiceConfig, + isVideo = true, + payload = emptyMap(), + streamVideo = streamVideo, + notification = notification, + ) + testScheduler.advanceUntilIdle() + + coVerify { jetpackTelecomRepository.registerCall(any(), any(), true, any()) } + } + + @Test + fun `showIncomingCall skips telecom when permissions fail`() = runTest { + every { anyConstructed().canUseTelecom(any(), any()) } returns false + + serviceLauncher.showIncomingCall( + context, + callId, + "Test Caller", + callServiceConfig, + isVideo = false, + payload = emptyMap(), + streamVideo = streamVideo, + notification = notification, + ) + + coVerify(exactly = 0) { jetpackTelecomRepository.registerCall(any(), any(), any(), any()) } + } + + // +// // endregion +// +// // region showOutgoingCall() +// + @Test + fun `showOutgoingCall launches foreground service and registers telecom`() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val testScope = TestScope(testDispatcher) + + val call = mockk(relaxed = true) + every { streamVideo.call(any(), any()) } returns call + every { call.state } returns mockk(relaxed = true) + every { call.scope } returns testScope + + every { streamVideo.callServiceConfigRegistry.get(any()) } returns callServiceConfig + every { call.cid } returns "default:cid-123" + every { call.isVideoEnabled() } returns true + + serviceLauncher.showOutgoingCall(call, "outgoing_call", streamVideo) + + verify { ContextCompat.startForegroundService(context, any()) } + + testScheduler.advanceUntilIdle() + + coVerify { + jetpackTelecomRepository.registerCall( + any(), + any(), + false, + true, + ) + } + } + + // + @Test + fun `showOutgoingCall skips telecom if permissions fail`() = runTest { + val call = mockk(relaxed = true) + every { streamVideo.callServiceConfigRegistry.get(any()) } returns callServiceConfig + every { call.cid } returns "default:cid-123" + every { call.isVideoEnabled() } returns true + every { anyConstructed().canUseTelecom(any(), any()) } returns false + + serviceLauncher.showOutgoingCall(call, "outgoing_call", streamVideo) + + coVerify(exactly = 0) { jetpackTelecomRepository.registerCall(any(), any(), any(), any()) } + } + + // endregion +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt new file mode 100644 index 00000000000..9ff3c3957ff --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2014-2024 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.content.Context +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_ONGOING_CALL +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL +import io.getstream.video.android.model.StreamCallId +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ServiceNotificationRetrieverTest { + + @MockK + private lateinit var mockStreamVideoClient: StreamVideoClient + + @MockK + lateinit var mockNotification: Notification + + private lateinit var context: Context + private lateinit var serviceNotificationRetriever: ServiceNotificationRetriever + private lateinit var testCallId: StreamCallId + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + context = RuntimeEnvironment.getApplication() +// callService = CallService() + serviceNotificationRetriever = ServiceNotificationRetriever() + testCallId = StreamCallId(type = "default", id = "test-call-123") + } + + // Test notification generation logic + @Test + fun `getNotificationPair returns correct data for ongoing call`() { + // Given + every { + mockStreamVideoClient.getOngoingCallNotification(any(), any(), payload = any()) + } returns mockNotification + + // When + val result = serviceNotificationRetriever.getNotificationPair( + context = context, + trigger = TRIGGER_ONGOING_CALL, + streamVideo = mockStreamVideoClient, + streamCallId = testCallId, + intentCallDisplayName = "John Doe", + ) + + // Then + assertEquals(mockNotification, result.first) + assertEquals(testCallId.hashCode(), result.second) + } + + @Test + fun `getNotificationPair returns correct data for incoming call`() { + // Given + val mockState = mockk() + every { mockStreamVideoClient.state } returns mockState + every { mockState.activeCall } returns mockk { + every { value } returns null + } + every { + mockStreamVideoClient.getRingingCallNotification(any(), any(), any(), any(), any()) + } returns mockNotification + + // When + val result = serviceNotificationRetriever.getNotificationPair( + context = context, + trigger = TRIGGER_INCOMING_CALL, + streamVideo = mockStreamVideoClient, + streamCallId = testCallId, + intentCallDisplayName = "John Doe", + ) + + // Then + assertEquals(mockNotification, result.first) + assertEquals(testCallId.getNotificationId(NotificationType.Incoming), result.second) + } + + @Test + fun `getNotificationPair returns null notification for remove incoming call`() { + // When + val result = serviceNotificationRetriever.getNotificationPair( + context = context, + trigger = TRIGGER_REMOVE_INCOMING_CALL, + streamVideo = mockStreamVideoClient, + streamCallId = testCallId, + intentCallDisplayName = null, + ) + + // Then + assertNull(result.first) + assertEquals(testCallId.getNotificationId(NotificationType.Incoming), result.second) + } + + @Test + fun `getNotificationPair returns null notification for unknown trigger`() { + // When + val result = serviceNotificationRetriever.getNotificationPair( + context = context, + trigger = "unknown_trigger", + streamVideo = mockStreamVideoClient, + streamCallId = testCallId, + intentCallDisplayName = null, + ) + + // Then + assertNull(result.first) + assertEquals(testCallId.hashCode(), result.second) + } + + @Test + fun `service handles missing StreamVideo instance gracefully in notification generation`() { + // Given - Using a real CallService instance but with mocked dependencies + + // When - Call getNotificationPair with minimal valid parameters + val result = serviceNotificationRetriever.getNotificationPair( + context, + trigger = TRIGGER_REMOVE_INCOMING_CALL, // This trigger doesn't need StreamVideo methods + streamVideo = mockStreamVideoClient, + streamCallId = testCallId, + intentCallDisplayName = null, + ) + + // Then - Should handle gracefully and return expected result + assertNull(result.first) // No notification for remove trigger + assertEquals(testCallId.getNotificationId(NotificationType.Incoming), result.second) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomCallControllerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomCallControllerTest.kt new file mode 100644 index 00000000000..b55320159ad --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomCallControllerTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import android.content.Context +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow + +// @RunWith(RobolectricTestRunner::class) +class TelecomCallControllerTest { + private lateinit var context: Context + private lateinit var call: Call + private lateinit var telecomCall: TelecomCall.Registered + private lateinit var repository: JetpackTelecomRepository + private lateinit var callState: CallState + private lateinit var telecomPermissions: TelecomPermissions + private lateinit var telecomHelper: TelecomHelper + private lateinit var controller: TelecomCallController + +// @Before + fun setup() { + mockkConstructor(TelecomPermissions::class) + mockkConstructor(TelecomHelper::class) + + context = mockk(relaxed = true) + call = mockk(relaxed = true) + telecomCall = mockk(relaxed = true) + repository = mockk(relaxed = true) + callState = mockk(relaxed = true) + telecomPermissions = mockk(relaxed = true) + telecomHelper = mockk(relaxed = true) + + // Construct the system under test + controller = TelecomCallController(context) + + // Replace internal helper behaviors + every { anyConstructed().canUseTelecom(any(), context) } returns true +// every { anyConstructed() } returns telecomPermissions + every { anyConstructed().canUseJetpackTelecom() } returns true + + // Setup repository and call + every { repository.currentCall } returns MutableStateFlow(telecomCall) + every { call.state } returns callState + every { callState.jetpackTelecomRepository } returns repository + + // Setup fake client + config + val client = mockk(relaxed = true) + every { call.client } returns client + val configRegistry = mockk(relaxed = true) + every { client.callServiceConfigRegistry } returns configRegistry + every { configRegistry.get(any()) } returns CallServiceConfig(enableTelecom = true) + } + +// @After + fun tearDown() { + unmockkAll() + } + + // region leaveCall() + +// @Test + fun `leaveCall should call processAction with Disconnect`() { + every { anyConstructed().canUseTelecom(any(), any()) } returns true + + controller.leaveCall(call) + + verify { + telecomCall.processAction(any()) + } + +// verify { +// telecomCall.processAction( +// match { +// it is TelecomCallAction.Disconnect && +// it.cause.code == DisconnectCause.LOCAL && +// it.source == InteractionSource.PHONE +// } +// ) +// } + } + +// @Test + fun `leaveCall should not call processAction if telecom unavailable`() { + every { anyConstructed().canUseTelecom(any(), any()) } returns false + + controller.leaveCall(call) + + verify(exactly = 0) { telecomCall.processAction(any()) } + } + + // endregion + + // region onAnswer() + +// @Test +// fun `onAnswer should call processAction with Answer video=false when call is video`() { +// every { call.hasCapability(OwnCapability.SendVideo) } returns true +// +// controller.onAnswer(call) +// +// verify { +// telecomCall.processAction( +// match { +// it is TelecomCallAction.Answer && it.isAudioOnly == false +// } +// ) +// } +// } +// +// @Test +// fun `onAnswer should call processAction with Answer video=true when call is audio only`() { +// every { call.hasCapability(OwnCapability.SendVideo) } returns false +// every { call.isVideoEnabled() } returns false +// +// controller.onAnswer(call) +// +// verify { +// telecomCall.processAction( +// match { +// it is TelecomCallAction.Answer && it.isAudioOnly +// } +// ) +// } +// } + +// @Test + fun `onAnswer should not call processAction if telecom not allowed`() { + every { anyConstructed().canUseTelecom(any(), any()) } returns false + + controller.onAnswer(call) + + verify(exactly = 0) { telecomCall.processAction(any()) } + } + + // endregion +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt new file mode 100644 index 00000000000..9f65fd17318 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2014-2024 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.telecom + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.telecom.TelecomManager +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class TelecomPermissionsTest { + + private lateinit var context: Context + private lateinit var packageManager: PackageManager + private lateinit var telecomManager: TelecomManager + private lateinit var callServiceConfig: CallServiceConfig + private lateinit var telecomPermissions: TelecomPermissions + + @Before + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + mockkStatic(ContextCompat::class) +// mockkStatic(StreamVideo::class) + mockkObject(StreamVideo) + + context = mockk(relaxed = true) +// context = RuntimeEnvironment.getApplication() +// callService = CallService() +// testCallId = StreamCallId(type = "default", id = "test-call-123") + + packageManager = mockk(relaxed = true) + telecomManager = mockk(relaxed = true) + callServiceConfig = CallServiceConfig(enableTelecom = true) + + every { context.packageManager } returns packageManager + every { context.getSystemService(Context.TELECOM_SERVICE) } returns telecomManager + + telecomPermissions = TelecomPermissions() + } + + @After + fun tearDown() { + unmockkObject(StreamVideo) + unmockkStatic(ContextCompat::class) + unmockkStatic(StreamVideo::class) + clearAllMocks() + } + + @Test + fun `supportsTelecom returns true when device has telephony and default dialer`() { + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns true + every { telecomManager.defaultDialerPackage } returns "com.android.dialer" + + val result = telecomPermissions.supportsTelecom(context) + + assertTrue(result) + } + + @Test + fun `supportsTelecom returns false when no telephony feature`() { + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns false + every { telecomManager.defaultDialerPackage } returns "com.android.dialer" + + val result = telecomPermissions.supportsTelecom(context) + + assertFalse(result) + } + + @Test + fun `supportsTelecom returns false when no default dialer`() { + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns true + every { telecomManager.defaultDialerPackage } returns null + + val result = telecomPermissions.supportsTelecom(context) + + assertFalse(result) + } +// +// // endregion +// +// // region canUseTelecom() + + @Test + fun `canUseTelecom returns true when all conditions pass`() { + val client = mockk { + every { telecomConfig } returns mockk() + } + every { StreamVideo.instanceOrNull() } returns client + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns true + every { telecomManager.defaultDialerPackage } returns "com.android.dialer" + + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.MANAGE_OWN_CALLS) + } returns PackageManager.PERMISSION_GRANTED + + val result = telecomPermissions.canUseTelecom(callServiceConfig, context) + + assertTrue(result) + } + + @Test + fun `canUseTelecom returns false when telecom disabled in config`() { + val result = telecomPermissions.canUseTelecom( + callServiceConfig.copy(enableTelecom = false), + context, + ) + assertFalse(result) + } + + @Test + fun `canUseTelecom returns false when StreamVideo client not opted for telecom`() { + every { StreamVideo.instanceOrNull() } returns null + val result = telecomPermissions.canUseTelecom(callServiceConfig, context) + assertFalse(result) + } + + @Test + fun `canUseTelecom returns false when missing permission`() { + val client = mockk { every { telecomConfig } returns mockk() } + every { StreamVideo.instanceOrNull() } returns client + + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.MANAGE_OWN_CALLS) + } returns PackageManager.PERMISSION_DENIED + + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns true + every { telecomManager.defaultDialerPackage } returns "com.android.dialer" + + val result = telecomPermissions.canUseTelecom(callServiceConfig, context) + + assertFalse(result) + } + + @Test + fun `canUseTelecom returns false when device doesn't support telecom`() { + val client = mockk { every { telecomConfig } returns mockk() } + every { StreamVideo.instanceOrNull() } returns client + + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns false + every { telecomManager.defaultDialerPackage } returns "com.android.dialer" + + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.MANAGE_OWN_CALLS) + } returns PackageManager.PERMISSION_GRANTED + + val result = telecomPermissions.canUseTelecom(callServiceConfig, context) + + assertFalse(result) + } +} diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/CallPermissions.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/CallPermissions.kt index c5854c728c3..be2a7d91bc5 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/CallPermissions.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/CallPermissions.kt @@ -21,11 +21,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import io.getstream.video.android.core.Call +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions /** * Remember call related Android permissions below: @@ -39,18 +42,7 @@ import io.getstream.video.android.core.Call @Composable public fun rememberCallPermissionsState( call: Call, - permissions: List = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - mutableListOf( - android.Manifest.permission.CAMERA, - android.Manifest.permission.RECORD_AUDIO, - android.Manifest.permission.BLUETOOTH_CONNECT, - ) - } else { - mutableListOf( - android.Manifest.permission.CAMERA, - android.Manifest.permission.RECORD_AUDIO, - ) - }, + permissions: List = getPermissions(), onPermissionsResult: ((Map) -> Unit)? = null, onAllPermissionsGranted: (suspend () -> Unit)? = null, ): VideoPermissionsState { @@ -93,6 +85,38 @@ public fun rememberCallPermissionsState( } } +@Composable +private fun getPermissions(): List { + val context = LocalContext.current + val permissionsList = mutableListOf() + val telecomPermissions = TelecomPermissions() + + if (telecomPermissions.supportsTelecom(context)) { + val telecomIntegrationType = StreamVideo.instanceOrNull()?.state?.getTelecomIntegrationType() + telecomIntegrationType?.let { + permissionsList.addAll( + telecomPermissions.getRequiredPermissionsArray(telecomIntegrationType), + ) + } + } + + permissionsList.addAll( + mutableListOf( + android.Manifest.permission.CAMERA, + android.Manifest.permission.RECORD_AUDIO, + ), + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissionsList.addAll( + mutableListOf( + android.Manifest.permission.BLUETOOTH_CONNECT, + ), + ) + } + return permissionsList +} + /** * Lunch call permissions about: * diff --git a/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt b/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt index f7c34ee3ce4..1f0e8a3e335 100644 --- a/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt +++ b/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt @@ -59,6 +59,7 @@ import io.getstream.video.android.core.events.CallEndedSfuEvent import io.getstream.video.android.core.events.ParticipantLeftEvent import io.getstream.video.android.core.model.RejectReason import io.getstream.video.android.core.notifications.NotificationHandler +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.streamCallId import io.getstream.video.android.ui.common.models.StreamCallActivityException @@ -844,11 +845,15 @@ public abstract class StreamCallActivity : ComponentActivity(), ActivityCallOper logger.d { "[accept] #ringing; call.cid: ${call.cid}" } acceptOrJoinNewCall(call, onSuccess, onError) { val result = call.acceptThenJoin() - result.onError { error -> - lifecycleScope.launch { - onError?.invoke(Exception(error.message)) + .onSuccess { + TelecomCallController(applicationContext) + .onAnswer(call) + } + .onError { error -> + lifecycleScope.launch { + onError?.invoke(Exception(error.message)) + } } - } result } }