diff --git a/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt b/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt index 820a0d3845..7d2bbfd85c 100644 --- a/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt +++ b/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt @@ -6,11 +6,11 @@ object Configuration { const val minSdk = 24 const val majorVersion = 1 const val minorVersion = 0 - const val patchVersion = 12 + const val patchVersion = 13 const val versionName = "$majorVersion.$minorVersion.$patchVersion" - const val versionCode = 36 + const val versionCode = 37 const val snapshotVersionName = "$majorVersion.$minorVersion.${patchVersion + 1}-SNAPSHOT" const val artifactGroup = "io.getstream" - const val streamVideoCallGooglePlayVersion = "1.1.5" + const val streamVideoCallGooglePlayVersion = "1.1.6" const val streamWebRtcVersionName = "1.1.1" } diff --git a/docusaurus/docs/Android/04-ui-components/06-ui-previews.mdx b/docusaurus/docs/Android/04-ui-components/06-ui-previews.mdx index 9334d4dac0..257a2711cb 100644 --- a/docusaurus/docs/Android/04-ui-components/06-ui-previews.mdx +++ b/docusaurus/docs/Android/04-ui-components/06-ui-previews.mdx @@ -33,11 +33,10 @@ Now, you can implement your preview composable like the example below: @Preview @Composable private fun CallContentPreview() { - StreamMockUtils.initializeStreamVideo(LocalContext.current) + StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) VideoTheme { CallContent( - modifier = Modifier.background(color = VideoTheme.colors.appBackground), - call = mockCall, + call = previewCall, ) } } @@ -49,17 +48,17 @@ After adding the above example to your project, you'll see the following preview You should follow the steps below to make your previews work well: -1. Initialize a mock `StreamVideo` with the following method: `StreamMockUtils.initializeStreamVideo`. +1. Initialize a mock `StreamVideo` with the following method: `StreamPreviewDataUtils.initializeStreamVideo`. 2. Wrap your composable with the `VideoTheme`. 3. Use the provided mock instances for Stream Video UI components. This library provides the following mocks: -- **mockCall**: Mock a `Call` that contains few of mock users. -- **mockParticipant**: Mock a `ParticipantState` instance. -- **mockParticipantList**: Mock a list of `ParticipantState` instances. -- **mockUsers**: Mock a list of `User` instances. -- **mockVideoMediaTrack**: Mock a new `MediaTrack` instance. +- **previewCall**: Mock a `Call` that contains few of mock users. +- **previewParticipant**: Mock a `ParticipantState` instance. +- **previewParticipantsList**: Mock a list of `ParticipantState` instances. +- **previewUsers**: Mock a list of `User` instances. +- **previewVideoMediaTrack**: Mock a new `MediaTrack` instance. For example, you can build a preview Composable for `ParticipantVideo` as in the example below: @@ -67,11 +66,11 @@ For example, you can build a preview Composable for `ParticipantVideo` as in the @Preview @Composable private fun ParticipantVideoPreview() { - StreamMockUtils.initializeStreamVideo(LocalContext.current) + StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) VideoTheme { ParticipantVideoRenderer( - call = mockCall, - participant = mockParticipant, + call = previewCall, + participant = previewParticipant, ) } } diff --git a/docusaurus/docs/Android/04-ui-components/07-ui-testing.mdx b/docusaurus/docs/Android/04-ui-components/07-ui-testing.mdx index 1f141e26f8..6abe8f6c8d 100644 --- a/docusaurus/docs/Android/04-ui-components/07-ui-testing.mdx +++ b/docusaurus/docs/Android/04-ui-components/07-ui-testing.mdx @@ -35,7 +35,7 @@ class ScreenTests { composable: @Composable () -> Unit ) { paparazzi.snapshot(name = name) { - StreamMockUtils.initializeStreamVideo(LocalContext.current) + StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) CompositionLocalProvider( LocalInspectionMode provides true, LocalAvatarPreviewPlaceholder provides @@ -49,7 +49,7 @@ class ScreenTests { @Test fun `snapshot CallContent component`() { snapshot(name = "CallContent") { - CallContent(call = mockCall) + CallContent(call = previewCall) } } @@ -58,7 +58,7 @@ class ScreenTests { snapshot(name = "CallLobby") { CallLobby( modifier = Modifier.fillMaxWidth(), - call = mockCall + call = previewCall ) } } @@ -70,7 +70,7 @@ Let's break the code down line by line. First, you should initialize Stream Video SDK with the `initializeStreamVideo()` method. You can learn more about our mock library on [UI Previews](07-ui-previews.mdx). ```kotlin -StreamMockUtils.initializeStreamVideo(LocalContext.current) +StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) ``` Next, you should enable `LocalInspectionMode` with the `CompositionLocalProvider` and allow Stream UI components to be rendered for the test environment. @@ -90,7 +90,7 @@ Finally, snapshot Stream Video components or your own Composable functions that @Test fun `snapshot CallContent component`() { snapshot(name = "CallContent") { - CallContent(call = mockCall) + CallContent(call = previewCall) } } ``` diff --git a/docusaurus/docs/Android/06-advanced/02-push-notifications/01-overview.mdx b/docusaurus/docs/Android/06-advanced/02-push-notifications/01-overview.mdx index 75f1aba090..0f72fe17e2 100644 --- a/docusaurus/docs/Android/06-advanced/02-push-notifications/01-overview.mdx +++ b/docusaurus/docs/Android/06-advanced/02-push-notifications/01-overview.mdx @@ -5,6 +5,11 @@ title: Overview Push notifications can be configured to receive updates when the application is closed or on the background, or even app is in a different contextual screen. Stream Video Server sends push notification for Ringing calls and Live calls that are about to start to users that have at least one registered device. +Push notifications are sent in the following scenarios: +- you create a call with the `ring` value set to true. In this case, a notification that shows a ringing screen is sent. +- you create a call with the `notify` value set to true. In this case, a regular push notification is sent. +- you haven't answered a call. In this case, a missed call notification is sent (regular push notification). + To receive push notifications from Stream Video Server, you'll need to: 1. Configure your push notification provider on the [Stream Dashboard](https://dashboard.getstream.io/). 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 b6de11540f..1d6bde92f1 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -127,6 +127,7 @@ public final class io/getstream/video/android/core/CallState { public final fun getErrors ()Lkotlinx/coroutines/flow/StateFlow; public final fun getIngress ()Lkotlinx/coroutines/flow/StateFlow; public final fun getLive ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getLiveDuration ()Lkotlinx/coroutines/flow/StateFlow; public final fun getLiveDurationInMs ()Lkotlinx/coroutines/flow/StateFlow; public final fun getLivestream ()Lkotlinx/coroutines/flow/StateFlow; public final fun getLocalParticipant ()Lkotlinx/coroutines/flow/StateFlow; @@ -816,12 +817,13 @@ 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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;Z)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Z)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;I)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/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;IILkotlin/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/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;)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/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;Ljava/lang/String;)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/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;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;)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/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;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Z)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/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;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;)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/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;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;I)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/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;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun build ()Lio/getstream/video/android/core/StreamVideo; } @@ -4138,6 +4140,7 @@ public class io/getstream/video/android/core/notifications/DefaultNotificationHa protected final fun getNotificationManager ()Landroidx/core/app/NotificationManagerCompat; public fun getOngoingCallNotification (Ljava/lang/String;Lio/getstream/video/android/model/StreamCallId;)Landroid/app/Notification; public fun getRingingCallNotification (Lio/getstream/video/android/core/RingingState;Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;Z)Landroid/app/Notification; + public fun getSettingUpCallNotification ()Landroid/app/Notification; public fun onLiveCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public fun onMissedCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public fun onNotification (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V @@ -4188,6 +4191,7 @@ public abstract interface class io/getstream/video/android/core/notifications/No public abstract fun getOngoingCallNotification (Ljava/lang/String;Lio/getstream/video/android/model/StreamCallId;)Landroid/app/Notification; public abstract fun getRingingCallNotification (Lio/getstream/video/android/core/RingingState;Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;Z)Landroid/app/Notification; public static synthetic fun getRingingCallNotification$default (Lio/getstream/video/android/core/notifications/NotificationHandler;Lio/getstream/video/android/core/RingingState;Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;ZILjava/lang/Object;)Landroid/app/Notification; + public abstract fun getSettingUpCallNotification ()Landroid/app/Notification; public abstract fun onLiveCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public abstract fun onMissedCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V public abstract fun onNotification (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V @@ -4215,6 +4219,29 @@ public final class io/getstream/video/android/core/notifications/internal/receiv public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V } +public final class io/getstream/video/android/core/notifications/internal/service/CallServiceConfig { + public fun ()V + public fun (ZILjava/util/Map;)V + public synthetic fun (ZILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()I + public final fun component3 ()Ljava/util/Map; + public final fun copy (ZILjava/util/Map;)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;ILjava/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 getRunCallServiceInForeground ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/video/android/core/notifications/internal/service/CallServiceConfigKt { + public static final fun callServiceConfig ()Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; + public static final fun livestreamCallServiceConfig ()Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; + public static final fun livestreamGuestCallServiceConfig ()Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfig; +} + public final class io/getstream/video/android/core/permission/PermissionRequest { public fun (Lio/getstream/video/android/core/Call;Lio/getstream/video/android/model/User;Lorg/threeten/bp/OffsetDateTime;Ljava/util/List;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;)V public synthetic fun (Lio/getstream/video/android/core/Call;Lio/getstream/video/android/model/User;Lorg/threeten/bp/OffsetDateTime;Ljava/util/List;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-video-android-core/src/main/AndroidManifest.xml b/stream-video-android-core/src/main/AndroidManifest.xml index f9e5b5fdb6..701e3aadb5 100644 --- a/stream-video-android-core/src/main/AndroidManifest.xml +++ b/stream-video-android-core/src/main/AndroidManifest.xml @@ -34,12 +34,26 @@ - + + + - - + + + - + + + + + + + + + + + + @@ -83,5 +97,15 @@ + + + + \ 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 ecd163873c..71f8b4c24c 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 @@ -42,6 +42,7 @@ import io.getstream.video.android.core.model.VideoTrack import io.getstream.video.android.core.model.toIceServer import io.getstream.video.android.core.socket.SocketState import io.getstream.video.android.core.utils.RampValueUpAndDownHelper +import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.toQueriedMembers import io.getstream.video.android.model.User import io.getstream.webrtc.android.ui.VideoTextureViewRenderer @@ -132,10 +133,10 @@ public class Call( private val network by lazy { clientImpl.connectionModule.networkStateProvider } /** Camera gives you access to the local camera */ - val camera by lazy { mediaManager.camera } - val microphone by lazy { mediaManager.microphone } - val speaker by lazy { mediaManager.speaker } - val screenShare by lazy { mediaManager.screenShare } + val camera by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.camera } + val microphone by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.microphone } + val speaker by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.speaker } + val screenShare by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.screenShare } /** The cid is type:id */ val cid = "$type:$id" @@ -601,7 +602,7 @@ public class Call( leave(disconnectionReason = null) } - private fun leave(disconnectionReason: Throwable?) { + private fun leave(disconnectionReason: Throwable?) = safeCall { logger.v { "[leave] #ringing; disconnectionReason: $disconnectionReason" } if (isDestroyed) { logger.w { "[leave] #ringing; Call already destroyed, ignoring" } @@ -616,7 +617,7 @@ public class Call( RealtimeConnection.Disconnected } stopScreenSharing() - client.state.removeActiveCall() + client.state.removeActiveCall() // Will also stop CallService client.state.removeRingingCall() (client as StreamVideoImpl).onCallCleanUp(this) camera.disable() 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 551e11de58..c7b3695109 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 @@ -63,8 +63,8 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.isActive @@ -125,6 +125,7 @@ import java.util.Date import java.util.Locale import java.util.SortedMap import java.util.UUID +import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -282,6 +283,26 @@ public class CallState( // TODO: could optimize performance by subscribing only to relevant events call.subscribe { + if (it is TrackPublishedEvent) { + val participant = getOrCreateParticipant(it.sessionId, it.userId) + + if (it.trackType == TrackType.TRACK_TYPE_VIDEO) { + participant._videoEnabled.value = true + } else if (it.trackType == TrackType.TRACK_TYPE_AUDIO) { + participant._audioEnabled.value = true + } + } + + if (it is TrackUnpublishedEvent) { + val participant = getOrCreateParticipant(it.sessionId, it.userId) + + if (it.trackType == TrackType.TRACK_TYPE_VIDEO) { + participant._videoEnabled.value = false + } else if (it.trackType == TrackType.TRACK_TYPE_AUDIO) { + participant._audioEnabled.value = false + } + } + emitLivestreamVideo() } @@ -372,7 +393,7 @@ public class CallState( } /** how long the call has been running, rounded to seconds, null if the call didn't start yet */ - public val duration: StateFlow = + public val duration: StateFlow = _durationInMs.transform { emit(((it ?: 0L) / 1000L).toDuration(DurationUnit.SECONDS)) } .stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) @@ -422,17 +443,35 @@ public class CallState( /** the opposite of backstage, if we are live or not */ val live: StateFlow = _backstage.mapState { !it } - /** how many milliseconds the call has been running, null if the call didn't start yet */ - public val liveDurationInMs: StateFlow = - _durationInMs - .map { - if (live.value) { - it - } else { - null - } + /** + * How long the call has been live for, in milliseconds, or null if the call hasn't been live yet. + * Keeps its value when live ends and resets when live starts again. + * + * @see [liveDuration] + */ + public val liveDurationInMs = flow { + while (currentCoroutineContext().isActive) { + delay(1000) + + val liveStartedAt = _session.value?.liveStartedAt + val liveEndedAt = _session.value?.liveEndedAt ?: OffsetDateTime.now() + + liveStartedAt?.let { + val duration = liveEndedAt.toInstant().toEpochMilli() - liveStartedAt.toInstant().toEpochMilli() + emit(duration) } - .stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) + } + }.distinctUntilChanged().stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) + + /** + * How long the call has been live for, represented as [Duration], or null if the call hasn't been live yet. + * Keeps its value when live ends and resets when live starts again. + * + * @see [liveDurationInMs] + */ + public val liveDuration = liveDurationInMs.mapState { durationInMs -> + durationInMs?.takeIf { it >= 1000 }?.let { (it / 1000).toDuration(DurationUnit.SECONDS) } + } private val _egress: MutableStateFlow = MutableStateFlow(null) val egress: StateFlow = _egress 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 14d1e555eb..ca111ef206 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 @@ -154,12 +154,13 @@ class ClientState(client: StreamVideo) { * This depends on the flag in [StreamVideoBuilder] called `runForegroundServiceForCalls` */ internal fun maybeStartForegroundService(call: Call, trigger: String) { - if (clientImpl.runForegroundService) { + if (clientImpl.callServiceConfig.runCallServiceInForeground) { val context = clientImpl.context val serviceIntent = CallService.buildStartIntent( context, StreamCallId.fromCallCid(call.cid), trigger, + callServiceConfiguration = clientImpl.callServiceConfig, ) ContextCompat.startForegroundService(context, serviceIntent) } @@ -169,9 +170,12 @@ class ClientState(client: StreamVideo) { * Stop the foreground service that manages the call even when the UI is gone. */ internal fun maybeStopForegroundService() { - if (clientImpl.runForegroundService) { + if (clientImpl.callServiceConfig.runCallServiceInForeground) { val context = clientImpl.context - val serviceIntent = CallService.buildStopIntent(context) + val serviceIntent = CallService.buildStopIntent( + context, + callServiceConfiguration = clientImpl.callServiceConfig, + ) context.stopService(serviceIntent) } } 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 aa1657758a..1d0b579d4b 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 @@ -28,6 +28,8 @@ import io.getstream.video.android.core.internal.module.ConnectionModule import io.getstream.video.android.core.logging.LoggingLevel import io.getstream.video.android.core.notifications.NotificationConfig import io.getstream.video.android.core.notifications.internal.StreamNotificationManager +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.getstream.video.android.core.notifications.internal.service.callServiceConfig import io.getstream.video.android.core.notifications.internal.storage.DeviceTokenStorage import io.getstream.video.android.core.permission.android.DefaultStreamPermissionCheck import io.getstream.video.android.core.permission.android.StreamPermissionCheck @@ -94,6 +96,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private var ensureSingleInstance: Boolean = true, private val videoDomain: String = "video.stream-io-api.com", private val runForegroundServiceForCalls: Boolean = true, + private val callServiceConfig: CallServiceConfig? = null, private val localSfuAddress: String? = null, private val sounds: Sounds = Sounds(), private val crashOnMissingPermission: Boolean = false, @@ -186,7 +189,11 @@ public class StreamVideoBuilder @JvmOverloads constructor( lifecycle = lifecycle, connectionModule = connectionModule, streamNotificationManager = streamNotificationManager, - runForegroundService = runForegroundServiceForCalls, + callServiceConfig = callServiceConfig + ?: callServiceConfig().copy( + runCallServiceInForeground = runForegroundServiceForCalls, + audioUsage = audioUsage, + ), testSfuAddress = localSfuAddress, sounds = sounds, permissionCheck = permissionCheck, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt index 5cb548fb4c..d4b95fe578 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt @@ -45,6 +45,9 @@ import io.getstream.video.android.core.model.UpdateUserPermissionsData 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.CallService +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.getstream.video.android.core.notifications.internal.service.callServiceConfig 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 @@ -147,7 +150,7 @@ internal class StreamVideoImpl internal constructor( internal val connectionModule: ConnectionModule, internal val tokenProvider: (suspend (error: Throwable?) -> String)?, internal val streamNotificationManager: StreamNotificationManager, - internal val runForegroundService: Boolean = true, + internal val callServiceConfig: CallServiceConfig = callServiceConfig(), internal val testSfuAddress: String? = null, internal val sounds: Sounds, internal val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), @@ -204,7 +207,14 @@ internal class StreamVideoImpl internal constructor( socketImpl.cleanup() // call cleanup on the active call val activeCall = state.activeCall.value - activeCall?.cleanup() + activeCall?.leave() + // Stop the call service if it was running + if (callServiceConfig.runCallServiceInForeground) { + safeCall { + val serviceIntent = CallService.buildStopIntent(context, callServiceConfig) + context.stopService(serviceIntent) + } + } } /** 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 210b903166..ddec0150ec 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 @@ -907,9 +907,10 @@ public class RtcSession internal constructor( return@synchronized } - val enabledRids = event.changePublishQuality.video_senders.firstOrNull()?.layers?.associate { - it.name to it.active - } + val enabledRids = + event.changePublishQuality.video_senders.firstOrNull()?.layers?.associate { + it.name to it.active + } dynascaleLogger.i { "enabled rids: $enabledRids}" } val params = sender.parameters val updatedEncodings: MutableList = mutableListOf() @@ -1069,9 +1070,10 @@ public class RtcSession internal constructor( fun handleEvent(event: VideoEvent) { logger.i { "[rtc handleEvent] #sfu; event: $event" } if (event is JoinCallResponseEvent) { - logger.i { "[rtc handleEvent] unlocking joinEventReceivedMutex" } - - joinEventReceivedMutex.unlock() + if (joinEventReceivedMutex.isLocked) { + logger.i { "[rtc handleEvent] unlocking joinEventReceivedMutex" } + joinEventReceivedMutex.unlock() + } } if (event is SfuDataEvent) { coroutineScope.launch { @@ -1410,7 +1412,13 @@ public class RtcSession internal constructor( track_id = track.id(), track_type = trackType, layers = layers, - mid = transceiver.mid ?: extractMid(sdp, track, screenShareTrack, trackType, transceivers), + mid = transceiver.mid ?: extractMid( + sdp, + track, + screenShareTrack, + trackType, + transceivers, + ), ) } return tracks @@ -1429,6 +1437,7 @@ public class RtcSession internal constructor( TrackType.TRACK_TYPE_VIDEO } } + else -> TrackType.TRACK_TYPE_UNSPECIFIED } @@ -1475,7 +1484,10 @@ public class RtcSession internal constructor( return media.mid.toString() } - private fun createVideoLayers(transceiver: RtpTransceiver, captureResolution: CaptureFormat): List { + private fun createVideoLayers( + transceiver: RtpTransceiver, + captureResolution: CaptureFormat, + ): List { // we tell the Sfu which resolutions we're sending return transceiver.sender.parameters.encodings.map { val scaleBy = it.scaleResolutionDownBy ?: 1.0 @@ -1715,7 +1727,13 @@ public class RtcSession internal constructor( } } - suspend fun switchSfu(sfuName: String, sfuUrl: String, sfuToken: String, remoteIceServers: List, failedToSwitch: () -> Unit) { + suspend fun switchSfu( + sfuName: String, + sfuUrl: String, + sfuToken: String, + remoteIceServers: List, + failedToSwitch: () -> Unit, + ) { logger.i { "[switchSfu] from ${this.sfuUrl} to $sfuUrl" } val timer = clientImpl.debugInfo.trackTime("call.switchSfu") diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/network/NetworkStateProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/network/NetworkStateProvider.kt index 2539e452d8..a216f7b3c2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/network/NetworkStateProvider.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/network/NetworkStateProvider.kt @@ -20,6 +20,7 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import io.getstream.log.StreamLog import java.util.concurrent.atomic.AtomicBoolean /** @@ -96,14 +97,24 @@ public class NetworkStateProvider(private val connectivityManager: ConnectivityM synchronized(lock) { listeners = listeners + listener if (isRegistered.compareAndSet(false, true)) { - connectivityManager.registerNetworkCallback( - NetworkRequest.Builder().build(), - callback, - ) + safelyRegisterNetworkCallback(NetworkRequest.Builder().build(), callback) } } } + /** + * Calls [ConnectivityManager.registerNetworkCallback] and catches potential [SecurityException]. + * This is a known [bug](https://android-review.googlesource.com/c/platform/frameworks/base/+/1758029) on Android 11. + */ + private fun safelyRegisterNetworkCallback( + networkRequest: NetworkRequest, + callback: ConnectivityManager.NetworkCallback, + ) { + connectivityManager.callWithSecurityExceptionHandling { + registerNetworkCallback(networkRequest, callback) + } + } + /** * Removes a listener for network state changes. * @@ -113,12 +124,24 @@ public class NetworkStateProvider(private val connectivityManager: ConnectivityM synchronized(lock) { listeners = (listeners - listener).also { if (it.isEmpty() && isRegistered.compareAndSet(true, false)) { - connectivityManager.unregisterNetworkCallback(callback) + safelyUnregisterNetworkCallback(callback) } } } } + /** + * Calls [ConnectivityManager.unregisterNetworkCallback] and catches potential [SecurityException]. + * This is a known [bug](https://android-review.googlesource.com/c/platform/frameworks/base/+/1758029) on Android 11. + */ + private fun safelyUnregisterNetworkCallback(callback: ConnectivityManager.NetworkCallback) { + connectivityManager.callWithSecurityExceptionHandling { + unregisterNetworkCallback( + callback, + ) + } + } + /** * Listener which is used to listen and react to network state changes. */ @@ -128,3 +151,13 @@ public class NetworkStateProvider(private val connectivityManager: ConnectivityM public fun onDisconnected() } } + +private fun ConnectivityManager.callWithSecurityExceptionHandling(method: ConnectivityManager.() -> Unit) { + try { + method() + } catch (e: SecurityException) { + StreamLog.e("ConnectivityManager", e) { + "SecurityException occurred. This is a known bug on Android 11. We log and prevent the app from crashing. Cause: ${e.message}" + } + } +} 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 f94a4f5582..c8db32af1a 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 @@ -146,6 +146,39 @@ public open class DefaultNotificationHandler( } } + override fun getSettingUpCallNotification(): Notification? { + val channelId = application.getString( + R.string.stream_video_call_setup_notification_channel_id, + ) + + maybeCreateChannel( + channelId = channelId, + context = application, + configure = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + name = application.getString( + R.string.stream_video_call_setup_notification_channel_title, + ) + description = application.getString( + R.string.stream_video_call_setup_notification_channel_description, + ) + } + }, + ) + + return getNotification { + setContentTitle( + application.getString(R.string.stream_video_call_setup_notification_title), + ) + setContentText( + application.getString(R.string.stream_video_call_setup_notification_description), + ) + setChannelId(channelId) + setCategory(NotificationCompat.CATEGORY_CALL) + setOngoing(true) + } + } + private fun getIncomingCallNotification( fullScreenPendingIntent: PendingIntent, acceptCallPendingIntent: PendingIntent, @@ -166,27 +199,36 @@ public open class DefaultNotificationHandler( R.string.stream_video_incoming_call_low_priority_notification_channel_id }, ) - maybeCreateChannel(channelId, application) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - description = application.getString( - if (showAsHighPriority) { - R.string.stream_video_incoming_call_notification_channel_description + + maybeCreateChannel( + channelId = channelId, + context = application, + configure = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + name = application.getString( + R.string.stream_video_incoming_call_notification_channel_title, + ) + description = application.getString( + if (showAsHighPriority) { + R.string.stream_video_incoming_call_notification_channel_description + } else { + R.string.stream_video_incoming_call_low_priority_notification_channel_description + }, + ) + importance = if (showAsHighPriority) { + NotificationManager.IMPORTANCE_HIGH } else { - R.string.stream_video_incoming_call_low_priority_notification_channel_description - }, - ) - importance = if (showAsHighPriority) { - NotificationManager.IMPORTANCE_HIGH - } else { - NotificationManager.IMPORTANCE_LOW + NotificationManager.IMPORTANCE_LOW + } + this.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + this.setShowBadge(true) } - this.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - this.setShowBadge(true) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - this.setAllowBubbles(true) - } - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.setAllowBubbles(true) + } + }, + ) + return getNotification { priority = NotificationCompat.PRIORITY_HIGH setContentTitle( @@ -219,15 +261,18 @@ public open class DefaultNotificationHandler( callDisplayName: String, ): Notification { val channelId = application.getString( - R.string.stream_video_ongoing_call_notification_channel_id, + R.string.stream_video_outgoing_call_notification_channel_id, ) maybeCreateChannel( channelId = channelId, context = application, configure = { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + name = application.getString( + R.string.stream_video_outgoing_call_notification_channel_title, + ) description = application.getString( - R.string.stream_video_ongoing_call_notification_channel_description, + R.string.stream_video_outgoing_call_notification_channel_description, ) } }, @@ -293,12 +338,19 @@ public open class DefaultNotificationHandler( val ongoingCallsChannelId = application.getString( R.string.stream_video_ongoing_call_notification_channel_id, ) - maybeCreateChannel(ongoingCallsChannelId, application) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - description = - application.getString(R.string.stream_video_ongoing_call_notification_channel_description) - } - } + maybeCreateChannel( + channelId = ongoingCallsChannelId, + context = application, + configure = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + name = application.getString( + R.string.stream_video_ongoing_call_notification_channel_title, + ) + description = + application.getString(R.string.stream_video_ongoing_call_notification_channel_description) + } + }, + ) if (endCallIntent == null) { logger.e { "End call intent is null, not showing notification!" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt index 407f2ed3fc..b9b7a41148 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt @@ -33,6 +33,7 @@ public interface NotificationHandler : NotificationPermissionHandler { callDisplayName: String, shouldHaveContentIntent: Boolean = true, ): Notification? + fun getSettingUpCallNotification(): Notification? companion object { const val ACTION_NOTIFICATION = "io.getstream.video.android.action.NOTIFICATION" diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt index 35006536e2..f4fa89e3ad 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NoOpNotificationHandler.kt @@ -36,6 +36,7 @@ internal object NoOpNotificationHandler : NotificationHandler { callDisplayName: String, shouldHaveContentIntent: Boolean, ): Notification? = null + override fun getSettingUpCallNotification(): Notification? = null override fun onPermissionDenied() { /* NoOp */ } override fun onPermissionGranted() { /* NoOp */ } 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 e578824c53..9beb88a79f 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 @@ -17,6 +17,7 @@ package io.getstream.video.android.core.notifications.internal.service import android.annotation.SuppressLint +import android.app.ActivityManager import android.app.Notification import android.app.Service import android.content.Context @@ -24,12 +25,11 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo import android.media.MediaPlayer -import android.os.Build import android.os.IBinder import androidx.annotation.RawRes import androidx.core.app.NotificationManagerCompat -import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.video.android.core.R import io.getstream.video.android.core.RingingState @@ -39,6 +39,8 @@ import io.getstream.video.android.core.notifications.NotificationHandler.Compani import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_DISPLAY_NAME import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver +import io.getstream.video.android.core.utils.safeCall +import io.getstream.video.android.core.utils.startForegroundWithServiceType import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.streamCallDisplayName import io.getstream.video.android.model.streamCallId @@ -54,8 +56,11 @@ import org.openapitools.client.models.CallRejectedEvent /** * A foreground service that is running when there is an active call. */ -internal class CallService : Service() { - private val logger by taggedLogger("CallService") +internal open class CallService : Service() { + internal open val logger by taggedLogger("CallService") + + // Service type + open val serviceType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL // Data private var callId: StreamCallId? = null @@ -74,6 +79,7 @@ internal class CallService : Service() { private var mediaPlayer: MediaPlayer? = null internal companion object { + private const val TAG = "CallServiceCompanion" const val TRIGGER_KEY = "io.getstream.video.android.core.notifications.internal.service.CallService.call_trigger" const val TRIGGER_INCOMING_CALL = "incoming_call" @@ -94,8 +100,11 @@ internal class CallService : Service() { callId: StreamCallId, trigger: String, callDisplayName: String? = null, + callServiceConfiguration: CallServiceConfig = callServiceConfig(), ): Intent { - val serviceIntent = Intent(context, CallService::class.java) + val serviceClass = resolveServiceClass(callId, callServiceConfiguration) + StreamLog.i(TAG) { "Resolved service class: $serviceClass" } + val serviceIntent = Intent(context, serviceClass) serviceIntent.putExtra(INTENT_EXTRA_CALL_CID, callId) when (trigger) { @@ -130,9 +139,22 @@ internal class CallService : Service() { * * @param context the context. */ - fun buildStopIntent(context: Context) = Intent(context, CallService::class.java) + fun buildStopIntent( + context: Context, + callServiceConfiguration: CallServiceConfig = callServiceConfig(), + ) = safeCall(Intent(context, CallService::class.java)) { + val intent = callServiceConfiguration.callServicePerType.firstNotNullOfOrNull { + val serviceClass = it.value + if (isServiceRunning(context, serviceClass)) { + Intent(context, serviceClass) + } else { + null + } + } + intent ?: Intent(context, CallService::class.java) + } - fun showIncomingCall(context: Context, callId: StreamCallId, callDisplayName: String?) { + fun showIncomingCall(context: Context, callId: StreamCallId, callDisplayName: String?, callServiceConfiguration: CallServiceConfig = callServiceConfig()) { val hasActiveCall = StreamVideo.instanceOrNull()?.state?.activeCall?.value != null if (!hasActiveCall) { @@ -143,6 +165,7 @@ internal class CallService : Service() { callId, TRIGGER_INCOMING_CALL, callDisplayName, + callServiceConfiguration, ), ) } else { @@ -152,20 +175,39 @@ internal class CallService : Service() { callId, TRIGGER_INCOMING_CALL, callDisplayName, + callServiceConfiguration, ), ) } } - fun removeIncomingCall(context: Context, callId: StreamCallId) { + fun removeIncomingCall(context: Context, callId: StreamCallId, config: CallServiceConfig = callServiceConfig()) { context.startService( buildStartIntent( context, callId, TRIGGER_REMOVE_INCOMING_CALL, + callServiceConfiguration = config, ), ) } + + private fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean = safeCall( + 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 + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -178,6 +220,13 @@ internal class CallService : Service() { logger.i { "[onStartCommand]. callId: ${intentCallId?.id}, trigger: $trigger" } val started = if (intentCallId != null && streamVideo != null && trigger != null) { + // Promote early to foreground service + maybePromoteToForegroundService( + videoClient = streamVideo, + notificationId = intentCallId.hashCode(), + trigger, + ) + val type = intentCallId.type val id = intentCallId.id val call = streamVideo.call(type, id) @@ -235,27 +284,18 @@ internal class CallService : Service() { if (notification != null) { if (trigger == TRIGGER_INCOMING_CALL) { showIncomingCall( - notification = notification, notificationId = notificationData.second, + notification = notification, ) } else { callId = intentCallId - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val foregroundServiceType = when (trigger) { - TRIGGER_ONGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL - TRIGGER_OUTGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - else -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - } - ServiceCompat.startForeground( - this@CallService, - intentCallId.hashCode(), - notification, - foregroundServiceType, - ) - } else { - startForeground(intentCallId.hashCode(), notification) - } + startForegroundWithServiceType( + intentCallId.hashCode(), + notification, + trigger, + serviceType, + ) } true } else { @@ -297,15 +337,34 @@ internal class CallService : Service() { } } + private fun maybePromoteToForegroundService(videoClient: StreamVideoImpl, notificationId: Int, trigger: String) { + val hasActiveCall = videoClient.state.activeCall.value != null + val not = if (hasActiveCall) " not" else "" + + logger.d { + "[maybePromoteToForegroundService] hasActiveCall: $hasActiveCall. Will$not call startForeground early." + } + + if (!hasActiveCall) { + videoClient.getSettingUpCallNotification()?.let { + startForegroundWithServiceType(notificationId, it, trigger, serviceType) + } + } + } + @SuppressLint("MissingPermission") - private fun showIncomingCall(notification: Notification, notificationId: Int) { + private fun showIncomingCall(notificationId: Int, notification: Notification) { if (callId == null) { // If there isn't another call in progress (callId is set in onStartCommand()) - startForeground( + // The service was started with startForegroundService() (from companion object), so we need to call startForeground(). + startForegroundWithServiceType( notificationId, notification, - ) // The service was started with startForegroundService() (from companion object), so we need to call startForeground(). + TRIGGER_INCOMING_CALL, + serviceType, + ) } else { - NotificationManagerCompat // Else, we show a simple notification (the service was already started as a foreground service). + // Else, we show a simple notification (the service was already started as a foreground service). + NotificationManagerCompat .from(this) .notify(notificationId, notification) } 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 new file mode 100644 index 0000000000..cc7899c635 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt @@ -0,0 +1,84 @@ +/* + * 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.media.AudioAttributes +import io.getstream.video.android.model.StreamCallId + +// Constants +/** Marker for all the call types. */ +internal const val ANY_MARKER = "ALL_CALL_TYPES" + +// API +/** + * Configuration class for the call service. + * @param runCallServiceInForeground If the call service should run in the foreground. + * @param callServicePerType A map of call service per type. + */ +public data class CallServiceConfig( + val runCallServiceInForeground: Boolean = true, + val audioUsage: Int = AudioAttributes.USAGE_VOICE_COMMUNICATION, + val callServicePerType: Map> = mapOf( + Pair(ANY_MARKER, CallService::class.java), + ), +) + +/** + * Return a default configuration for the call service configuration. + */ +public fun callServiceConfig(): CallServiceConfig { + return CallServiceConfig( + runCallServiceInForeground = true, + callServicePerType = mapOf( + Pair(ANY_MARKER, CallService::class.java), + ), + ) +} + +/** + * Return a default configuration for the call service configuration. + */ +public fun livestreamCallServiceConfig(): CallServiceConfig { + return CallServiceConfig( + runCallServiceInForeground = true, + callServicePerType = mapOf( + Pair(ANY_MARKER, CallService::class.java), + Pair("livestream", LivestreamCallService::class.java), + ), + ) +} + +/** + * Return a default configuration for the call service configuration. + */ +public fun livestreamGuestCallServiceConfig(): CallServiceConfig { + return CallServiceConfig( + runCallServiceInForeground = true, + audioUsage = AudioAttributes.USAGE_MEDIA, + callServicePerType = mapOf( + Pair(ANY_MARKER, CallService::class.java), + Pair("livestream", LivestreamViewerService::class.java), + ), + ) +} + +// Internal +internal fun resolveServiceClass(callId: StreamCallId, config: CallServiceConfig): Class<*> { + val callType = callId.type + val resolvedServiceClass = config.callServicePerType[callType] + return resolvedServiceClass ?: config.callServicePerType[ANY_MARKER] ?: CallService::class.java +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt new file mode 100644 index 0000000000..608bf5bdcc --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt @@ -0,0 +1,37 @@ +/* + * 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.pm.ServiceInfo +import io.getstream.log.TaggedLogger +import io.getstream.log.taggedLogger + +/** + * Due to the nature of the livestream calls, the service that is used is of different type. + */ +internal open class LivestreamCallService : CallService() { + override val logger: TaggedLogger by taggedLogger("LivestreamHostCallService") + override val serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE +} + +/** + * Due to the nature of the livestream calls, the service that is used is of different type. + */ +internal class LivestreamViewerService : LivestreamCallService() { + override val logger: TaggedLogger by taggedLogger("LivestreamHostCallService") + override val serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/screenshare/StreamScreenShareService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/screenshare/StreamScreenShareService.kt index 76c377de86..b7e1ca325f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/screenshare/StreamScreenShareService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/screenshare/StreamScreenShareService.kt @@ -21,6 +21,7 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Binder import android.os.IBinder import androidx.core.app.NotificationChannelCompat @@ -28,6 +29,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.getstream.video.android.core.R import io.getstream.video.android.core.notifications.internal.receivers.StopScreenshareBroadcastReceiver +import io.getstream.video.android.core.utils.startForegroundWithServiceType /** * Screen-sharing in Android requires a ForegroundService (with type foregroundServiceType set to "mediaProjection"). @@ -102,15 +104,22 @@ internal class StreamScreenShareService : Service() { ) } - startForeground(NOTIFICATION_ID, builder.build()) + startForegroundWithServiceType( + NOTIFICATION_ID, + builder.build(), + TRIGGER_SHARE_SCREEN, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION, + ) return super.onStartCommand(intent, flags, startId) } companion object { internal const val NOTIFICATION_ID = 43534 internal const val EXTRA_CALL_ID = "EXTRA_CALL_ID" - internal const val BROADCAST_CANCEL_ACTION = "io.getstream.video.android.action.CANCEL_SCREEN_SHARE" + internal const val BROADCAST_CANCEL_ACTION = + "io.getstream.video.android.action.CANCEL_SCREEN_SHARE" internal const val INTENT_EXTRA_CALL_ID = "io.getstream.video.android.intent-extra.call_cid" + internal const val TRIGGER_SHARE_SCREEN = "share_screen" fun createIntent(context: Context, callId: String) = Intent(context, StreamScreenShareService::class.java).apply { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt index 878b9a1276..4073ec7e95 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt @@ -19,17 +19,25 @@ package io.getstream.video.android.core.utils import android.app.Activity +import android.app.Notification import android.app.NotificationManager +import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.ServiceInfo import android.os.Build import android.os.Vibrator import android.os.VibratorManager import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat +import androidx.core.app.ServiceCompat import io.getstream.log.StreamLog +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.screenshare.StreamScreenShareService.Companion.TRIGGER_SHARE_SCREEN import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking @@ -162,3 +170,39 @@ inline fun safeCall(default: T, block: () -> T): T { default } } + +/** + * Start a foreground service with a service type to meet requirements introduced in Android 14. + * + * @param notificationId The notification ID + * @param notification The notification to show + * @param trigger The trigger that started the service: [TRIGGER_ONGOING_CALL], [TRIGGER_OUTGOING_CALL], [TRIGGER_INCOMING_CALL], [TRIGGER_SHARE_SCREEN] + */ +internal fun Service.startForegroundWithServiceType( + notificationId: Int, + notification: Notification, + trigger: String, + foregroundServiceType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + startForeground(notificationId, notification) + } else { + val beforeOrAfterAndroid14Type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } else { + foregroundServiceType + } + + ServiceCompat.startForeground( + this, + notificationId, + notification, + when (trigger) { + TRIGGER_ONGOING_CALL -> foregroundServiceType + TRIGGER_OUTGOING_CALL, TRIGGER_INCOMING_CALL -> beforeOrAfterAndroid14Type + TRIGGER_SHARE_SCREEN -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + else -> beforeOrAfterAndroid14Type + }, + ) + } +} diff --git a/stream-video-android-core/src/main/res/values/strings.xml b/stream-video-android-core/src/main/res/values/strings.xml index b011cad9c5..ef39925f05 100644 --- a/stream-video-android-core/src/main/res/values/strings.xml +++ b/stream-video-android-core/src/main/res/values/strings.xml @@ -31,10 +31,19 @@ Incoming audio and video call alerts Incoming audio and video call alerts Incoming call + outgoing_calls + Outgoing Calls + Outgoing call notifications Calling... + There is a call in progress, tap to go back to the call ongoing_calls - Ongoing calls + Ongoing Calls Ongoing call notifications Call in progress - There is a call in progress, tap to go back to the call. + There is a call in progress, tap to go back to the call + call_setup + Call Setup + Temporary notifications used while setting up calls + Setting up call + Please wait while we set up your call \ No newline at end of file diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigTest.kt new file mode 100644 index 0000000000..6962fd80fa --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigTest.kt @@ -0,0 +1,127 @@ +/* + * 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.media.AudioAttributes +import io.getstream.video.android.model.StreamCallId +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import kotlin.test.Test + +class CallServiceConfigTest { + + @Test + fun `callServiceConfig should return correct default configuration`() { + // Given + val config = callServiceConfig() + + // When + val runInForeground = config.runCallServiceInForeground + val servicePerTypeSize = config.callServicePerType.size + val serviceClass = config.callServicePerType[ANY_MARKER] + val audioUsage = config.audioUsage + + // Then + assertEquals(true, runInForeground) + assertEquals(1, servicePerTypeSize) + assertEquals(CallService::class.java, serviceClass) + assertEquals(AudioAttributes.USAGE_VOICE_COMMUNICATION, audioUsage) + } + + @Test + fun `livestreamCallServiceConfig should return correct default configuration`() { + // Given + val config = livestreamCallServiceConfig() + + // When + val runInForeground = config.runCallServiceInForeground + val servicePerTypeSize = config.callServicePerType.size + val hostServiceClass = config.callServicePerType[ANY_MARKER] + val livestreamServiceClass = config.callServicePerType["livestream"] + val audioUsage = config.audioUsage + + // Then + assertEquals(true, runInForeground) + assertEquals(2, servicePerTypeSize) + assertEquals(CallService::class.java, hostServiceClass) + assertEquals(LivestreamCallService::class.java, livestreamServiceClass) + assertEquals(AudioAttributes.USAGE_VOICE_COMMUNICATION, audioUsage) + } + + @Test + fun `resolveServiceClass should return correct service class for livestream type`() { + // Given + val streamCallId = mockk() + every { streamCallId.type } returns "livestream" + val config = livestreamCallServiceConfig() + + // When + val resolvedClass = resolveServiceClass(streamCallId, config) + + // Then + assertEquals(LivestreamCallService::class.java, resolvedClass) + } + + @Test + fun `resolveServiceClass should return default service class for unknown type`() { + // Given + val streamCallId = mockk() + every { streamCallId.type } returns "unknown" + val config = livestreamCallServiceConfig() + + // When + val resolvedClass = resolveServiceClass(streamCallId, config) + + // Then + assertEquals(CallService::class.java, resolvedClass) + } + + @Test + fun `resolveServiceClass should return default service class when no type is provided`() { + // Given + val streamCallId = mockk() + every { streamCallId.type } returns "" + val config = livestreamCallServiceConfig() + + // When + val resolvedClass = resolveServiceClass(streamCallId, config) + + // Then + assertEquals(CallService::class.java, resolvedClass) + } + + @Test + fun `livestreamGuestCallServiceConfig should return correct default configuration`() { + // Given + val config = livestreamGuestCallServiceConfig() + + // When + val runInForeground = config.runCallServiceInForeground + val servicePerTypeSize = config.callServicePerType.size + val hostServiceClass = config.callServicePerType[ANY_MARKER] + val livestreamServiceClass = config.callServicePerType["livestream"] + val audioUsage = config.audioUsage + + // Then + assertEquals(true, runInForeground) + assertEquals(2, servicePerTypeSize) + assertEquals(CallService::class.java, hostServiceClass) + assertEquals(LivestreamViewerService::class.java, livestreamServiceClass) + assertEquals(AudioAttributes.USAGE_MEDIA, audioUsage) + } +} diff --git a/stream-video-android-previewdata/api/stream-video-android-previewdata.api b/stream-video-android-previewdata/api/stream-video-android-previewdata.api index 759b5a3510..daa4869105 100644 --- a/stream-video-android-previewdata/api/stream-video-android-previewdata.api +++ b/stream-video-android-previewdata/api/stream-video-android-previewdata.api @@ -7,7 +7,6 @@ public final class io/getstream/video/android/mock/StreamPreviewDataUtils { } public final class io/getstream/video/android/mock/StreamPreviewDataUtilsKt { - public static final fun getMockVideoMediaTrack ()Lio/getstream/video/android/core/model/MediaTrack; public static final fun getPreviewCall ()Lio/getstream/video/android/core/Call; public static final fun getPreviewMember ()Lio/getstream/video/android/core/MemberState; public static final fun getPreviewMemberListState ()Ljava/util/List; @@ -16,5 +15,6 @@ public final class io/getstream/video/android/mock/StreamPreviewDataUtilsKt { public static final fun getPreviewThreeMembers ()Ljava/util/List; public static final fun getPreviewTwoMembers ()Ljava/util/List; public static final fun getPreviewUsers ()Ljava/util/List; + public static final fun getPreviewVideoMediaTrack ()Lio/getstream/video/android/core/model/MediaTrack; } diff --git a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt index ca2e5fad2f..2e0d44471c 100644 --- a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt +++ b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt @@ -56,7 +56,7 @@ public val previewCall: Call = Call( ).apply { val participants = previewUsers.take(2).map { user -> val sessionId = if (user == previewUsers.first()) { - sessionId ?: UUID.randomUUID().toString() + sessionId } else { UUID.randomUUID().toString() } @@ -70,7 +70,7 @@ public val previewCall: Call = Call( } /** Mock a new [MediaTrack]. */ -public val mockVideoMediaTrack: MediaTrack +public val previewVideoMediaTrack: MediaTrack inline get() = io.getstream.video.android.core.model.VideoTrack( UUID.randomUUID().toString(), VideoTrack(123), @@ -146,7 +146,7 @@ public val previewMemberListState: List previewCall.state.clearParticipants() previewUsers.forEach { user -> val sessionId = if (user == previewUsers.first()) { - previewCall.sessionId ?: UUID.randomUUID().toString() + previewCall.sessionId } else { UUID.randomUUID().toString() } 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 c5f3a27f78..b6dda4d512 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 @@ -383,11 +383,8 @@ public abstract class StreamCallActivity : ComponentActivity() { * @param call the call */ public open fun onStop(call: Call) { + // Extension point only. logger.d { "Default activity - stopped (call -> $call)" } - if (isVideoCall(call) && !isInPictureInPictureMode) { - logger.d { "Default activity - stopped: No PiP detected, will leave call. (call -> $call)" } - leave(call) // Already finishing - } } /** diff --git a/tutorials/tutorial-livestream/src/main/AndroidManifest.xml b/tutorials/tutorial-livestream/src/main/AndroidManifest.xml index ab6a771b3d..8d8fe5da21 100644 --- a/tutorials/tutorial-livestream/src/main/AndroidManifest.xml +++ b/tutorials/tutorial-livestream/src/main/AndroidManifest.xml @@ -19,6 +19,11 @@ + + + + +