diff --git a/README.md b/README.md index fb67a9aeff..4490680526 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,17 @@ Video roadmap and changelog is available [here](https://github.com/GetStream/pro ### 0.2.0 milestone -- [ ] Local Video disconnects sometimes (ICE restarts issue for the publisher. we're waiting for the backend support) -- [ ] Deeplink support for video call demo & dogfooding app (skip auth for the video demo, keep it for dogfooding) -- [ ] Chat Integration -- [ ] XML version of VideoRenderer -- [ ] Call Analytics stateflow -- [ ] Automatically handle pagination and sorting on > 6 participants -- [ ] Make it easy to test ringing support -- [ ] publish app on play store -- [ ] report version number of SDK on all API calls -- [ ] Bug: java.net.UnknownHostException: Unable to resolve host "hint.stream-io-video.com" isn't throw but instead logged as INFO +- [ ] Deeplink support for video call demo & dogfooding app (skip auth for the video demo, keep it for dogfooding) (Daniel) +- [ ] Chat Integration (Jaewoong) +- [ ] XML version of VideoRenderer (Jaewoong) +- [ ] Local Video disconnects sometimes (ICE restarts issue for the publisher. we're waiting for the backend support) (Thierry) +- [ ] Call Analytics stateflow (Thierry) +- [ ] Automatically handle pagination and sorting on > 6 participants +- [ ] Ringing: Make it easy to test +- [ ] Ringing: Make a list of what needs to be configurable +- [ ] Publish app on play store +- [ ] Report version number of SDK on all API calls (Daniel) +- [ ] Bug: java.net.UnknownHostException: Unable to resolve host "hint.stream-io-video.com" isn't throw but instead logged as INFO (Daniel) - [ ] Bug: screensharing is broken. android doesn’t receive/render (not sure) the screenshare. video shows up as the gray avatar - [X] Reactions - [X] bug: screenshare is not removed after it stops when a participant leaves the call (Thierry) (probably just dont update the state when the participant leaves) @@ -92,8 +93,9 @@ Video roadmap and changelog is available [here](https://github.com/GetStream/pro - [ ] Test coverage - [ ] Testing on more devices - [ ] Speaking while muted stateflow -- [X] Cleanup the retry behaviour in the RtcSession - [ ] Android SDK development.md cleanup (Daniel) +- [ ] Logging is too verbose (rtc is very noisy), clean it up to focus on the essential for info and higher +- [X] Cleanup the retry behaviour in the RtcSession - [X] SDK development guide for all teams ### 0.4.0 milestone 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 f03e6bffce..6af5095aef 100644 --- a/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt +++ b/buildSrc/src/main/kotlin/io/getstream/video/android/Configuration.kt @@ -6,7 +6,7 @@ object Configuration { const val minSdk = 24 const val majorVersion = 0 const val minorVersion = 0 - const val patchVersion = 18 + const val patchVersion = 20 const val versionName = "$majorVersion.$minorVersion.$patchVersion" const val versionCode = 1 const val snapshotVersionName = "$majorVersion.$minorVersion.${patchVersion}-SNAPSHOT" diff --git a/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx b/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx index 7155ca67fc..b3e96cfad1 100644 --- a/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx +++ b/docusaurus/docs/Android/02-tutorials/01-video-calling.mdx @@ -15,7 +15,7 @@ This tutorial teaches you how to build Zoom/Whatsapp style video calling for you ### Step 1 - Create a new project in Android Studio 1. Create a new project -2. Select Phone & Template -> **Empty Activity** +2. Select Phone & Tablet -> **Empty Activity** 3. Name your project **VideoCall**. Note that setup steps can vary slightly across Android Studio versions. @@ -29,7 +29,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-compose:0.0.18") + implementation("io.getstream:stream-video-android-compose:0.0.19") // Optionally add Jetpack Compose if Android studio didn't automatically include them implementation(platform("androidx.compose:compose-bom:2023.06.00")) @@ -51,6 +51,10 @@ For this tutorial, we'll use the compose UI components. ### Step 3 - Create & Join a call +To keep this tutorial short and easy to understand we'll place all code in `MainActivity.kt`. +For a production app you'd want to initialize the client in your Application class or DI module. +You'd also want to use a viewmodel. + Open up `MainActivity.kt` and replace the **MainActivity** class with: ```kotlin @@ -65,8 +69,7 @@ class MainActivity : ComponentActivity() { // step1 - create a user. val user = User( id = userId, // any string - name = "Tutorial", // name and image are used in the UI - role = "admin" + name = "Tutorial" // name and image are used in the UI ) // step2 - initialize StreamVideo. For a production app we recommend adding the client to your Application class or di module. @@ -126,13 +129,11 @@ Let's review what we did in the above code. **Create a user**. First we create a user object. You typically sync these users via a server side integration from your own backend. Alternatively, you can also use guest or anonymous users. -The user's role allows you to configure permissions in the video call. ```kotlin val user = User( id = userId, // any string - name = "Tutorial", // name and image are used in the UI - role = "admin" + name = "Tutorial" // name and image are used in the UI ) ``` diff --git a/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx b/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx index 518a39a764..b10ff77202 100644 --- a/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx +++ b/docusaurus/docs/Android/02-tutorials/02-audio-room.mdx @@ -12,7 +12,7 @@ The end result will look like the image below and support the following features * Calls run on Stream's global edge network for optimal latency and scalability. * There is no cap to how many listeners you can have in a room. * Listeners can raise their hand, and be invited to speak by the host. -* Audio tracks are send multiple times for optimal reliability. +* Audio tracks are sent multiple times for optimal reliability. ![Audio Room](../assets/audio-room.png) @@ -25,7 +25,7 @@ Setup steps can vary slightly across Android Studio versions. If you run into trouble, make sure to use the latest version of Android Studio. 1. Create a new project -2. Select Phone & Template -> **Empty Activity** +2. Select Phone & Tablet -> **Empty Activity** 3. Name your project **AudioRoom**. ### Step 2 - Install the SDK & Setup the client @@ -36,7 +36,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-compose:0.0.18") + implementation("io.getstream:stream-video-android-compose:0.0.19") // Jetpack Compose (optional/ android studio typically adds them when you create a new project) implementation(platform("androidx.compose:compose-bom:2023.06.00")) @@ -72,8 +72,7 @@ class MainActivity : ComponentActivity() { // step1 - create a user. val user = User( id = userId, // any string - name = "Tutorial", // name and image are used in the UI - role = "admin" + name = "Tutorial" // name and image are used in the UI ) // step2 - initialize StreamVideo. For a production app we recommend adding the client to your Application class or di module. @@ -90,7 +89,7 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { val result = call.join(create = true, createOptions = CreateCallOptions( members = listOf( - MemberRequest(userId = "sophia", role="host", custom = emptyMap()) + MemberRequest(userId = userId, role="host", custom = emptyMap()) ), custom = mapOf( "title" to "Compose Trends", "description" to "Talk about how easy compose makes it to reuse and combine UI" @@ -126,13 +125,11 @@ Let's review the example above and go over the details. **Create a user**. First we create a user object. You typically sync your users via a server side integration from your own backend. Alternatively, you can also use guest or anonymous users. -The user's role allows you to configure permissions in the video call. ```kotlin val user = User( id = userId, // any string - name = "Tutorial", // name and image are used in the UI - role = "admin" + name = "Tutorial" // name and image are used in the UI ) ``` @@ -156,7 +153,7 @@ lifecycleScope.launch { val result = call.join( create = true, createOptions = CreateCallOptions( members = listOf( - MemberRequest(userId = "sophia", role = "host", custom = emptyMap()) + MemberRequest(userId = userId, role = "host", custom = emptyMap()) ), custom = mapOf( "title" to "Compose Trends", "description" to "Talk about how easy compose makes it to reuse and combine UI" @@ -170,7 +167,7 @@ lifecycleScope.launch { ``` * This joins and creates a call with the type: "audio_room" and the specified callId. -* The user with id sophia is granted the "host" role. You can create custom roles and grant them permissions to fit your app. +* You add yourself as a member with the "host" role. You can create custom roles and grant them permissions to fit your app. * The `title` and `description` custom fields are set on the call object. * Shows an error toast if you fail to join an audio room. @@ -195,7 +192,6 @@ Replace the code in `setContent` with the following sample: ```kotlin setContent { VideoTheme { - val connection by call.state.connection.collectAsState() val connection by call.state.connection.collectAsState() val activeSpeakers by call.state.activeSpeakers.collectAsState() val audioLevel = activeSpeakers.firstOrNull()?.audioLevel?.collectAsState() @@ -223,6 +219,9 @@ setContent { } ``` +All state for a call is available in `call.state`. In the example above we're observing the connection state and the active speakers. +The [ParticipantState docs](../03-guides/03-call-and-participant-state.mdx) explain the available stateflow objects. + You'll see that the **AudioRoom** composable hasn't been implemented yet. In `MainActivity`, add the following `AudioRoom` composable: ```kotlin @@ -269,7 +268,9 @@ public fun AudioRoom( } ``` -The audio room is pretty basic. It needs a **Controls**, **Participants**, and **Description** composable functions to work. +The code above observes the participants, active speakers and backstage stateflow objects in `call.state`. + +We still need to implement a **Controls**, **Participants**, and **Description** composable. Let's add those next. ```kotlin @@ -311,12 +312,6 @@ That's it for the basics. Now when you run your app, you'll see the following UI The approach is the same for all components. We take the states of the call by observing `call.state` properties, such as `call.state.participants` and use it to power our UI. The [ParticipantState docs](../03-guides/03-call-and-participant-state.mdx) exposes all the state objects we need for the name, avatar, audio levels, speaking, etc. -To make this a little more interactive, let's join the audio room from your browser. - - - -On your Android device, you'll see the text update to 2 participants. - ### Step 5 - Audio Room Controls & Permission Any app that records the microphone needs to ask the user for permission. We'll do this now. @@ -371,6 +366,20 @@ public fun Controls( Now when you run the app, you'll see a button to disable/enable the microphone and to start or end the broadcast. +To make this a little more interactive, let's join the audio room from your browser. + + + +At first you won't be allowed to join the room since it's not live yet. +By default the audio_room call type has backstage mode enabled. This makes it easy to try out your room and talk to your co-hosts before going live. +You can enable/disable the usage of backstage mode in the dashboard. + +Let's go live and join the call: + +* Click go live on Android +* On web join the room +* You'll see the participant count increase to 2 + ### Step 6 - Participants UI Time to build a pretty UI for the participants. Replace the `Participants` composable with the following: diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinScreen.kt index 1bd11dbd20..88d85dc5ee 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinScreen.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinScreen.kt @@ -70,7 +70,7 @@ fun CallJoinScreen( navigateToCallLobby: (callId: String) -> Unit, navigateUpToLogin: () -> Unit ) { - val uiState by callJoinViewModel.uiState.collectAsState() + val uiState by callJoinViewModel.uiState.collectAsState(CallJoinUiState.Nothing) HandleCallJoinUiState( callJoinUiState = uiState, diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinViewModel.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinViewModel.kt index a920deba59..2100ee0203 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinViewModel.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/join/CallJoinViewModel.kt @@ -26,12 +26,13 @@ import io.getstream.video.android.datastore.delegate.StreamUserDataStore import io.getstream.video.android.model.User import io.getstream.video.android.model.mapper.isValidCallId import io.getstream.video.android.model.mapper.toTypeAndId -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject @@ -42,13 +43,14 @@ class CallJoinViewModel @Inject constructor( ) : ViewModel() { val user: StateFlow = dataStore.user - private val event: MutableStateFlow = MutableStateFlow(CallJoinEvent.Nothing) - internal val uiState: StateFlow = event + private val event: MutableSharedFlow = MutableSharedFlow() + internal val uiState: SharedFlow = event .flatMapLatest { event -> when (event) { is CallJoinEvent.GoBackToLogin -> { flowOf(CallJoinUiState.GoBackToLogin) } + is CallJoinEvent.JoinCall -> { val call = joinCall(event.callId) flowOf(CallJoinUiState.JoinCompleted(callId = call.cid)) @@ -58,7 +60,7 @@ class CallJoinViewModel @Inject constructor( else -> flowOf(CallJoinUiState.Nothing) } } - .stateIn(viewModelScope, SharingStarted.Lazily, CallJoinUiState.Nothing) + .shareIn(viewModelScope, SharingStarted.Lazily, 0) init { viewModelScope.launch { @@ -66,13 +68,13 @@ class CallJoinViewModel @Inject constructor( // if not. In the current implementation we only initialise after Login and if the // Android process is restored then the Login is skipped Stream Video is not initialised. if (!StreamVideo.isInstalled) { - event.value = CallJoinEvent.GoBackToLogin + event.emit(CallJoinEvent.GoBackToLogin) } } } fun handleUiEvent(event: CallJoinEvent) { - this.event.value = event + viewModelScope.launch { this@CallJoinViewModel.event.emit(event) } } private fun joinCall(callId: String? = null): Call { diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyScreen.kt index 1db0e643a8..ba7ac164ee 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyScreen.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyScreen.kt @@ -111,7 +111,7 @@ private fun CallLobbyHeader( callLobbyViewModel: CallLobbyViewModel = hiltViewModel(), navigateUpToLogin: () -> Unit ) { - val uiState by callLobbyViewModel.uiState.collectAsState() + val uiState by callLobbyViewModel.uiState.collectAsState(initial = CallLobbyUiState.Nothing) HandleCallLobbyUiState( callLobbyUiState = uiState, diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyViewModel.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyViewModel.kt index 3768a212c2..91031b6889 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyViewModel.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/lobby/CallLobbyViewModel.kt @@ -26,13 +26,16 @@ import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.datastore.delegate.StreamUserDataStore import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -54,8 +57,8 @@ class CallLobbyViewModel @Inject constructor( private val _isLoading: MutableStateFlow = MutableStateFlow(false) internal val isLoading: StateFlow = _isLoading - private val event: MutableStateFlow = MutableStateFlow(CallLobbyEvent.Nothing) - internal val uiState: StateFlow = event + private val event: MutableSharedFlow = MutableSharedFlow() + internal val uiState: SharedFlow = event .flatMapLatest { event -> when (event) { is CallLobbyEvent.JoinCall -> flowOf(CallLobbyUiState.JoinCompleted) @@ -64,10 +67,10 @@ class CallLobbyViewModel @Inject constructor( } } .onCompletion { _isLoading.value = false } - .stateIn(viewModelScope, SharingStarted.Lazily, CallLobbyUiState.Nothing) + .shareIn(viewModelScope, SharingStarted.Lazily, 0) fun handleUiEvent(event: CallLobbyEvent) { - this.event.value = event + viewModelScope.launch { this@CallLobbyViewModel.event.emit(event) } } fun enableCamera(enabled: Boolean) { diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginScreen.kt index a4d8b67479..c26f240954 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginScreen.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginScreen.kt @@ -73,7 +73,7 @@ fun LoginScreen( loginViewModel: LoginViewModel = hiltViewModel(), navigateToCallJoin: () -> Unit ) { - val uiState by loginViewModel.uiState.collectAsState() + val uiState by loginViewModel.uiState.collectAsState(initial = LoginUiState.Nothing) val isLoading by remember(uiState) { mutableStateOf(uiState !is LoginUiState.Nothing) } var isShowingEmailLoginDialog by remember { mutableStateOf(false) } diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginViewModel.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginViewModel.kt index a18d0f8811..1ffa6467b5 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginViewModel.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/dogfooding/ui/login/LoginViewModel.kt @@ -32,15 +32,15 @@ import io.getstream.video.android.dogfooding.token.TokenResponse import io.getstream.video.android.model.User import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject @@ -50,8 +50,8 @@ class LoginViewModel @Inject constructor( private val dataStore: StreamUserDataStore ) : ViewModel() { - private val event: MutableStateFlow = MutableStateFlow(LoginEvent.Nothing) - internal val uiState: StateFlow = event + private val event: MutableSharedFlow = MutableSharedFlow() + internal val uiState: SharedFlow = event .flatMapLatest { event -> when (event) { is LoginEvent.Loading -> flowOf(LoginUiState.Loading) @@ -59,10 +59,10 @@ class LoginViewModel @Inject constructor( is LoginEvent.SignInInSuccess -> signInInSuccess(event.email) else -> flowOf(LoginUiState.Nothing) } - }.stateIn(viewModelScope, SharingStarted.Lazily, LoginUiState.Nothing) + }.shareIn(viewModelScope, SharingStarted.Lazily, 0) fun handleUiEvent(event: LoginEvent) { - this.event.value = event + viewModelScope.launch { this@LoginViewModel.event.emit(event) } } private fun signInInSuccess(email: String) = flow { 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 9ecc458f5c..1db00c1ed4 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 @@ -167,8 +167,8 @@ public class CallState(private val call: Call, private val user: User) { } /** participants who are currently speaking */ - public val activeSpeakers: StateFlow> = - _participants.mapState { it.values.filter { participant -> participant.speaking.value } } + private val _activeSpeakers: MutableStateFlow> = MutableStateFlow(emptyList()) + public val activeSpeakers: StateFlow> = _activeSpeakers /** participants other than yourself */ public val remoteParticipants: StateFlow> = @@ -464,6 +464,10 @@ public class CallState(private val call: Call, private val user: User) { participant._speaking.value = entry.value.isSpeaking participant.updateAudioLevel(entry.value.audioLevel) } + + _activeSpeakers.value = participants.value + .filter { it.speaking.value } + .sortedByDescending { it.audioLevel.value } } is DominantSpeakerChangedEvent -> { 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 dfcb35d4f5..77c4c1d9a7 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 @@ -64,6 +64,9 @@ class EventTest : IntegrationTestBase(connectCoordinatorWS = false) { @Test fun `Audio level changes`() = runTest { + // ensure the participant exists + call.state.getOrCreateParticipant("thierry", "thierry", updateFlow = true) + val levels = mutableMapOf( "thierry" to io.getstream.video.android.model.UserAudioLevel( "thierry", @@ -75,6 +78,7 @@ class EventTest : IntegrationTestBase(connectCoordinatorWS = false) { clientImpl.fireEvent(event, call.cid) // ensure we update call data and capabilities + assertThat(call.state.activeSpeakers.value.map { it.initialUser.id }).containsExactly("thierry") } @Test