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 365d7acff6..d3d6f76932 100644 --- a/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt +++ b/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt @@ -6,9 +6,9 @@ object Configuration { const val minSdk = 24 const val majorVersion = 1 const val minorVersion = 0 - const val patchVersion = 6 + const val patchVersion = 7 const val versionName = "$majorVersion.$minorVersion.$patchVersion" - const val versionCode = 30 + const val versionCode = 31 const val snapshotVersionName = "$majorVersion.$minorVersion.${patchVersion + 1}-SNAPSHOT" const val artifactGroup = "io.getstream" const val streamVideoCallGooglePlayVersion = "1.1.4" diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/models/Users.kt b/demo-app/src/main/kotlin/io/getstream/video/android/models/Users.kt index 7c137607fe..2f2a0d15f4 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/models/Users.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/models/Users.kt @@ -19,13 +19,49 @@ package io.getstream.video.android.models import io.getstream.video.android.model.User public fun User.Companion.builtInUsers(): List { - return listOf( + return listOf( + User( + id = "thierry", + name = "Thierry", + role = "user", + image = "https://getstream.io/static/237f45f28690696ad8fff92726f45106/c59de/thierry.webp", + ), + User( + id = "tommaso", + name = "Tommaso", + role = "user", + image = "https://getstream.io/static/712bb5c0bd5ed8d3fa6e5842f6cfbeed/c59de/tommaso.webp", + ), + User( + id = "martin", + name = "Martin", + role = "user", + image = "https://getstream.io/static/2796a305dd07651fcceb4721a94f4505/802d2/martin-mitrevski.webp", + ), + User( + id = "ilias", + name = "Ilias", + role = "user", + image = "https://getstream.io/static/62cdddcc7759dc8c3ba5b1f67153658c/802d2/ilias-pavlidakis.webp", + ), + User( + id = "marcelo", + name = "Marcelo", + role = "user", + image = "https://getstream.io/static/aaf5fb17dcfd0a3dd885f62bd21b325a/802d2/marcelo-pires.webp", + ), User( id = "alex", name = "Alex", role = "user", image = "https://ca.slack-edge.com/T02RM6X6B-U05UD37MA1G-f062f8b7afc2-512", ), + User( + id = "liviu", + name = "Liviu", + role = "user", + image = "https://ca.slack-edge.com/T02RM6X6B-U0604NCKKRA-76f99b6ba2c8-512", + ), User( id = "kanat", name = "Kanat", diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt index 7efa5907b2..91b0d69e39 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt @@ -237,7 +237,7 @@ private fun CallJoinHeader( Text( modifier = Modifier.weight(1f), color = Color.White, - text = user?.name?.ifBlank { user?.id }?.ifBlank { user!!.custom["email"] }.orEmpty(), + text = user?.userNameOrId.orEmpty(), maxLines = 1, fontSize = 16.sp, ) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt index 1af22ef21f..35b8f4fb38 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt @@ -16,8 +16,8 @@ package io.getstream.video.android.ui.login +import android.app.Activity import android.content.Intent -import androidx.activity.ComponentActivity import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult @@ -36,7 +36,7 @@ fun rememberRegisterForActivityResult( return rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { result -> - if (result.resultCode != ComponentActivity.RESULT_OK) { + if (result.resultCode != Activity.RESULT_OK) { onSignInFailed.invoke() } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt index ad5dc5345c..fe13adc166 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt @@ -16,9 +16,9 @@ package io.getstream.video.android.ui.login +import android.app.Activity import android.content.Intent import android.util.Log -import androidx.activity.ComponentActivity import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult @@ -39,7 +39,7 @@ fun rememberLauncherForGoogleSignInActivityResult( ) { result -> Log.d("Google Sign In", "Checking activity result") - if (result.resultCode != ComponentActivity.RESULT_OK) { + if (result.resultCode != Activity.RESULT_OK) { Log.d("Google Sign In", "Failed with result not OK: ${result.resultCode}") onSignInFailed() } else { diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt index c20a849498..0730d5b048 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt @@ -426,7 +426,7 @@ private fun BuiltInUsersLoginDialog( Spacer(modifier = Modifier.width(16.dp)) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = user.name, + text = user.name.orEmpty(), color = VideoTheme.colors.basePrimary, style = VideoTheme.typography.subtitleS, ) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt index d4b8b795e2..4c7f5eebd0 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt @@ -107,7 +107,7 @@ private fun Header(user: User?) { Text( modifier = Modifier.weight(1f), color = Color.White, - text = user?.name?.ifBlank { user.id }?.ifBlank { user.custom["email"] }.orEmpty(), + text = user?.userNameOrId ?: "", maxLines = 1, fontSize = 16.sp, ) @@ -215,7 +215,7 @@ private fun UserList(entries: List, onUserClick: (Int) -> Unit) { with(entries[index]) { UserRow( index = index, - name = user.name, + name = user.name.orEmpty(), avatarUrl = user.image, isSelected = isSelected, onClick = { onUserClick(index) }, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/InAppUpdateHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/InAppUpdateHelper.kt index c8e355ca1c..5e3fb3a482 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/InAppUpdateHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/InAppUpdateHelper.kt @@ -16,6 +16,7 @@ package io.getstream.video.android.util +import android.app.Activity import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity @@ -87,11 +88,11 @@ class InAppUpdateHelper(private val activity: ComponentActivity) { when (resultCode) { // For immediate updates, you might not receive RESULT_OK because // the update should already be finished by the time control is given back to your app. - ComponentActivity.RESULT_OK -> { + Activity.RESULT_OK -> { Log.d(IN_APP_UPDATE_LOG_TAG, "Update successful") showToast(activity.getString(R.string.in_app_update_successful)) } - ComponentActivity.RESULT_CANCELED -> { + Activity.RESULT_CANCELED -> { Log.d(IN_APP_UPDATE_LOG_TAG, "Update canceled") showToast(activity.getString(R.string.in_app_update_canceled)) } 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 a8b359046d..786c3fcf6e 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 @@ -168,8 +168,8 @@ object StreamVideoInitHelper { val chatUser = io.getstream.chat.android.models.User( id = user.id, - name = user.name, - image = user.image, + name = user.name.orEmpty(), + image = user.image.orEmpty(), ) chatClient.connectUser( @@ -199,7 +199,7 @@ object StreamVideoInitHelper { ), ), tokenProvider = { - val email = user.custom["email"] + val email = user.custom?.get("email") val authData = StreamService.instance.getAuthData( environment = AppConfig.currentEnvironment.value!!.env, userId = email, diff --git a/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx b/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx index 8f6a072b27..74660dd751 100644 --- a/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx +++ b/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx @@ -31,7 +31,7 @@ If you're new to android, note that there are 2 `build.gradle` files, you want t ```kotlin dependencies { // Stream Video Compose SDK - implementation("io.getstream:stream-video-android-ui-compose:1.0.5") + implementation("io.getstream:stream-video-android-ui-compose:1.0.7") // Optionally add Jetpack Compose if Android studio didn't automatically include them implementation(platform("androidx.compose:compose-bom:2023.08.00")) diff --git a/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx b/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx index a5f6505279..ac9b342862 100644 --- a/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx +++ b/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx @@ -35,7 +35,7 @@ If you're new to android, note that there are 2 `build.gradle` files, you want t ```groovy dependencies { // Stream Video Compose SDK - implementation("io.getstream:stream-video-android-ui-compose:1.0.5") + implementation("io.getstream:stream-video-android-ui-compose:1.0.7") // Jetpack Compose (optional/ android studio typically adds them when you create a new project) implementation(platform("androidx.compose:compose-bom:2023.08.00")) diff --git a/docusaurus/docs/Android/02-tutorials/03-livestream.mdx b/docusaurus/docs/Android/02-tutorials/03-livestream.mdx index 5e74c36855..63291c90bf 100644 --- a/docusaurus/docs/Android/02-tutorials/03-livestream.mdx +++ b/docusaurus/docs/Android/02-tutorials/03-livestream.mdx @@ -35,7 +35,7 @@ If you're new to android, note that there are 2 `build.gradle` files, you want t ```kotlin dependencies { // Stream Video Compose SDK - implementation("io.getstream:stream-video-android-ui-compose:1.0.5") + implementation("io.getstream:stream-video-android-ui-compose:1.0.7") // Jetpack Compose (optional/ android studio typically adds them when you create a new project) implementation(platform("androidx.compose:compose-bom:2023.08.00")) diff --git a/docusaurus/docs/Android/06-advanced/07-chat-with-video.mdx b/docusaurus/docs/Android/06-advanced/07-chat-with-video.mdx index 81592f3167..7bc251d255 100644 --- a/docusaurus/docs/Android/06-advanced/07-chat-with-video.mdx +++ b/docusaurus/docs/Android/06-advanced/07-chat-with-video.mdx @@ -31,7 +31,7 @@ Let the project sync. It should have all the dependencies required for you to fi ```groovy dependencies { // Stream Video Compose SDK - implementation("io.getstream:stream-video-android-ui-compose:1.0.5") + implementation("io.getstream:stream-video-android-ui-compose:1.0.7") // Stream Chat implementation(libs.stream.chat.compose) diff --git a/docusaurus/docusaurus b/docusaurus/docusaurus new file mode 120000 index 0000000000..6926ca9487 --- /dev/null +++ b/docusaurus/docusaurus @@ -0,0 +1 @@ +/Users/kanat/.nvm/versions/node/v16.18.1/bin/../lib/node_modules/stream-chat-docusaurus-cli/shared \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0423a6a18c..2a6e308506 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ androidxCore = "1.12.0" androidxAnnotation = "1.7.1" androidxLifecycle = "2.7.0" androidxStartup = "1.1.1" -androidxActivity = "1.8.2" +androidxActivity = "1.9.0" androidxDataStore = "1.0.0" googleService = "4.3.14" @@ -64,7 +64,7 @@ junit = "4.13.2" truth = "1.1.3" mockk = "1.13.4" kotlinTestJunit = "1.8.20" -firebaseBom = "32.5.0" +firebaseBom = "33.1.0" firebaseCrashlytics = "2.9.5" installReferrer = "2.2" diff --git a/settings.gradle.kts b/settings.gradle.kts index 2cd56c2f37..aea4049a3c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ include(":stream-video-android-bom") include(":tutorials:tutorial-video") include(":tutorials:tutorial-audio") include(":tutorials:tutorial-livestream") +include(":tutorials:tutorial-ringing") buildCache { local { 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 980354ff43..de8f1ad646 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -297,6 +297,7 @@ public final class io/getstream/video/android/core/ConnectionState$Disconnected public final class io/getstream/video/android/core/ConnectionState$Failed : io/getstream/video/android/core/ConnectionState { public fun (Ljava/lang/Error;)V + public final fun getError ()Ljava/lang/Error; } public final class io/getstream/video/android/core/ConnectionState$Loading : io/getstream/video/android/core/ConnectionState { @@ -824,7 +825,6 @@ 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;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 final fun build ()Lio/getstream/video/android/core/StreamVideo; - public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; } public abstract interface class io/getstream/video/android/core/StreamVideoConfig { @@ -1148,6 +1148,9 @@ public final class io/getstream/video/android/core/call/state/Settings : io/gets public final class io/getstream/video/android/core/call/state/ShowCallParticipantInfo : io/getstream/video/android/core/call/state/CallAction { public static final field INSTANCE Lio/getstream/video/android/core/call/state/ShowCallParticipantInfo; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class io/getstream/video/android/core/call/state/ToggleCamera : io/getstream/video/android/core/call/state/CallAction { @@ -3646,6 +3649,7 @@ public final class io/getstream/video/android/core/model/CallStatus$Outgoing : i public final class io/getstream/video/android/core/model/CallUser : java/io/Serializable { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lio/getstream/video/android/core/model/CallUserState;Ljava/util/Date;Ljava/util/Date;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lio/getstream/video/android/core/model/CallUserState;Ljava/util/Date;Ljava/util/Date;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; @@ -4133,6 +4137,7 @@ public class io/getstream/video/android/core/notifications/DefaultNotificationHa public fun getChannelName ()Ljava/lang/String; public final fun getHideRingingNotificationInForeground ()Z public final fun getNotificationIconRes ()I + 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 onLiveCall (Lio/getstream/video/android/model/StreamCallId;Ljava/lang/String;)V @@ -4172,6 +4177,7 @@ public abstract interface class io/getstream/video/android/core/notifications/No public static final field ACTION_INCOMING_CALL Ljava/lang/String; public static final field ACTION_LEAVE_CALL Ljava/lang/String; public static final field ACTION_LIVE_CALL Ljava/lang/String; + public static final field ACTION_MISSED_CALL Ljava/lang/String; public static final field ACTION_NOTIFICATION Ljava/lang/String; public static final field ACTION_ONGOING_CALL Ljava/lang/String; public static final field ACTION_OUTGOING_CALL Ljava/lang/String; @@ -4195,6 +4201,7 @@ public final class io/getstream/video/android/core/notifications/NotificationHan public static final field ACTION_INCOMING_CALL Ljava/lang/String; public static final field ACTION_LEAVE_CALL Ljava/lang/String; public static final field ACTION_LIVE_CALL Ljava/lang/String; + public static final field ACTION_MISSED_CALL Ljava/lang/String; public static final field ACTION_NOTIFICATION Ljava/lang/String; public static final field ACTION_ONGOING_CALL Ljava/lang/String; public static final field ACTION_OUTGOING_CALL Ljava/lang/String; @@ -4294,7 +4301,7 @@ public final class io/getstream/video/android/core/socket/ErrorResponse$Companio } public class io/getstream/video/android/core/socket/PersistentSocket : okhttp3/WebSocketListener { - public field connected Lkotlinx/coroutines/CancellableContinuation; + public field connectContinuation Lkotlinx/coroutines/CancellableContinuation; public fun (Ljava/lang/String;Lokhttp3/OkHttpClient;Lio/getstream/video/android/core/internal/network/NetworkStateProvider;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Lokhttp3/OkHttpClient;Lio/getstream/video/android/core/internal/network/NetworkStateProvider;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V protected final fun ackHealthMonitor ()V @@ -4303,7 +4310,7 @@ public class io/getstream/video/android/core/socket/PersistentSocket : okhttp3/W public fun connect (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun connect$default (Lio/getstream/video/android/core/socket/PersistentSocket;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun disconnect (Lio/getstream/video/android/core/socket/PersistentSocket$DisconnectReason;)V - public final fun getConnected ()Lkotlinx/coroutines/CancellableContinuation; + public final fun getConnectContinuation ()Lkotlinx/coroutines/CancellableContinuation; public final fun getConnectionId ()Lkotlinx/coroutines/flow/StateFlow; public final fun getConnectionState ()Lkotlinx/coroutines/flow/StateFlow; protected final fun getDestroyed ()Z @@ -4318,7 +4325,7 @@ public class io/getstream/video/android/core/socket/PersistentSocket : okhttp3/W public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V public final fun reconnect (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun reconnect$default (Lio/getstream/video/android/core/socket/PersistentSocket;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public final fun setConnected (Lkotlinx/coroutines/CancellableContinuation;)V + public final fun setConnectContinuation (Lkotlinx/coroutines/CancellableContinuation;)V protected final fun setConnectedStateAndContinue (Lorg/openapitools/client/models/VideoEvent;)V protected final fun setDestroyed (Z)V public final fun setReconnectTimeout (J)V @@ -6972,21 +6979,23 @@ public final class org/openapitools/client/models/CallRecordingStoppedEvent : or } public final class org/openapitools/client/models/CallRejectedEvent : org/openapitools/client/models/VideoEvent, org/openapitools/client/models/WSCallEvent { - public fun (Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;)V - public synthetic fun (Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;Ljava/lang/String;)V + public synthetic fun (Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lorg/openapitools/client/models/CallResponse; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Lorg/threeten/bp/OffsetDateTime; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lorg/openapitools/client/models/UserResponse; - public final fun copy (Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;)Lorg/openapitools/client/models/CallRejectedEvent; - public static synthetic fun copy$default (Lorg/openapitools/client/models/CallRejectedEvent;Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;ILjava/lang/Object;)Lorg/openapitools/client/models/CallRejectedEvent; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;Ljava/lang/String;)Lorg/openapitools/client/models/CallRejectedEvent; + public static synthetic fun copy$default (Lorg/openapitools/client/models/CallRejectedEvent;Lorg/openapitools/client/models/CallResponse;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Lorg/openapitools/client/models/UserResponse;Ljava/lang/String;ILjava/lang/Object;)Lorg/openapitools/client/models/CallRejectedEvent; public fun equals (Ljava/lang/Object;)Z public final fun getCall ()Lorg/openapitools/client/models/CallResponse; public fun getCallCID ()Ljava/lang/String; public final fun getCallCid ()Ljava/lang/String; public final fun getCreatedAt ()Lorg/threeten/bp/OffsetDateTime; public fun getEventType ()Ljava/lang/String; + public final fun getReason ()Ljava/lang/String; public final fun getType ()Ljava/lang/String; public final fun getUser ()Lorg/openapitools/client/models/UserResponse; public fun hashCode ()I 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 3c7f3bf70a..551e11de58 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 @@ -40,6 +40,7 @@ import io.getstream.video.android.core.model.Ingress import io.getstream.video.android.core.model.NetworkQuality import io.getstream.video.android.core.model.RTMP 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.permission.PermissionRequest @@ -567,7 +568,22 @@ public class CallState( val new = _rejectedBy.value.toMutableSet() new.add(event.user.id) _rejectedBy.value = new.toSet() - updateRingingState() + + Log.d( + "RingingStateDebug", + "CallRejectedEvent. Rejected by: ${event.user.id}. Reason: ${event.reason}. Will call updateRingingState().", + ) + + updateRingingState( + rejectReason = event.reason?.let { + when (it) { + RejectReason.Busy.alias -> RejectReason.Busy + RejectReason.Cancel.alias -> RejectReason.Cancel + RejectReason.Decline.alias -> RejectReason.Decline + else -> RejectReason.Custom(alias = it) + } + }, + ) } is CallEndedEvent -> { @@ -853,31 +869,29 @@ public class CallState( } } - private fun updateRingingState(timeout: Boolean = false) { + private fun updateRingingState(rejectReason: RejectReason? = null) { // this is only true when we are in the session (we have accepted/joined the call) - val userIsParticipant = - _session.value?.participants?.find { it.user.id == client.userId } != null - val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size val rejectedBy = _rejectedBy.value + val isRejectedByMe = _rejectedBy.value.contains(client.userId) val acceptedBy = _acceptedBy.value + val isAcceptedByMe = _acceptedBy.value.contains(client.userId) val createdBy = _createdBy.value - val members = _members.value - val rejectedByMe = _rejectedBy.value.findLast { it == client.userId } - val acceptedByMe = _acceptedBy.value.findLast { it == client.userId } val hasActiveCall = client.state.activeCall.value != null val hasRingingCall = client.state.ringingCall.value != null + val userIsParticipant = + _session.value?.participants?.find { it.user.id == client.userId } != null + val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size Log.d("RingingState", "Current: ${_ringingState.value}") - val rejectedByMeBool = !rejectedByMe.isNullOrBlank() Log.d( "RingingState", "Flags: [\n" + - "acceptedByMe: ${!acceptedByMe.isNullOrBlank()},\n" + - "rejectedByMe: $rejectedByMeBool,\n" + + "acceptedByMe: $isAcceptedByMe,\n" + + "rejectedByMe: $isRejectedByMe,\n" + + "rejectReason: $rejectReason,\n" + "hasActiveCall: $hasActiveCall\n" + "hasRingingCall: $hasRingingCall\n" + "userIsParticipant: $userIsParticipant,\n" + - "timeout: $timeout\n" + "]", ) @@ -885,16 +899,13 @@ public class CallState( val state: RingingState = if (hasActiveCall) { cancelTimeout() RingingState.Active - } else if (rejectedBy.isNotEmpty() && acceptedBy.isEmpty() && rejectedBy.size >= outgoingMembersCount) { - // Call leave same as CallEndedEvent we do not want to receive updates anymore, - // since we or the caller timed-out or the call was rejected. - // We leave the call, we do not depend on the SDK user to call leave() + } else if (rejectedBy.isNotEmpty() && rejectedBy.size >= outgoingMembersCount) { call.leave() - if (timeout || !rejectedByMeBool) { - cancelTimeout() + cancelTimeout() + + if (rejectReason?.alias == REJECT_REASON_TIMEOUT) { RingingState.TimeoutNoAnswer } else { - cancelTimeout() RingingState.RejectedByAll } } else if (hasRingingCall && createdBy?.id != client.userId) { @@ -904,7 +915,7 @@ public class CallState( cancelTimeout() RingingState.Active } else { - RingingState.Incoming(acceptedByMe = acceptedByMe != null) + RingingState.Incoming(acceptedByMe = isAcceptedByMe) } } else if (hasRingingCall && createdBy?.id == client.userId) { // The call is created by us @@ -919,12 +930,6 @@ public class CallState( cancelTimeout() RingingState.Active } - } else if (timeout) { - // It was an outgoing, or incoming call, but timeout was reached - // Call leave same as CallEndedEvent we do not want to receive updates anymore - call.leave() - cancelTimeout() - RingingState.TimeoutNoAnswer } else { RingingState.Idle } @@ -944,6 +949,11 @@ public class CallState( // stop the call ringing timer if it's running } Log.d("RingingState", "Update: $state") + + Log.d("RingingStateDebug", "updateRingingState 1. Called by: ${getCallingMethod()}") + Log.d("RingingStateDebug", "updateRingingState 2. New state: $state") + Log.d("RingingStateDebug", "-------------------") + _ringingState.value = state } @@ -990,9 +1000,9 @@ public class CallState( // double check that we are still in Outgoing call state and call is not active if (_ringingState.value is RingingState.Outgoing || _ringingState.value is RingingState.Incoming && client.state.activeCall.value == null) { - call.reject() - call.leave() - updateRingingState(true) + call.reject(reason = RejectReason.Custom(alias = REJECT_REASON_TIMEOUT)) + + Log.d("RingingStateDebug", "startRingingTimer. Timeout.") } } else { logger.w { "[startRingingTimer] No autoCancelTimeoutMs set - call ring with no timeout" } @@ -1282,3 +1292,17 @@ private fun MemberResponse.toMemberState(): MemberState { deletedAt = deletedAt, ) } + +private fun getCallingMethod(): String { + val stackTrace = Thread.currentThread().stackTrace + + return if (stackTrace.isEmpty()) { + "" + } else { + stackTrace[6].let { callingMethod -> + callingMethod.className + "." + callingMethod.methodName + } + } +} + +private const val REJECT_REASON_TIMEOUT = "timeout" 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 0fe0daf8eb..d76d8ac040 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 @@ -28,6 +28,7 @@ import org.openapitools.client.models.CallCreatedEvent import org.openapitools.client.models.CallRingEvent import org.openapitools.client.models.ConnectedEvent import org.openapitools.client.models.VideoEvent +import java.net.ConnectException @Stable public sealed interface ConnectionState { @@ -36,7 +37,7 @@ public sealed interface ConnectionState { public data object Connected : ConnectionState public data object Reconnecting : ConnectionState public data object Disconnected : ConnectionState - public class Failed(error: Error) : ConnectionState + public class Failed(val error: Error) : ConnectionState } @Stable @@ -57,11 +58,12 @@ class ClientState(client: StreamVideo) { private val _user: MutableStateFlow = MutableStateFlow(client.user) public val user: StateFlow = _user - /** - * connectionState shows if we've established a connection with the coordinator - */ private val _connection: MutableStateFlow = MutableStateFlow(ConnectionState.PreConnect) + + /** + * Shows the Coordinator connection state + */ public val connection: StateFlow = _connection /** @@ -102,6 +104,12 @@ class ClientState(client: StreamVideo) { } } + internal fun handleError(error: Throwable) { + if (error is ConnectException) { + _connection.value = ConnectionState.Failed(error = Error(error)) + } + } + fun setActiveCall(call: Call) { removeRingingCall() maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) 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 051c81dde6..d93bd1a5a1 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 @@ -38,6 +38,8 @@ import io.getstream.video.android.model.UserToken import io.getstream.video.android.model.UserType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import java.lang.RuntimeException +import java.net.ConnectException /** * The [StreamVideoBuilder] is used to create a new instance of the [StreamVideo] client. This is the @@ -50,28 +52,33 @@ import kotlinx.coroutines.launch * geo = GEO.GlobalEdgeNetwork, * user = user, * token = token, - * loggingLevel = LoggingLevel.BODY - * ) + * loggingLevel = LoggingLevel.BODY, + * // ... + * ).build() *``` * * @property context Android [Context] to be used for initializing Android resources. - * @property apiKey Your Stream API Key, you can find it in the dashboard. - * @property geo Your GEO routing policy, supports geofencing for privacy concerns. - * @property user The user object, can be a regular user, guest user or anonymous. - * @property token The token for this user generated using your API secret on your server. - * @property tokenProvider If a token is expired, the token provider makes a request to your backend for a new token. + * @property apiKey Your Stream API Key. You can find it in the dashboard. + * @property geo Your GEO routing policy. Supports geofencing for privacy concerns. + * @property user The user object. Can be an authenticated user, guest user or anonymous. + * @property token The token for this user, generated using your API secret on your server. + * @property tokenProvider Used to make a request to your backend for a new token when the token has expired. * @property loggingLevel Represents and wraps the HTTP logging level for our API service. * @property notificationConfig The configurations for handling push notification. * @property ringNotification Overwrite the default notification logic for incoming calls. * @property connectionTimeoutInMs Connection timeout in seconds. - * @property ensureSingleInstance Verify that only 1 version of the video client exists, prevents integration mistakes. + * @property ensureSingleInstance Verify that only 1 version of the video client exists. Prevents integration mistakes. * @property videoDomain URL overwrite to allow for testing against a local instance of video. * @property runForegroundServiceForCalls If set to true, when there is an active call the SDK will run a foreground service to keep the process alive. (default: true) * @property localSfuAddress Local SFU address (IP:port) to be used for testing. Leave null if not needed. * @property sounds Overwrite the default SDK sounds. See [Sounds]. - * @property crashOnMissingPermission if [permissionCheck] returns false there will be an exception. - * @property permissionCheck used to check for system permission based on call capabilities. See [StreamPermissionCheck]. - * @property audioUsage used to signal to the system how to treat the audio tracks (voip or media). + * @property crashOnMissingPermission If [permissionCheck] returns false there will be an exception. + * @property permissionCheck Used to check for system permission based on call capabilities. See [StreamPermissionCheck]. + * @property audioUsage Used to signal to the system how to treat the audio tracks (voip or media). + * + * @see build + * @see ClientState.connection + * */ public class StreamVideoBuilder @JvmOverloads constructor( context: Context, @@ -94,21 +101,31 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val audioUsage: Int = defaultAudioUsage, ) { private val context: Context = context.applicationContext - - val scope = CoroutineScope(DispatcherProvider.IO) - + private val scope = CoroutineScope(DispatcherProvider.IO) + + /** + * Builds the [StreamVideo] client. + * + * @return The [StreamVideo] client. + * + * @throws RuntimeException If an instance of the client already exists and [ensureSingleInstance] is set to true. + * @throws IllegalArgumentException If [apiKey] is blank. + * @throws IllegalArgumentException If [user] type is [UserType.Authenticated] and the [user] id is blank. + * @throws IllegalArgumentException If [user] type is [UserType.Authenticated] and both [token] and [tokenProvider] are empty. + * @throws ConnectException If the WebSocket connection fails. + */ public fun build(): StreamVideo { val lifecycle = ProcessLifecycleOwner.get().lifecycle val existingInstance = StreamVideo.instanceOrNull() if (existingInstance != null && ensureSingleInstance) { - throw IllegalArgumentException( + throw RuntimeException( "Creating 2 instance of the video client will cause bugs with call.state. Before creating a new client, please remove the old one. You can remove the old client using StreamVideo.removeClient()", ) } if (apiKey.isBlank()) { - throw IllegalArgumentException("The API key can not be empty") + throw IllegalArgumentException("The API key cannot be blank") } if (token.isBlank() && tokenProvider == null && user.type == UserType.Authenticated) { @@ -117,21 +134,21 @@ public class StreamVideoBuilder @JvmOverloads constructor( ) } - if (user.type == UserType.Authenticated && user.id.isEmpty()) { + if (user.type == UserType.Authenticated && user.id.isBlank()) { throw IllegalArgumentException( - "Please specify the user id for authenticated users", + "The user ID cannot be empty for authenticated users", ) } - if (user.role.isEmpty()) { + if (user.role.isNullOrBlank()) { user = user.copy(role = "user") } - /** initialize Stream internal loggers. */ + // Initialize Stream internal loggers StreamLog.install(AndroidStreamLogger()) StreamLog.setValidator { priority, _ -> priority.level >= loggingLevel.priority.level } - /** android JSR-310 backport backport. */ + // Android JSR-310 backport backport AndroidThreeTen.init(context) // This connection module class exposes the connections to the various retrofit APIs. @@ -148,7 +165,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( val deviceTokenStorage = DeviceTokenStorage(context) - // install the StreamNotificationManager to configure push notifications. + // Install the StreamNotificationManager to configure push notifications. val streamNotificationManager = StreamNotificationManager.install( context = context, scope = scope, @@ -157,7 +174,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( deviceTokenStorage = deviceTokenStorage, ) - // create the client + // Create the client val client = StreamVideoImpl( context = context, _scope = scope, @@ -183,25 +200,30 @@ public class StreamVideoBuilder @JvmOverloads constructor( connectionModule.updateAuthType("anonymous") } - // establish a ws connection with the coordinator (we don't support this for anonymous users) + // Establish a WS connection with the coordinator (we don't support this for anonymous users) if (user.type != UserType.Anonymous) { scope.launch { - val result = client.connectAsync().await() - result.onSuccess { - streamLog { "connection succeed! (duration: ${result.getOrNull()})" } - }.onError { - streamLog { it.message } + try { + val result = client.connectAsync().await() + result.onSuccess { + streamLog { "Connection succeeded! (duration: ${result.getOrNull()})" } + }.onError { + streamLog { it.message } + } + } catch (e: Exception) { + // If the connect continuation was resumed with an exception, we catch it here. + streamLog { e.message.orEmpty() } } } } - // see which location is best to connect to + // See which location is best to connect to scope.launch { val location = client.loadLocationAsync().await() streamLog { "location initialized: ${location.getOrNull()}" } } - // installs Stream Video instance + // Installs Stream Video instance StreamVideo.install(client) // Needs to be started after the client is initialised because the VideoPushDelegate 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 f1f1176ded..71f0a15af1 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 @@ -121,6 +121,7 @@ import org.openapitools.client.models.UserRequest import org.openapitools.client.models.VideoEvent import org.openapitools.client.models.WSCallEvent import retrofit2.HttpException +import java.net.ConnectException import java.util.* import kotlin.coroutines.Continuation import kotlin.coroutines.resumeWithException @@ -356,8 +357,12 @@ internal class StreamVideoImpl internal constructor( } scope.launch { connectionModule.coordinatorSocket.errors.collect { throwable -> - (throwable as? ErrorResponse)?.let { - if (it.code == VideoErrorCode.TOKEN_EXPIRED.code) refreshToken(it) + if (throwable is ConnectException) { + state.handleError(throwable) + } else { + (throwable as? ErrorResponse)?.let { + if (it.code == VideoErrorCode.TOKEN_EXPIRED.code) refreshToken(it) + } } } } @@ -461,9 +466,9 @@ internal class StreamVideoImpl internal constructor( val response = createGuestUser( userRequest = UserRequest( id = user.id, - image = user.image.takeUnless { it.isBlank() }, - name = user.name.takeUnless { it.isBlank() }, - custom = user.custom.takeUnless { it.isEmpty() }, + image = user.image, + name = user.name, + custom = user.custom, ), ) if (response.isFailure) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt index 54c5b2a8a2..d965070c5d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt @@ -120,7 +120,7 @@ public data class ToggleScreenConfiguration( /** * Used to set the state to showing call participant info. */ -public object ShowCallParticipantInfo : CallAction +public data object ShowCallParticipantInfo : CallAction /** * Custom action used to handle any custom behavior with the given [data] and [tag], such as opening chat, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/model/VideoModel.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/model/VideoModel.kt index c93f31419f..03692fc630 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/model/VideoModel.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/model/VideoModel.kt @@ -27,10 +27,10 @@ import java.util.Date @Stable public data class CallUser( val id: String, - val name: String, - val role: String, - val imageUrl: String, - val teams: List, + val name: String? = null, + val role: String? = null, + val imageUrl: String? = null, + val teams: List? = null, val state: CallUserState?, val createdAt: Date?, val updatedAt: Date?, @@ -133,13 +133,13 @@ public infix fun CallUser.merge(that: CallUser?): CallUser = when (that) { null -> this else -> copy( id = that.id.ifEmpty { this.id }, - name = that.name.ifEmpty { this.name }, - role = that.role.ifEmpty { this.role }, - imageUrl = that.imageUrl.ifEmpty { this.imageUrl }, + name = that.name.takeUnless { it.isNullOrBlank() } ?: this.name, + role = that.role.takeUnless { it.isNullOrBlank() } ?: this.role, + imageUrl = that.imageUrl.takeUnless { it.isNullOrBlank() } ?: this.imageUrl, state = that.state merge this.state, createdAt = that.createdAt ?: this.createdAt, updatedAt = that.updatedAt ?: this.updatedAt, - teams = (that.teams + this.teams).distinct(), + teams = (that.teams.orEmpty() + this.teams.orEmpty()).distinct(), ) } 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 86a77491f7..f94a4f5582 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 @@ -38,6 +38,7 @@ 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.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 import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INCOMING_CALL_NOTIFICATION_ID import io.getstream.video.android.core.notifications.internal.DefaultStreamIntentResolver @@ -66,7 +67,7 @@ public open class DefaultNotificationHandler( private val logger by taggedLogger("Call:NotificationHandler") private val intentResolver = DefaultStreamIntentResolver(application) - private val notificationManager: NotificationManagerCompat by lazy { + protected val notificationManager: NotificationManagerCompat by lazy { NotificationManagerCompat.from(application).also { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { it.createNotificationChannel( @@ -91,14 +92,16 @@ public open class DefaultNotificationHandler( override fun onMissedCall(callId: StreamCallId, callDisplayName: String) { logger.d { "[onMissedCall] #ringing; callId: ${callId.id}" } val notificationId = callId.hashCode() - intentResolver.searchNotificationCallPendingIntent(callId, notificationId) - ?.let { notificationPendingIntent -> - showMissedCallNotification( - notificationPendingIntent, - callDisplayName, - notificationId, - ) - } ?: logger.e { "Couldn't find any activity for $ACTION_NOTIFICATION" } + val intent = intentResolver.searchMissedCallPendingIntent(callId, notificationId) + ?: run { + logger.e { "Couldn't find any activity for $ACTION_MISSED_CALL" } + intentResolver.getDefaultPendingIntent() + } + showMissedCallNotification( + intent, + callDisplayName, + notificationId, + ) } override fun getRingingCallNotification( 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 590deff590..407f2ed3fc 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 @@ -36,6 +36,7 @@ public interface NotificationHandler : NotificationPermissionHandler { companion object { const val ACTION_NOTIFICATION = "io.getstream.video.android.action.NOTIFICATION" + const val ACTION_MISSED_CALL = "io.getstream.video.android.action.MISSED_CALL" const val ACTION_LIVE_CALL = "io.getstream.video.android.action.LIVE_CALL" const val ACTION_INCOMING_CALL = "io.getstream.video.android.action.INCOMING_CALL" const val ACTION_OUTGOING_CALL = "io.getstream.video.android.action.OUTGOING_CALL" diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/DefaultStreamIntentResolver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/DefaultStreamIntentResolver.kt index 12de2ac5ba..624b94d709 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/DefaultStreamIntentResolver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/DefaultStreamIntentResolver.kt @@ -84,6 +84,36 @@ internal class DefaultStreamIntentResolver(val context: Context) { notificationId, ) + /** + * Search for an activity that can receive missed calls from Stream Server. + * + * @param callId The call id from the incoming call. + */ + internal fun searchMissedCallPendingIntent( + callId: StreamCallId, + notificationId: Int, + ): PendingIntent? = + searchActivityPendingIntent( + Intent(NotificationHandler.ACTION_MISSED_CALL), + callId, + notificationId, + ) + + internal fun getDefaultPendingIntent(): PendingIntent { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: Intent(Intent.ACTION_MAIN).apply { + setPackage(context.packageName) + addCategory(Intent.CATEGORY_LAUNCHER) + } + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + /** * Search for an activity that can receive live calls from Stream Server. * diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/CoordinatorSocket.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/CoordinatorSocket.kt index f795338aac..76d280323c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/CoordinatorSocket.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/CoordinatorSocket.kt @@ -20,6 +20,7 @@ import com.squareup.moshi.JsonAdapter import io.getstream.log.taggedLogger import io.getstream.video.android.core.dispatchers.DispatcherProvider import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.getstream.video.android.core.utils.isWhitespaceOnly import io.getstream.video.android.model.User import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -68,9 +69,9 @@ public class CoordinatorSocket( token = token, userDetails = ConnectUserDetailsRequest( id = user.id, - name = user.name.takeUnless { it.isBlank() }, - image = user.image.takeUnless { it.isBlank() }, - custom = user.custom.takeUnless { it.isEmpty() }, + name = user.name.takeUnless { it.isWhitespaceOnly() }, + image = user.image.takeUnless { it.isWhitespaceOnly() }, + custom = user.custom, ), ) val message = adapter.toJson(authRequest) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/PersistentSocket.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/PersistentSocket.kt index 064a1b27b0..e874d9d51e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/PersistentSocket.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/PersistentSocket.kt @@ -39,6 +39,7 @@ import org.openapitools.client.models.VideoEvent import stream.video.sfu.event.HealthCheckRequest import java.io.IOException import java.io.InterruptedIOException +import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.concurrent.Executors @@ -90,12 +91,12 @@ public open class PersistentSocket( val connectionId: StateFlow = _connectionId /** Continuation if the socket successfully connected and we've authenticated */ - lateinit var connected: CancellableContinuation + lateinit var connectContinuation: CancellableContinuation - internal var socket: WebSocket? = null + // Controls when we can resume the continuation + private var connectContinuationCompleted: Boolean = false - // prevent us from resuming the continuation twice - private var continuationCompleted: Boolean = false + internal var socket: WebSocket? = null // True if cleanup was called and socket is completely destroyed (intentionally). // You need to create a new instance (this is mainly used for the SfuSocket which is tied @@ -105,7 +106,11 @@ public open class PersistentSocket( internal var reconnectionAttempts = 0 /** - * Connect the socket, authenticate, start the healthmonitor and see if the network is online + * Connect the socket, authenticate, start the health monitor and see if the network is online + * @param invocation Provides a way to extend the [connect] method with additional behavior. + * This can be useful in cases where additional setup or checks need to be performed + * once the socket is connected but before the [connect] method returns. + * To return from [connect] and to resume the enclosing coroutine, use the provided [CancellableContinuation] parameter. */ open suspend fun connect( invocation: (CancellableContinuation) -> Unit = {}, @@ -115,9 +120,9 @@ public open class PersistentSocket( return null } - return suspendCancellableCoroutine { connectedContinuation -> + return suspendCancellableCoroutine { continuation -> logger.i { "[connect]" } - connected = connectedContinuation + connectContinuation = continuation _connectionState.value = SocketState.Connecting @@ -138,7 +143,7 @@ public open class PersistentSocket( networkStateProvider.subscribe(networkStateListener) // run the invocation - invocation.invoke(connectedContinuation) + invocation.invoke(continuation) } } } @@ -171,7 +176,7 @@ public open class PersistentSocket( is DisconnectReason.PermanentError -> SocketState.DisconnectedPermanently(disconnectReason.error) } - continuationCompleted = false + connectContinuationCompleted = false disconnectSocket() healthMonitor.stop() @@ -215,7 +220,16 @@ public open class PersistentSocket( reconnectionAttempts++ - connect() + tryConnect() + } + + private suspend fun tryConnect() { + try { + connectContinuationCompleted = false + connect() + } catch (e: Exception) { + logger.e { "[reconnect] failed to reconnect: $e" } + } } open fun authenticate() { } @@ -270,17 +284,41 @@ public open class PersistentSocket( protected fun setConnectedStateAndContinue(message: VideoEvent) { _connectionState.value = SocketState.Connected(message) - if (!continuationCompleted) { - continuationCompleted = true - connected.resume(message as T) + if (!connectContinuationCompleted) { + connectContinuationCompleted = true + connectContinuation.resume(message as T) } } - private fun setPermanentFailureAndContinue(error: Throwable) { - _connectionState.value = SocketState.DisconnectedPermanently(error) - if (!continuationCompleted) { - continuationCompleted = true - connected.resumeWithException(error) + internal fun handleError(error: Throwable) { + // onFailure, onClosed and the 2 onMessage can all generate errors + // temporary errors should be logged and retried + // permanent errors should be emitted so the app can decide how to handle it + if (destroyed) { + logger.d { "[handleError] Ignoring socket error - already closed $error" } + return + } + + val permanentError = isPermanentError(error) + if (permanentError) { + logger.e { "[handleError] Permanent error: $error" } + + _connectionState.value = SocketState.DisconnectedPermanently(error) + + // If the connect continuation is not completed, it means the error happened during the connection phase. + connectContinuationCompleted.not().let { isConnectionPhaseError -> + if (isConnectionPhaseError) { + emitError(error, isConnectionPhaseError = true) + resumeConnectionPhaseWithException(error) + } else { + emitError(error, isConnectionPhaseError = false) + } + } + } else { + logger.w { "[handleError] Temporary error: $error" } + + _connectionState.value = SocketState.DisconnectedTemporarily(error) + scope.launch { reconnect(reconnectTimeout) } } } @@ -306,31 +344,25 @@ public open class PersistentSocket( return isPermanent } - internal fun handleError(error: Throwable) { - // onFailure, onClosed and the 2 onMessage can all generate errors - // temporary errors should be logged and retried - // permanent errors should be emitted so the app can decide how to handle it - if (destroyed) { - logger.d { "[handleError] Ignoring socket error - already closed $error" } - return - } - - val permanentError = isPermanentError(error) - if (permanentError) { - // close the connection loop - setPermanentFailureAndContinue(error) - logger.e { "[handleError] permanent error: $error" } - // mark us permanently disconnected - scope.launch { + private fun emitError(error: Throwable, isConnectionPhaseError: Boolean) { + scope.launch { + if (isConnectionPhaseError) { + errors.emit( + ConnectException( + "Failed to establish WebSocket connection. Will try to reconnect. Cause: ${error.message}", + ), + ) + } else { errors.emit(error) } - } else { - logger.w { "[handleError] temporary error: $error" } - _connectionState.value = SocketState.DisconnectedTemporarily(error) - scope.launch { reconnect(reconnectTimeout) } } } + private fun resumeConnectionPhaseWithException(error: Throwable) { + connectContinuationCompleted = true + connectContinuation.resumeWithException(error) + } + /** * Invoked when the remote peer has indicated that no more incoming messages will be transmitted. */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/DomainUtils.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/DomainUtils.kt index 8e230c2181..a0ff1cd574 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/DomainUtils.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/DomainUtils.kt @@ -94,8 +94,8 @@ internal fun UserResponse.toUser(): User { return User( id = id, role = role, - name = name ?: "", - image = image ?: "", + name = name, + image = image, teams = teams, custom = custom.mapValues { it.value.toString() }, ) @@ -182,4 +182,4 @@ internal fun EdgeResponse.toEdge(): EdgeData { @JvmSynthetic @InternalStreamVideoApi -fun CallUser.getNameOrId(): String = name.ifEmpty { id } +fun CallUser.getNameOrId(): String = name.takeUnless { it.isNullOrBlank() } ?: id diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StringUtils.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StringUtils.kt index f2e8508462..e13ae83977 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StringUtils.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StringUtils.kt @@ -28,3 +28,7 @@ internal fun StreamPeerType.stringify() = when (this) { StreamPeerType.PUBLISHER -> "publisher" StreamPeerType.SUBSCRIBER -> "subscriber" } + +internal fun String?.isWhitespaceOnly(): Boolean { + return !this.isNullOrEmpty() && this.all { it.isWhitespace() } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/UserUtils.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/UserUtils.kt index a225ffd09f..f96a9ac04c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/UserUtils.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/UserUtils.kt @@ -23,11 +23,11 @@ import org.threeten.bp.OffsetDateTime internal fun User.toResponse(): UserResponse { return UserResponse( id = id, - role = role, + role = role ?: "user", name = name, image = image, - teams = teams, - custom = custom, + teams = teams ?: emptyList(), + custom = custom ?: emptyMap(), createdAt = createdAt ?: OffsetDateTime.now(), updatedAt = updatedAt ?: OffsetDateTime.now(), deletedAt = deletedAt, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/User.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/User.kt index 3a4009143c..456f70eaa6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/User.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/model/User.kt @@ -70,12 +70,12 @@ public object OffsetDateTimeSerializer : KSerializer { public data class User( /** ID is required, the rest is optional */ val id: String = "", - val role: String = "user", + val role: String? = "user", val type: UserType = UserType.Authenticated, - val name: String = "", - val image: String = "", - val teams: List = emptyList(), - val custom: Map = emptyMap(), + val name: String? = null, + val image: String? = null, + val teams: List? = emptyList(), + val custom: Map? = emptyMap(), @Serializable(with = OffsetDateTimeSerializer::class) val createdAt: OffsetDateTime? = null, @Serializable(with = OffsetDateTimeSerializer::class) @@ -88,7 +88,7 @@ public data class User( } public val userNameOrId: String - inline get() = name.ifEmpty { id } + inline get() = name.takeUnless { it.isNullOrBlank() } ?: id companion object { diff --git a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallRejectedEvent.kt b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallRejectedEvent.kt index 3964ee1119..f327c14165 100644 --- a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallRejectedEvent.kt +++ b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallRejectedEvent.kt @@ -45,6 +45,7 @@ import org.openapitools.client.infrastructure.Serializer * @param createdAt * @param type The type of event: \"call.rejected\" in this case * @param user + * @param reason */ @@ -64,7 +65,10 @@ data class CallRejectedEvent ( val type: kotlin.String = "call.rejected", @Json(name = "user") - val user: UserResponse + val user: UserResponse, + + @Json(name = "reason") + val reason: kotlin.String? = null ) : VideoEvent(), WSCallEvent { diff --git a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/UserResponse.kt b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/UserResponse.kt index 315887a767..8ce9cf47e2 100644 --- a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/UserResponse.kt +++ b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/UserResponse.kt @@ -59,7 +59,7 @@ import org.openapitools.client.infrastructure.Serializer data class UserResponse ( @Json(name = "banned") - val banned: kotlin.Boolean, + val banned: kotlin.Boolean = false, /* Date/time of creation */ @Json(name = "created_at") diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientAndAuthTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientAndAuthTest.kt index f91b09df0c..c9c043a715 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientAndAuthTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientAndAuthTest.kt @@ -208,7 +208,7 @@ class ClientAndAuthTest : TestBase() { // } } - @Test(expected = IllegalArgumentException::class) + @Test(expected = RuntimeException::class) fun `two clients is not allowed`() = runTest { val builder = StreamVideoBuilder( context = context, diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventTest.kt index 1623f8b5a3..2b4b71c794 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventTest.kt @@ -317,11 +317,11 @@ class EventTest : IntegrationTestBase(connectCoordinatorWS = false) { private fun io.getstream.video.android.model.User.toUserResponse(): UserResponse { return UserResponse( id = id, - role = role, - teams = teams, + role = role ?: "user", + teams = teams ?: emptyList(), image = image, name = name, - custom = custom, + custom = custom ?: emptyMap(), createdAt = OffsetDateTime.now(), updatedAt = OffsetDateTime.now(), deletedAt = OffsetDateTime.now(), diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SocketTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SocketTest.kt index 678d3cd308..1440dbec9b 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SocketTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/SocketTest.kt @@ -197,7 +197,7 @@ class CoordinatorSocketTest : SocketTestBase() { ) socket.connect() - socket.connected.cancel() + socket.connectContinuation.cancel() // create a VideoEvent type that resembles a real one, but doesn't contain the necessary fields val testJson = "{\"type\":\"health.check\"}" diff --git a/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api b/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api index e6b2102118..6daa63645b 100644 --- a/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api +++ b/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api @@ -559,7 +559,7 @@ public final class io/getstream/video/android/compose/ui/components/avatar/UserA } public final class io/getstream/video/android/compose/ui/components/background/CallBackgroundKt { - public static final fun CallBackground (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun CallBackground (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/video/android/compose/ui/components/background/ComposableSingletons$CallBackgroundKt { @@ -1633,8 +1633,8 @@ public final class io/getstream/video/android/compose/ui/components/call/ringing } public final class io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContentKt { - public static final fun IncomingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZLjava/util/List;ZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V - public static final fun IncomingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun IncomingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZLjava/util/List;ZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final fun IncomingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallControlsKt { @@ -1669,8 +1669,8 @@ public final class io/getstream/video/android/compose/ui/components/call/ringing } public final class io/getstream/video/android/compose/ui/components/call/ringing/outgoingcall/OutgoingCallContentKt { - public static final fun OutgoingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZLjava/util/List;ZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V - public static final fun OutgoingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun OutgoingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZLjava/util/List;ZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final fun OutgoingCallContent (Landroidx/compose/ui/Modifier;Lio/getstream/video/android/core/Call;ZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/video/android/compose/ui/components/call/ringing/outgoingcall/OutgoingCallControlsKt { diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt index 066c59b6ad..eab4ff94fd 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt @@ -103,7 +103,7 @@ private fun UserAvatarBackgroundPreview() { UserAvatarBackground( modifier = Modifier.fillMaxSize(), userImage = user.image, - userName = user.name.ifBlank { user.id }, + userName = user.name.takeUnless { it.isNullOrBlank() } ?: user.id, previewModePlaceholder = R.drawable.stream_video_call_sample, ) } diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/background/CallBackground.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/background/CallBackground.kt index edaebc2144..0c185162f7 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/background/CallBackground.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/background/CallBackground.kt @@ -33,11 +33,13 @@ import io.getstream.video.android.mock.StreamPreviewDataUtils * Renders a call background that shows either a static image or user images based on the call state. * * @param modifier Modifier for styling. + * @param backgroundContent The background content to render. * @param content The content to render on top of the background. */ @Composable public fun CallBackground( modifier: Modifier = Modifier, + backgroundContent: (@Composable BoxScope.() -> Unit)? = null, content: @Composable BoxScope.() -> Unit, ) { Box( @@ -45,6 +47,7 @@ public fun CallBackground( .fillMaxSize() .background(color = VideoTheme.colors.baseSheetTertiary), ) { + backgroundContent?.invoke(this) content() } } diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/lobby/CallLobby.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/lobby/CallLobby.kt index 5494458050..c950b6bf67 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/lobby/CallLobby.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/lobby/CallLobby.kt @@ -206,7 +206,7 @@ private fun OnDisabledContent(user: User) { .size(VideoTheme.dimens.genericMax) .align(Alignment.Center), userImage = user.image, - userName = user.name.ifBlank { user.id }, + userName = user.name.takeUnless { it.isNullOrBlank() } ?: user.id, ) } } diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt index a145206131..0ffe3dde5c 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/incomingcall/IncomingCallContent.kt @@ -50,6 +50,7 @@ import io.getstream.video.android.ui.common.R * @param isVideoType Represent the call type is a video or an audio. * @param modifier Modifier for styling. * @param isShowingHeader Weather or not the app bar will be shown. + * @param backgroundContent Content shown for the call background. * @param headerContent Content shown for the call header. * @param detailsContent Content shown for call details, such as call participant information. * @param controlsContent Content shown for controlling call, such as accepting a call or declining a call. @@ -62,6 +63,7 @@ public fun IncomingCallContent( call: Call, isVideoType: Boolean = true, isShowingHeader: Boolean = true, + backgroundContent: (@Composable BoxScope.() -> Unit)? = null, headerContent: (@Composable ColumnScope.() -> Unit)? = null, detailsContent: ( @Composable ColumnScope.( @@ -87,6 +89,7 @@ public fun IncomingCallContent( isCameraEnabled = isCameraEnabled, isShowingHeader = isShowingHeader, modifier = modifier, + backgroundContent = backgroundContent, headerContent = headerContent, detailsContent = detailsContent, controlsContent = controlsContent, @@ -105,6 +108,7 @@ public fun IncomingCallContent( * @param isCameraEnabled Whether the video should be enabled when entering the call or not. * @param modifier Modifier for styling. * @param isShowingHeader If the app bar header is shown or not. + * @param backgroundContent Content shown for the call background. * @param onBackPressed Handler when the user taps on the back button. * @param onCallAction Handler used when the user interacts with Call UI. */ @@ -116,6 +120,7 @@ public fun IncomingCallContent( participants: List, isCameraEnabled: Boolean, isShowingHeader: Boolean = true, + backgroundContent: (@Composable BoxScope.() -> Unit)? = null, headerContent: (@Composable ColumnScope.() -> Unit)? = null, detailsContent: ( @Composable ColumnScope.( @@ -127,7 +132,10 @@ public fun IncomingCallContent( onBackPressed: () -> Unit = {}, onCallAction: (CallAction) -> Unit = {}, ) { - CallBackground { + CallBackground( + modifier = modifier, + backgroundContent = backgroundContent, + ) { Column { if (isShowingHeader) { headerContent?.invoke(this) diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/outgoingcall/OutgoingCallContent.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/outgoingcall/OutgoingCallContent.kt index ced2713ad8..ab2c235945 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/outgoingcall/OutgoingCallContent.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/ringing/outgoingcall/OutgoingCallContent.kt @@ -47,6 +47,7 @@ import io.getstream.video.android.mock.previewMemberListState * @param isVideoType Represent the call type is a video or an audio. * @param modifier Modifier for styling. * @param isShowingHeader Weather or not the app bar will be shown. + * @param backgroundContent Content shown for the call background. * @param headerContent Content shown for the call header. * @param detailsContent Content shown for call details, such as call participant information. * @param controlsContent Content shown for controlling call, such as accepting a call or declining a call. @@ -59,6 +60,7 @@ public fun OutgoingCallContent( call: Call, isVideoType: Boolean, isShowingHeader: Boolean = true, + backgroundContent: (@Composable BoxScope.() -> Unit)? = null, headerContent: (@Composable ColumnScope.() -> Unit)? = null, detailsContent: ( @Composable ColumnScope.( @@ -78,6 +80,7 @@ public fun OutgoingCallContent( participants = participants, modifier = modifier, isShowingHeader = isShowingHeader, + backgroundContent = backgroundContent, headerContent = headerContent, detailsContent = detailsContent, controlsContent = controlsContent, @@ -107,6 +110,7 @@ public fun OutgoingCallContent( isVideoType: Boolean = true, participants: List, isShowingHeader: Boolean = true, + backgroundContent: (@Composable BoxScope.() -> Unit)? = null, headerContent: (@Composable ColumnScope.() -> Unit)? = null, detailsContent: ( @Composable ColumnScope.( @@ -131,6 +135,7 @@ public fun OutgoingCallContent( CallBackground( modifier = modifier, + backgroundContent = backgroundContent, ) { Column { if (isShowingHeader) { diff --git a/stream-video-android-ui-core/api/stream-video-android-ui-core.api b/stream-video-android-ui-core/api/stream-video-android-ui-core.api index 8ce32542ef..3c7208df4a 100644 --- a/stream-video-android-ui-core/api/stream-video-android-ui-core.api +++ b/stream-video-android-ui-core/api/stream-video-android-ui-core.api @@ -41,6 +41,8 @@ public abstract class io/getstream/video/android/ui/common/StreamCallActivity : public fun get (Lio/getstream/video/android/core/Call;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V public static synthetic fun get$default (Lio/getstream/video/android/ui/common/StreamCallActivity;Lio/getstream/video/android/core/Call;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public fun getConfiguration ()Lio/getstream/video/android/ui/common/StreamCallActivityConfiguration; + protected final fun getOnErrorFinish ()Lkotlin/jvm/functions/Function2; + protected final fun getOnSuccessFinish ()Lkotlin/jvm/functions/Function2; public abstract fun getUiDelegate ()Lio/getstream/video/android/ui/common/StreamActivityUiDelegate; public fun isVideoCall (Lio/getstream/video/android/core/Call;)Z public fun join (Lio/getstream/video/android/core/Call;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V @@ -56,8 +58,8 @@ public abstract class io/getstream/video/android/ui/common/StreamCallActivity : public fun onCreate (Landroid/os/Bundle;Landroid/os/PersistableBundle;Lio/getstream/video/android/core/Call;)V public fun onEnded (Lio/getstream/video/android/core/Call;)V public fun onFailed (Ljava/lang/Exception;)V - public fun onIntentAction (Lio/getstream/video/android/core/Call;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V - public static synthetic fun onIntentAction$default (Lio/getstream/video/android/ui/common/StreamCallActivity;Lio/getstream/video/android/core/Call;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public fun onIntentAction (Lio/getstream/video/android/core/Call;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun onIntentAction$default (Lio/getstream/video/android/ui/common/StreamCallActivity;Lio/getstream/video/android/core/Call;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public fun onLastParticipant (Lio/getstream/video/android/core/Call;)V public final fun onPause ()V public fun onPause (Lio/getstream/video/android/core/Call;)V @@ -72,8 +74,8 @@ public abstract class io/getstream/video/android/ui/common/StreamCallActivity : } public final class io/getstream/video/android/ui/common/StreamCallActivity$Companion { - public final fun callIntent (Landroid/content/Context;Lio/getstream/video/android/model/StreamCallId;Ljava/util/List;ZLjava/lang/String;Ljava/lang/Class;Lio/getstream/video/android/ui/common/StreamCallActivityConfiguration;)Landroid/content/Intent; - public static synthetic fun callIntent$default (Lio/getstream/video/android/ui/common/StreamCallActivity$Companion;Landroid/content/Context;Lio/getstream/video/android/model/StreamCallId;Ljava/util/List;ZLjava/lang/String;Ljava/lang/Class;Lio/getstream/video/android/ui/common/StreamCallActivityConfiguration;ILjava/lang/Object;)Landroid/content/Intent; + public final fun callIntent (Landroid/content/Context;Lio/getstream/video/android/model/StreamCallId;Ljava/util/List;ZLjava/lang/String;Ljava/lang/Class;Lio/getstream/video/android/ui/common/StreamCallActivityConfiguration;Landroid/os/Bundle;)Landroid/content/Intent; + public static synthetic fun callIntent$default (Lio/getstream/video/android/ui/common/StreamCallActivity$Companion;Landroid/content/Context;Lio/getstream/video/android/model/StreamCallId;Ljava/util/List;ZLjava/lang/String;Ljava/lang/Class;Lio/getstream/video/android/ui/common/StreamCallActivityConfiguration;Landroid/os/Bundle;ILjava/lang/Object;)Landroid/content/Intent; } public final class io/getstream/video/android/ui/common/StreamCallActivityConfiguration { 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 2e317364a9..c5f3a27f78 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 @@ -83,6 +83,8 @@ public abstract class StreamCallActivity : ComponentActivity() { * @param members list of members * @param action android action. * @param clazz the class of the Activity + * @param configuration the configuration object + * @param extraData extra data to pass to the activity */ public fun callIntent( context: Context, @@ -92,6 +94,7 @@ public abstract class StreamCallActivity : ComponentActivity() { action: String? = null, clazz: Class, configuration: StreamCallActivityConfiguration = StreamCallActivityConfiguration(), + extraData: Bundle? = null, ): Intent { return Intent(context, clazz).apply { // Setup the outgoing call action @@ -108,6 +111,9 @@ public abstract class StreamCallActivity : ComponentActivity() { val membersArrayList = ArrayList() members.forEach { membersArrayList.add(it) } putStringArrayListExtra(EXTRA_MEMBERS_ARRAY, membersArrayList) + extraData?.also { + putExtras(it) + } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) logger.d { "Created [${clazz.simpleName}] intent. -> $this" } } @@ -119,14 +125,14 @@ public abstract class StreamCallActivity : ComponentActivity() { private var callSocketConnectionMonitor: Job? = null private lateinit var cachedCall: Call private lateinit var config: StreamCallActivityConfiguration - private val onSuccessFinish: suspend (Call) -> Unit = { call -> + protected val onSuccessFinish: suspend (Call) -> Unit = { call -> logger.w { "The call was successfully finished! Closing activity" } onEnded(call) if (configuration.closeScreenOnCallEnded) { finish() } } - private val onErrorFinish: suspend (Exception) -> Unit = { error -> + protected val onErrorFinish: suspend (Exception) -> Unit = { error -> logger.e(error) { "Something went wrong, finishing the activity!" } onFailed(error) if (configuration.closeScreenOnError) { @@ -179,8 +185,9 @@ public abstract class StreamCallActivity : ComponentActivity() { null, onSuccess = { instanceState, persistentState, call, action -> logger.d { "Calling [onCreate(Call)], because call is initialized $call" } - onCreate(instanceState, persistentState, call) - onIntentAction(call, action, onError = onErrorFinish) + onIntentAction(call, action, onError = onErrorFinish) { successCall -> + onCreate(instanceState, persistentState, successCall) + } }, onError = { // We are not calling onErrorFinish here on purpose @@ -203,8 +210,9 @@ public abstract class StreamCallActivity : ComponentActivity() { persistentState, onSuccess = { instanceState, persistedState, call, action -> logger.d { "Calling [onCreate(Call)], because call is initialized $call" } - onCreate(instanceState, persistedState, call) - onIntentAction(call, action, onError = onErrorFinish) + onIntentAction(call, action, onError = onErrorFinish) { successCall -> + onCreate(instanceState, persistedState, successCall) + } }, onError = { // We are not calling onErrorFinish here on purpose @@ -250,22 +258,23 @@ public abstract class StreamCallActivity : ComponentActivity() { call: Call, action: String?, onError: (suspend (Exception) -> Unit)? = onErrorFinish, + onSuccess: (suspend (Call) -> Unit)? = null, ) { logger.d { "[onIntentAction] #ringing; action: $action, call.cid: ${call.cid}" } when (action) { NotificationHandler.ACTION_ACCEPT_CALL -> { logger.v { "[onIntentAction] #ringing; Action ACCEPT_CALL, ${call.cid}" } - accept(call, onError = onError) + accept(call, onError = onError, onSuccess = onSuccess) } NotificationHandler.ACTION_REJECT_CALL -> { logger.v { "[onIntentAction] #ringing; Action REJECT_CALL, ${call.cid}" } - reject(call, onError = onError) + reject(call, onError = onError, onSuccess = onSuccess) } NotificationHandler.ACTION_INCOMING_CALL -> { logger.v { "[onIntentAction] #ringing; Action INCOMING_CALL, ${call.cid}" } - get(call, onError = onError) + get(call, onError = onError, onSuccess = onSuccess) } NotificationHandler.ACTION_OUTGOING_CALL -> { @@ -276,6 +285,7 @@ public abstract class StreamCallActivity : ComponentActivity() { call, members = members, ring = true, + onSuccess = onSuccess, onError = onError, ) } @@ -292,6 +302,7 @@ public abstract class StreamCallActivity : ComponentActivity() { ring = false, onSuccess = { join(call, onError = onError) + onSuccess?.invoke(call) }, onError = onError, ) diff --git a/stream-video-android-ui-xml/src/main/kotlin/io/getstream/video/android/xml/widget/avatar/AvatarView.kt b/stream-video-android-ui-xml/src/main/kotlin/io/getstream/video/android/xml/widget/avatar/AvatarView.kt index 7a6a8d441d..6da95eed2b 100644 --- a/stream-video-android-ui-xml/src/main/kotlin/io/getstream/video/android/xml/widget/avatar/AvatarView.kt +++ b/stream-video-android-ui-xml/src/main/kotlin/io/getstream/video/android/xml/widget/avatar/AvatarView.kt @@ -86,7 +86,7 @@ public class AvatarView : ShapeableImageView { data = user.imageUrl, placeholderDrawable = AvatarPlaceholderDrawable( context = context, - initials = user.name.ifEmpty { user.id }.initials(), + initials = (user.name.takeUnless { it.isNullOrBlank() } ?: user.id).initials(), initialsTextStyle = avatarStyle.avatarInitialsTextStyle, ), ) diff --git a/tutorials/tutorial-ringing/.gitignore b/tutorials/tutorial-ringing/.gitignore new file mode 100644 index 0000000000..aa724b7707 --- /dev/null +++ b/tutorials/tutorial-ringing/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/tutorials/tutorial-ringing/build.gradle.kts b/tutorials/tutorial-ringing/build.gradle.kts new file mode 100644 index 0000000000..498ccf095f --- /dev/null +++ b/tutorials/tutorial-ringing/build.gradle.kts @@ -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. + */ +@file:Suppress("UnstableApiUsage") + +import io.getstream.video.android.Configuration + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.getstream.android.application.compose") + id("io.getstream.spotless") + + id("com.google.gms.google-services") +} + +android { + //namespace = "io.getstream.video.android.tutorial.video" + namespace = "io.getstream.android.samples.ringingcall" + //namespace = "io.getstream.video.android" + compileSdk = Configuration.compileSdk + + defaultConfig { + //applicationId = "io.getstream.video.android.tutorial.video" + applicationId = "io.getstream.android.samples.ringingcall" + //applicationId = "io.getstream.video.android" + minSdk = Configuration.minSdk + targetSdk = Configuration.targetSdk + versionCode = 1 + versionName = "1.0" + } +} + +dependencies { + implementation(project(":stream-video-android-ui-compose")) + + // Push Notification + implementation(libs.stream.push) + implementation(libs.stream.push.firebase) + implementation(platform(libs.firebase.bom)) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.lifecycle.runtime.compose) +} \ No newline at end of file diff --git a/tutorials/tutorial-ringing/google-services.json b/tutorials/tutorial-ringing/google-services.json new file mode 100644 index 0000000000..aec7d71734 --- /dev/null +++ b/tutorials/tutorial-ringing/google-services.json @@ -0,0 +1,46 @@ +{ + "project_info": { + "project_number": "347024607410", + "project_id": "stream-video-9b586", + "storage_bucket": "stream-video-9b586.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:347024607410:android:fc2f5712a8e803828c21ab", + "android_client_info": { + "package_name": "io.getstream.android.samples.ringingcall" + } + }, + "oauth_client": [ + { + "client_id": "347024607410-ett7cjt6ah9aj6s6k20p5fissj80d9la.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyD4FMyTdDv97hJia6YiV1NMgTdJhbnEwQE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "347024607410-ett7cjt6ah9aj6s6k20p5fissj80d9la.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "347024607410-48j4atipav0tcr4pesap4elr1u9t11uh.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.getstream.iOS.VideoDemoApp" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/AndroidManifest.xml b/tutorials/tutorial-ringing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3c14a00087 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/BusyCallActivity.kt b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/BusyCallActivity.kt new file mode 100644 index 0000000000..bf737a08fd --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/BusyCallActivity.kt @@ -0,0 +1,153 @@ +/* + * 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.tutorial.ringing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.ComposeStreamCallActivity +import io.getstream.video.android.compose.ui.StreamCallActivityComposeDelegate +import io.getstream.video.android.compose.ui.components.call.controls.actions.AcceptCallAction +import io.getstream.video.android.compose.ui.components.call.controls.actions.DeclineCallAction +import io.getstream.video.android.compose.ui.components.call.controls.actions.GenericAction +import io.getstream.video.android.compose.ui.components.call.controls.actions.ToggleCameraAction +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.MemberState +import io.getstream.video.android.core.call.state.CallAction +import io.getstream.video.android.core.call.state.CustomAction +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.ui.common.StreamActivityUiDelegate +import io.getstream.video.android.ui.common.StreamCallActivity +import io.getstream.video.android.ui.common.util.StreamCallActivityDelicateApi + +// Extends the ComposeStreamCallActivity class to provide a custom UI for the calling screen. +@Suppress("UNCHECKED_CAST") +class BusyCallActivity : ComposeStreamCallActivity() { + + // Internal delegate to customize the UI aspects of the call. + private val _internalDelegate = CustomUiDelegate() + + // Getter for UI delegate, specifies the custom UI delegate for handling UI related functionality. + override val uiDelegate: StreamActivityUiDelegate + get() = _internalDelegate + + @OptIn(StreamCallActivityDelicateApi::class) + override fun onCallAction(call: Call, action: CallAction) { + when (action) { + is BusyCall -> reject(call, RejectReason.Busy, onSuccessFinish, onErrorFinish) + else -> super.onCallAction(call, action) + } + } + + // Custom delegate class to define specific UI behaviors and layouts for call states. + private class CustomUiDelegate : StreamCallActivityComposeDelegate() { + + @Composable + override fun StreamCallActivity.IncomingCallContent( + modifier: Modifier, + call: Call, + isVideoType: Boolean, + isShowingHeader: Boolean, + headerContent: (@Composable ColumnScope.() -> Unit)?, + detailsContent: ( + @Composable ColumnScope.( + participants: List, + topPadding: Dp, + ) -> Unit + )?, + controlsContent: (@Composable BoxScope.() -> Unit)?, + onBackPressed: () -> Unit, + onCallAction: (CallAction) -> Unit, + ) { + io.getstream.video.android.compose.ui.components.call.ringing.incomingcall.IncomingCallContent( + call = call, + isVideoType = isVideoType, + modifier = modifier, + isShowingHeader = isShowingHeader, + headerContent = headerContent, + detailsContent = detailsContent, + controlsContent = { + val isCameraEnabled by call.camera.isEnabled.collectAsStateWithLifecycle() + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = VideoTheme.dimens.componentHeightM) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + BusyCallAction( + onCallAction = onCallAction, + ) + + DeclineCallAction( + onCallAction = onCallAction, + ) + + if (isVideoType) { + ToggleCameraAction( + onStyle = VideoTheme.styles.buttonStyles.tertiaryIconButtonStyle(), + offStyle = VideoTheme.styles.buttonStyles.secondaryIconButtonStyle(), + isCameraEnabled = isCameraEnabled, + onCallAction = onCallAction, + ) + } + + AcceptCallAction( + onCallAction = onCallAction, + ) + } + }, + onBackPressed = onBackPressed, + onCallAction = onCallAction, + ) + } + } +} + +@Composable +fun BusyCallAction( + modifier: Modifier = Modifier, + enabled: Boolean = true, + onCallAction: (BusyCall) -> Unit, + icon: ImageVector? = null, + bgColor: Color? = null, + iconTint: Color? = null, +): Unit = GenericAction( + modifier = modifier, + enabled = enabled, + onAction = { onCallAction(BusyCall) }, + icon = icon ?: Icons.Default.Close, + color = bgColor ?: VideoTheme.colors.alertWarning, + iconTint = iconTint ?: VideoTheme.colors.basePrimary, +) + +data object BusyCall : CustomAction(tag = "busy") diff --git a/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/CustomCallActivity.kt b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/CustomCallActivity.kt new file mode 100644 index 0000000000..be71cb4d8e --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/CustomCallActivity.kt @@ -0,0 +1,183 @@ +/* + * 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.tutorial.ringing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.ComposeStreamCallActivity +import io.getstream.video.android.compose.ui.StreamCallActivityComposeDelegate +import io.getstream.video.android.compose.ui.components.call.controls.actions.AcceptCallAction +import io.getstream.video.android.compose.ui.components.call.controls.actions.GenericAction +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.MemberState +import io.getstream.video.android.core.call.state.CallAction +import io.getstream.video.android.core.call.state.CustomAction +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.ui.common.StreamActivityUiDelegate +import io.getstream.video.android.ui.common.StreamCallActivity +import io.getstream.video.android.ui.common.util.StreamCallActivityDelicateApi + +// Extends the ComposeStreamCallActivity class to provide a custom UI for the calling screen. +@Suppress("UNCHECKED_CAST") +class CustomCallActivity : ComposeStreamCallActivity() { + + // Internal delegate to customize the UI aspects of the call. + private val _internalDelegate = CustomUiDelegate() + + // Getter for UI delegate, specifies the custom UI delegate for handling UI related functionality. + override val uiDelegate: StreamActivityUiDelegate + get() = _internalDelegate + + @OptIn(StreamCallActivityDelicateApi::class) + override fun onCallAction(call: Call, action: CallAction) { + when (action) { + is CustomRejectCall -> { + reject(call, RejectReason.Custom(action.reason), onSuccessFinish, onErrorFinish) + } + else -> super.onCallAction(call, action) + } + } + + // Custom delegate class to define specific UI behaviors and layouts for call states. + private class CustomUiDelegate : StreamCallActivityComposeDelegate() { + + @Composable + override fun StreamCallActivity.OutgoingCallContent( + modifier: Modifier, + call: Call, + isVideoType: Boolean, + isShowingHeader: Boolean, + headerContent: (@Composable ColumnScope.() -> Unit)?, + detailsContent: ( + @Composable ColumnScope.( + participants: List, + topPadding: Dp, + ) -> Unit + )?, + controlsContent: (@Composable BoxScope.() -> Unit)?, + onBackPressed: () -> Unit, + onCallAction: (CallAction) -> Unit, + ) { + io.getstream.video.android.compose.ui.components.call.ringing.outgoingcall.OutgoingCallContent( + call = call, + isVideoType = isVideoType, + modifier = modifier, + isShowingHeader = isShowingHeader, + headerContent = headerContent, + detailsContent = detailsContent, + controlsContent = { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = VideoTheme.dimens.componentHeightM) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CustomRejectAction( + reason = "custom-cancel-reason", + onCallAction = onCallAction, + ) + } + }, + onBackPressed = onBackPressed, + onCallAction = onCallAction, + ) + } + + @Composable + override fun StreamCallActivity.IncomingCallContent( + modifier: Modifier, + call: Call, + isVideoType: Boolean, + isShowingHeader: Boolean, + headerContent: (@Composable ColumnScope.() -> Unit)?, + detailsContent: ( + @Composable ColumnScope.( + participants: List, + topPadding: Dp, + ) -> Unit + )?, + controlsContent: (@Composable BoxScope.() -> Unit)?, + onBackPressed: () -> Unit, + onCallAction: (CallAction) -> Unit, + ) { + io.getstream.video.android.compose.ui.components.call.ringing.incomingcall.IncomingCallContent( + call = call, + isVideoType = isVideoType, + modifier = modifier, + isShowingHeader = isShowingHeader, + headerContent = headerContent, + detailsContent = detailsContent, + controlsContent = { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = VideoTheme.dimens.componentHeightM) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CustomRejectAction( + reason = "custom-decline-reason", + onCallAction = onCallAction, + ) + + AcceptCallAction( + onCallAction = onCallAction, + ) + } + }, + onBackPressed = onBackPressed, + onCallAction = onCallAction, + ) + } + } +} + +@Composable +public fun CustomRejectAction( + modifier: Modifier = Modifier, + reason: String, + enabled: Boolean = true, + onCallAction: (CustomAction) -> Unit, + icon: ImageVector? = null, + bgColor: Color? = null, + iconTint: Color? = null, +): Unit = GenericAction( + modifier = modifier, + enabled = enabled, + onAction = { onCallAction(CustomRejectCall(reason)) }, + icon = icon ?: Icons.Default.Call, + color = bgColor ?: VideoTheme.colors.alertWarning, + iconTint = iconTint ?: VideoTheme.colors.basePrimary, +) + +data class CustomRejectCall(val reason: String) : CustomAction(tag = "custom-reject") diff --git a/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/CustomNotificationHandler.kt b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/CustomNotificationHandler.kt new file mode 100644 index 0000000000..8ab6f22e29 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/CustomNotificationHandler.kt @@ -0,0 +1,58 @@ +/* + * 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.tutorial.ringing + +import android.annotation.SuppressLint +import android.app.Application +import android.app.PendingIntent +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.getstream.android.samples.ringingcall.R +import io.getstream.video.android.core.notifications.DefaultNotificationHandler +import io.getstream.video.android.model.StreamCallId + +class CustomNotificationHandler( + private val application: Application, +) : DefaultNotificationHandler( + application = application, + hideRingingNotificationInForeground = true, +) { + + override fun onRingingCall(callId: StreamCallId, callDisplayName: String) { + super.onRingingCall(callId, callDisplayName) + } + + @SuppressLint("MissingPermission") + override fun onMissedCall(callId: StreamCallId, callDisplayName: String) { + val notification = NotificationCompat.Builder(application, getChannelId()) + .setSmallIcon(R.drawable.round_call_missed_24) + .setContentIntent(buildContentIntent()) + .setContentTitle("Tutorial Missed Call from $callDisplayName") + .setAutoCancel(true) + .build() + notificationManager.notify(callId.hashCode(), notification) + } + + private fun buildContentIntent() = PendingIntent.getActivity( + application, + 0, + Intent(application, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} diff --git a/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/MainActivity.kt b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/MainActivity.kt new file mode 100644 index 0000000000..6c30aadd48 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/MainActivity.kt @@ -0,0 +1,319 @@ +/* + * 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.tutorial.ringing + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ListItem +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.rounded.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.components.avatar.UserAvatar +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.NotificationHandler +import io.getstream.video.android.model.StreamCallId +import io.getstream.video.android.ui.common.StreamCallActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * This is the video call sample project follows the official ringing flow tutorial: + * + * https://getstream.io/video/sdk/android/tutorial/ringing-flow/ + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val resultLauncher = defaultPermissionLauncher() + + setContent { + VideoTheme { + var selectedUser by remember { mutableStateOf(RingingApp.currentUser) } + when (selectedUser) { + null -> LoginScreen(onUserSelected = { user -> + RingingApp.login(applicationContext, user) + // For the simplicity of the tutorial, we are requesting the required permissions here. + // In a real app, you should request the permissions when needed. + resultLauncher.requestDefaultPermissions() + selectedUser = user + }) + + else -> HomeScreen(onLogoutClick = { + RingingApp.logout() + selectedUser = null + }, onDialClick = { callees -> + startOutgoingCallActivity(callees) + }) + } + } + } + + observeIncomingCall() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeIncomingCall() { + lifecycleScope.launch { + StreamVideo.instanceState.flatMapLatest { instance -> + instance?.state?.ringingCall ?: flowOf(null) + }.collectLatest { call -> + if (call != null) { + lifecycleScope.launch { + // Monitor the ringingState on a non-null call + call.state.ringingState.collectLatest { + if (it is RingingState.Incoming) { + startIncomingCallActivity(call) + } + } + } + } + } + } + } + + private fun startIncomingCallActivity(call: Call) { + val intent = StreamCallActivity.callIntent( + context = this, + cid = StreamCallId.fromCallCid(call.cid), + members = emptyList(), + leaveWhenLastInCall = true, + action = NotificationHandler.ACTION_INCOMING_CALL, + clazz = VideoCallActivity::class.java, + ) + startActivity(intent) + } + + private fun startOutgoingCallActivity(callees: List) { + val intent = StreamCallActivity.callIntent( + context = this, + cid = StreamCallId(type = "default", id = UUID.randomUUID().toString()), + members = callees.toList(), + leaveWhenLastInCall = true, + action = NotificationHandler.ACTION_OUTGOING_CALL, + clazz = VideoCallActivity::class.java, + extraData = Bundle().apply { + putBoolean("is_video_call", true) + }, + ) + startActivity(intent) + } +} + +@Composable +fun LoginScreen(onUserSelected: (TutorialUser) -> Unit) { + // step1 - select a user. + Surface(modifier = Modifier.fillMaxSize(), color = VideoTheme.colors.baseTertiary) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Text( + text = "Select a User", + style = VideoTheme.typography.titleM, + color = VideoTheme.colors.basePrimary, + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + val users = TutorialUser.builtIn + UserList(users, onUserClick = { userId -> + users.find { it.id == userId }?.let { onUserSelected(it) } + }) + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +fun HomeScreen(onLogoutClick: () -> Unit, onDialClick: (callees: List) -> Unit) { + Surface(modifier = Modifier.fillMaxSize(), color = VideoTheme.colors.baseTertiary) { + Column(modifier = Modifier.fillMaxSize()) { + HomeHeader(onLogoutClick) + Spacer(modifier = Modifier.weight(1f)) + var callees by remember { mutableStateOf(RingingApp.callees) } + UserList(callees, checkboxVisible = true) { calleeId -> + callees = callees.map { + when (it.id == calleeId) { + true -> it.copy(checked = !it.checked) + else -> it + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { onDialClick(callees.filter { it.checked }.map { it.id }) }, + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = callees.any { it.checked }, + ) { + Text(text = "Dial Selected Users") + } + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +@OptIn(ExperimentalMaterialApi::class) +private fun UserList( + users: List, + modifier: Modifier = Modifier, + checkboxVisible: Boolean = false, + onUserClick: (userId: String) -> Unit, +) { + LazyColumn(modifier = modifier) { + itemsIndexed(users) { idx, user -> + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onUserClick(user.id) } + .padding(vertical = 8.dp, horizontal = 16.dp), + icon = { + UserAvatar( + modifier = Modifier.size(48.dp), + userImage = user.image, + userName = user.name, + ) + }, + text = { + Text( + text = user.name.orEmpty(), + style = VideoTheme.typography.titleS, + ) + }, + trailing = { + if (checkboxVisible && user.checked) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = VideoTheme.colors.iconDefault, + ) + } + }, + ) + if (idx < users.lastIndex) { + Divider(color = VideoTheme.colors.baseQuinary, thickness = 1.dp) + } + } + } +} + +@Composable +private fun HomeHeader(onLogoutClick: () -> Unit) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + UserAvatar( + modifier = Modifier.size(48.dp), + userImage = RingingApp.caller.image, + userName = RingingApp.caller.name, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = RingingApp.caller.name.orEmpty(), + style = VideoTheme.typography.titleS, + color = VideoTheme.colors.basePrimary, + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onLogoutClick, + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.AutoMirrored.Outlined.ExitToApp, + tint = VideoTheme.colors.iconDefault, + contentDescription = null, + ) + } + } +} + +private fun ComponentActivity.defaultPermissionLauncher( + allGranted: () -> Unit = { + }, +) = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), +) { granted -> + // Handle the permissions result here + granted.entries.forEach { (permission, granted) -> + if (!granted) { + Toast.makeText(this, "$permission permission is required.", Toast.LENGTH_LONG).show() + } + } + if (granted.entries.all { it.value }) { + allGranted() + } +} + +private fun ActivityResultLauncher>.requestDefaultPermissions() { + var permissions = arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CAMERA, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions += Manifest.permission.POST_NOTIFICATIONS + } + launch(permissions) +} + +private fun Context.isAudioPermissionGranted() = ContextCompat.checkSelfPermission( + this, Manifest.permission.RECORD_AUDIO, +) == PackageManager.PERMISSION_GRANTED + +private fun Context.isCameraPermissionGranted() = ContextCompat.checkSelfPermission( + this, Manifest.permission.CAMERA, +) == PackageManager.PERMISSION_GRANTED diff --git a/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/RingingApp.kt b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/RingingApp.kt new file mode 100644 index 0000000000..b295dcf5f9 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/RingingApp.kt @@ -0,0 +1,129 @@ +/* + * 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.tutorial.ringing + +import android.app.Application +import android.content.Context +import com.google.firebase.FirebaseApp +import io.getstream.android.push.firebase.FirebasePushDeviceGenerator +import io.getstream.log.Priority +import io.getstream.video.android.core.GEO +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoBuilder +import io.getstream.video.android.core.logging.HttpLoggingLevel +import io.getstream.video.android.core.logging.LoggingLevel +import io.getstream.video.android.core.notifications.NotificationConfig +import io.getstream.video.android.model.User + +class RingingApp : Application() { + + override fun onCreate() { + super.onCreate() + // Initialize firebase first. + // Ensure that you have the correct service account credentials updated in the Stream Dashboard. + FirebaseApp.initializeApp(this) + } + + companion object { + + var currentUser: TutorialUser? = null + + val caller get() = currentUser ?: error("#caller; user not logged in") + + val callees get() = currentUser?.let { loggedInUser -> + TutorialUser.builtIn.filter { it.id != loggedInUser.id } + } ?: error("#callees; user not logged in") + + fun login(context: Context, user: TutorialUser) { + if (!StreamVideo.isInstalled) { + currentUser = user + // step2 - initialize StreamVideo. For a production app we recommend adding + // the client to your Application class or di module. + StreamVideoBuilder( + context = context.applicationContext, + apiKey = "mmhfdzb5evj2", + geo = GEO.GlobalEdgeNetwork, + user = user.delegate, + token = user.token, + loggingLevel = LoggingLevel(Priority.VERBOSE, HttpLoggingLevel.BODY), + notificationConfig = NotificationConfig( + // Make the notification low priority if the app is in foreground, so its not visible as a popup, since we want to handle + // the incoming call in full screen when app is running. + hideRingingNotificationInForeground = true, + // Make sure that the provider name is equal to the "Name" of the configuration in Stream Dashboard. + pushDeviceGenerators = listOf( + FirebasePushDeviceGenerator( + providerName = "firebase", + ), + ), + + notificationHandler = CustomNotificationHandler( + context.applicationContext as Application, + ), + ), + ).build() + } + } + + fun logout() { + if (StreamVideo.isInstalled) { + StreamVideo.instance().logOut() + StreamVideo.removeClient() + currentUser = null + } + } + } +} + +data class TutorialUser( + val delegate: User, + val token: String, + val checked: Boolean = false, +) { + val id: String get() = delegate.id + val name: String? get() = delegate.name + val image: String? get() = delegate.image + + companion object { + val builtIn = listOf( + TutorialUser( + User( + id = "android-tutorial-1", + name = "User 1", + image = "https://getstream.io/chat/docs/sdk/avatars/jpg/Willard%20Hessel.jpg", + ), + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW5kcm9pZC10dXRvcmlhbC0xIn0.3qB-FI6OAqf5ZEETtgs0XhaMmiaRF2jDJOqCVRsqqbc", + ), + TutorialUser( + User( + id = "android-tutorial-2", + name = "User 2", + image = "https://getstream.io/chat/docs/sdk/avatars/jpg/Claudia%20Bradtke.jpg", + ), + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW5kcm9pZC10dXRvcmlhbC0yIn0.ksOq5ahC0745oZdPDKr2hyLp0j9exfwLE-AQITc9ZSc", + ), + TutorialUser( + User( + id = "android-tutorial-3", + name = "User 3", + image = "https://getstream.io/chat/docs/sdk/avatars/jpg/Bernard%20Windler.jpg", + ), + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW5kcm9pZC10dXRvcmlhbC0zIn0.g5h8coX8J1XUNHagPFoGBI0D7bN6P0w2Sd2rui89puE", + ), + ) + } +} diff --git a/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/VideoCallActivity.kt b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/VideoCallActivity.kt new file mode 100644 index 0000000000..05ffe3cef8 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/kotlin/io/getstream/video/android/tutorial/ringing/VideoCallActivity.kt @@ -0,0 +1,187 @@ +/* + * 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.tutorial.ringing + +import android.os.Bundle +import android.os.PersistableBundle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.ComposeStreamCallActivity +import io.getstream.video.android.compose.ui.StreamCallActivityComposeDelegate +import io.getstream.video.android.compose.ui.components.call.controls.actions.AcceptCallAction +import io.getstream.video.android.compose.ui.components.video.VideoRenderer +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.MemberState +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.call.state.CallAction +import io.getstream.video.android.core.model.VideoTrack +import io.getstream.video.android.ui.common.StreamActivityUiDelegate +import io.getstream.video.android.ui.common.StreamCallActivity +import io.getstream.video.android.ui.common.util.StreamCallActivityDelicateApi + +// Extends the ComposeStreamCallActivity class to provide a custom UI for the calling screen. +@Suppress("UNCHECKED_CAST") +class VideoCallActivity : ComposeStreamCallActivity() { + + // Internal delegate to customize the UI aspects of the call. + private val _internalDelegate = CustomUiDelegate() + + // Getter for UI delegate, specifies the custom UI delegate for handling UI related functionality. + override val uiDelegate: StreamActivityUiDelegate + get() = _internalDelegate + + @OptIn(StreamCallActivityDelicateApi::class) + override fun onCreate( + savedInstanceState: Bundle?, + persistentState: PersistableBundle?, + call: Call, + ) { + super.onCreate(savedInstanceState, persistentState, call) + call.camera.setEnabled(isVideoCall(call)) + } + + // Custom delegate class to define specific UI behaviors and layouts for call states. + private class CustomUiDelegate : StreamCallActivityComposeDelegate() { + + @Composable + override fun StreamCallActivity.OutgoingCallContent( + modifier: Modifier, + call: Call, + isVideoType: Boolean, + isShowingHeader: Boolean, + headerContent: + @Composable() + (ColumnScope.() -> Unit)?, + detailsContent: + @Composable() + ( + ColumnScope.(participants: List, topPadding: Dp) -> Unit + )?, + controlsContent: + @Composable() + (BoxScope.() -> Unit)?, + onBackPressed: () -> Unit, + onCallAction: (CallAction) -> Unit, + ) { + io.getstream.video.android.compose.ui.components.call.ringing.outgoingcall.OutgoingCallContent( + call = call, + isVideoType = isVideoType, + modifier = modifier, + isShowingHeader = isShowingHeader, + headerContent = headerContent, + detailsContent = detailsContent, + controlsContent = controlsContent, + onBackPressed = onBackPressed, + onCallAction = onCallAction, + backgroundContent = { + val cameraEnabled by call.camera.isEnabled.collectAsStateWithLifecycle() + if (cameraEnabled) { + VideoRenderer( + call = call, + video = ParticipantState.Video( + sessionId = call.sessionId, + track = VideoTrack( + streamId = call.sessionId, + video = call.camera.mediaManager.videoTrack, + ), + enabled = true, + ), + modifier = Modifier.fillMaxSize(), + ) + } + }, + ) + } + + @Composable + override fun StreamCallActivity.IncomingCallContent( + modifier: Modifier, + call: Call, + isVideoType: Boolean, + isShowingHeader: Boolean, + headerContent: (@Composable ColumnScope.() -> Unit)?, + detailsContent: ( + @Composable ColumnScope.( + participants: List, + topPadding: Dp, + ) -> Unit + )?, + controlsContent: (@Composable BoxScope.() -> Unit)?, + onBackPressed: () -> Unit, + onCallAction: (CallAction) -> Unit, + ) { + io.getstream.video.android.compose.ui.components.call.ringing.incomingcall.IncomingCallContent( + call = call, + isVideoType = isVideoType, + modifier = modifier, + isShowingHeader = isShowingHeader, + headerContent = headerContent, + detailsContent = detailsContent, + controlsContent = { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = VideoTheme.dimens.componentHeightM) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CustomRejectAction( + reason = "custom-decline-reason", + onCallAction = onCallAction, + ) + + AcceptCallAction( + onCallAction = onCallAction, + ) + } + }, + onBackPressed = onBackPressed, + onCallAction = onCallAction, + backgroundContent = { + val cameraEnabled by call.camera.isEnabled.collectAsStateWithLifecycle() + if (cameraEnabled) { + VideoRenderer( + call = call, + video = ParticipantState.Video( + sessionId = call.sessionId, + track = VideoTrack( + streamId = call.sessionId, + video = call.camera.mediaManager.videoTrack, + ), + enabled = true, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + ) + } + } +} diff --git a/tutorials/tutorial-ringing/src/main/res/drawable/ic_launcher_background.xml b/tutorials/tutorial-ringing/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..fc44ecb816 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tutorials/tutorial-ringing/src/main/res/drawable/ic_launcher_foreground.xml b/tutorials/tutorial-ringing/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2e4e9cc7f0 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/res/drawable/round_call_missed_24.xml b/tutorials/tutorial-ringing/src/main/res/drawable/round_call_missed_24.xml new file mode 100644 index 0000000000..44be524655 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/drawable/round_call_missed_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/tutorials/tutorial-ringing/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..182e8f82ab --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/tutorials/tutorial-ringing/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..182e8f82ab --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-hdpi/ic_launcher.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-mdpi/ic_launcher.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-xhdpi/ic_launcher.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/tutorials/tutorial-ringing/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/tutorials/tutorial-ringing/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/tutorials/tutorial-ringing/src/main/res/values/colors.xml b/tutorials/tutorial-ringing/src/main/res/values/colors.xml new file mode 100644 index 0000000000..c833d2a666 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/values/colors.xml @@ -0,0 +1,25 @@ + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/res/values/strings.xml b/tutorials/tutorial-ringing/src/main/res/values/strings.xml new file mode 100644 index 0000000000..7e9b6bc682 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + tutorial-ringing + \ No newline at end of file diff --git a/tutorials/tutorial-ringing/src/main/res/values/themes.xml b/tutorials/tutorial-ringing/src/main/res/values/themes.xml new file mode 100644 index 0000000000..d14f8a0d03 --- /dev/null +++ b/tutorials/tutorial-ringing/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + +