# Getting started with Zoom Video SDK for Android ## Introduction The Zoom Video SDK is a feature-rich, fully-customizable video conferencing experience that helps enhance the communication possibilities of your app. Now, the Android Video SDK API makes integrating the SDK easier than ever for native Android developers of all backgrounds and experience levels. As a compliment, the Jetpack Compose UIToolKit provides a reactive UI that meshes perfectly with the functionality of the Video SDK, enabling developers to quickly spin up a quality video conferencing application. Here is a step-by-step guide to building an Android video conferencing app, built with Jetpack Compose and powered by the Zoom Video SDK for Android. You can reference the completed [Android VideoSDK Quickstart](https://github.com/zoom/videosdk-android-quickstart) app as you follow along. - [Prerequisites](#prerequisites) - [Marketplace configuration](#marketplace-configuration) - [SDK installation](#sdk-Installation) - [App architecture](#app-architecture) - [Generating your JWT](#generating-your-jwt) - [Joining a session](#joining-a-session) - [Setup event listener](#event-listener) - [Session layout](#session-layout) - [Handling permissions](#handling-permissions) - [User views](#user-views) - [Session controls](#session-controls) - [Handling session events](#handling-session-events) - [Handling orientation](#handling-orientation) - [Session teardown](#session-teardown) ## Prerequisites - Android Studio IDE - A [Zoom Video SDK account](/docs/video-sdk/developer-accounts/) with [Video SDK credentials](/docs/video-sdk/get-credentials/) ## Marketplace configuration To Begin, we must sign in to our Video SDK Zoom Account where we'll retrieve our SDK credentials and download the Android Video SDK: ![](/img/blog/ticorrianheard/sdkcredentials.png) ![](/img/blog/ticorrianheard/sdkdownload.png) ## SDK installation Next, you can reference [this page](https://developer.android.com/develop/ui/compose/setup) for creating your Kotlin Compose app on Android Studio. Then we'll reference [these steps](/docs/video-sdk/android/integrate/) for installing the Android Video SDK we downloaded into our Kotlin Compose application. The file structure and configuration should like this: ![](/img/blog/ticorrianheard/filestructure.png) In settings.gradle.kts: ```kotlin rootProject.name = "zoomvideosdkkotlin" include(":app") include(":mobilertc") ``` In build.gradle(app) dependencies: ```kotlin implementation(libs.zoomvideosdk.core) implementation(libs.zoomvideosdk.videoeffects) ``` In build.gradle(:mobilertc): ```kotlin configurations.create("default") artifacts.add("default", file('mobilertc.aar')) dependencies.add("default","androidx.security:security-crypto:1.1.0-alpha03") dependencies.add("default","com.google.crypto.tink:tink-android:1.5.0") dependencies.add("default","androidx.appcompat:appcompat:1.3.0") ``` Sync your project to complete the import and your app should be ready to use the Video SDK libraries. ## App architecture This application uses a Model - View - ViewModel design where we will use Jetpack Compose to drive our views and a ViewModel to handle the SDK logic and update UI State behind the scenes. We will have two views: _JoinSession_, our starting point for entering the meeting details and _InSession_, where the in-session experience will live. We will incorporate helper functions to aid with API calls, Routing, and Token Generation as well. First lets set up up our views where we'll interact with the app. We start in `MainActivity.kt`, the entry point of our application: ```kotlin class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) override fun onCreate(savedInstanceState: Bundle?) { val zoomSessionViewModel by viewModels() super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { val navController = rememberNavController() NavHost(navController = navController, startDestination = Routes.JOINSESSION, builder = { composable(Routes.JOINSESSION){ JoinSession(navController, zoomSessionViewModel) } composable(Routes.INSESSION){ InSession(navController, zoomSessionViewModel) } }) } } } ``` Here, we set up routing for our two views using the `NavHost` composable. The routes are configured as string references in the `Routes.kt` file under the `app/src/main/java/apppackagename/utils` folder: ```kotlin object Routes { const val JOINSESSION: String = "JoinSession" const val INSESSION: String = "InSession" } ``` From there we pass to these views, a `navController` which is used to navigate between each other and the `zoomSessionViewModel` instance, which is our object for using the ViewModel of our application. We will dive more into that next. ### ViewModel The ViewModel handles all Android Video SDK interation and updating the UI State in our application. This approach is important both for minimizing complexity and keeping your data in a reusable store, not constrained to the lifecycle of the View that uses it. First we set up our `ZoomSessionUIState` data class which will store any data that must be used by our view: ```kotlin data class ZoomSessionUIState( val selfView: ZoomVideoSDKVideoView? = null, val currentUsersInViewCount: Int = 0, val sessionName: String = "", val userName: String = "", val password: String? = "", val sessionLoader: Boolean = true, val isVideoOn: Boolean = false, val muted: Boolean = false, val audioConnected: Boolean = false, val pageNumber: Int = 1, val maxPages: Int = 1, val participantVideoOn: List = listOf(false, false, false, false), val participantMuted: List = listOf(false, false, false, false) ) ``` For Jetpack Compose recomposition, it is absolutely crucial to ensure this data class only contains immutable data types. This includes primitive data types using val and lists. Compose recomposition can easily tell if the value of these types have changed and thus recompose the composables that read that state value. For objects and mutable data types using var, Compose cannot tell if these values have been updated and will either not recompose the composable reading of that value or will recompose the composable every time there is a recomposition of the view. This can lead to unexpected behavior and performance issues if recomposition is not managed appropriately. You can read more about this [here](https://blog.savvas.cloud/2023/09/07/the-dos-and-donts-of-jetpack-compose/). Next, we create our `ZoomSessionViewModel` class, extending the `AndroidViewModel` class so we can access the context of our Application. Using `MutableStateFlow`, we instantiate a private mutable instance of the `ZoomSessionUIState` above, `_zoomSessionUIState`. This instance will be used by our viewmodel to update the state. We create another instance of `zoomSessionUIState`, this time as a public read-only StateFlow, to be used to display the latest UIState data to our Views. Finally, we implement the view-model methods that will interact with the Android Video SDK, which we'll cover each one as we go along: ```kotlin class ZoomSessionViewModel(application: Application): AndroidViewModel(application) { @SuppressLint("StaticFieldLeak") private val context: Context = getApplication().applicationContext private val _zoomSessionUIState = MutableStateFlow(ZoomSessionUIState()) private var currentUsersInView: List = emptyList() val zoomSessionUIState: StateFlow = _zoomSessionUIState.asStateFlow() fun initZoomSDK () {} fun joinSession(config: Config) {} fun getMyself(): ZoomVideoSDKUser {} fun getCurrentUsersInView(): List {} fun closeSession(end: Boolean) {} fun updateState(state: ZoomSessionUIState) {} fun getState(): ZoomSessionUIState {} fun rotateVideo(rotation: Int) {} fun toggleCamera() {} fun startVideo() {} fun stopVideo() {} fun toggleMicrophone() {} private fun stopAudio() {} fun updateUsersInView(page: Int) {} fun renderView(user: ZoomVideoSDKUser, view: ZoomVideoSDKVideoView) {} fun stopRenderSelfView(user: ZoomVideoSDKUser) {} } ``` ## Generating Your JWT The Video SDK requires a JWT Signature Token each time you want to join or start a session. To generate this token, we have two methods. The first method, for testing purposes only, is to use a local function in our code to generate the token. This local function is called `TokenGenerator.java`, which we will place in `app/src/main/java/apppackagename/utils`: ```kotlin public class TokenGenerator { public static String generateToken(@NotNull JWTOptions jwtOptions, @NotNull String sdkKey, @NotNull String sdkSecret) { Key signingKey = Keys.hmacShaKeyFor(sdkSecret.getBytes(StandardCharsets.UTF_8)); Instant now = Instant.now(); Map claims = new HashMap<>(); claims.put("app_key", sdkKey); claims.put("role_type", jwtOptions.getRole()); claims.put("tpc", jwtOptions.getSessionName()); claims.put("version", 1); claims.put("iat", Date.from(Instant.now())); claims.put("exp", Date.from(now.plus(5, ChronoUnit.MINUTES))); claims.put("user_identity", jwtOptions.getUserIdentity()); claims.put("geo_regions", jwtOptions.getGeo_regions()); claims.put("cloud_recording_option", jwtOptions.getCloud_recording_option()); claims.put("cloud_recording_election", jwtOptions.getCloud_recording_election()); claims.put("telemetry_tracking_id", jwtOptions.getTelemetry_tracking_id()); claims.put("video_webrtc_mode", jwtOptions.getVideo_webrtc_mode()); claims.put("audio_webrtc_mode", jwtOptions.getAudio_webrtc_mode()); return Jwts.builder() .header().add("typ", "JWT") .and() .claims(claims) .signWith(signingKey) .compact(); } } ``` This function takes your SDK key and secret from your Zoom web portal and a `JWTOptions` object to be used in the JWT payload. This method should only be used for testing purposes as it is bad practice to store sensitive on the client device. The second and more secure method is to utilize a backend server that will generate a secure token and send it to the client device. In this application, we can use the Retrofit library to make a request to the server to retrieve this token: ```kotlin // ApiClient.kt import io.github.cdimascio.dotenv.dotenv import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory object RetrofitClient { val dotenv = dotenv { directory = "/assets" filename = "env" } private val BASE_URL = dotenv["ENDPOINT_URL"] val retrofit: Retrofit by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() }} object ApiClient { val apiService: ApiService by lazy { RetrofitClient.retrofit.create(ApiService::class.java) }} // ApiService.kt import com.google.gson.JsonObject import com.zoomvsdkkotlin.activities.JWTOptions import retrofit2.Call import retrofit2.http.Bodyimport retrofit2.http.Headersimport retrofit2.http.POSTimport retrofit2.http.Queryinterface ApiService { @Headers("Accept: application/json") @POST("zoomtoken") fun getJWT(@Query ("topic") topic: String, @Query ("name") name: String, @Query ("password") password: String, @Body body: JWTOptions ): Call } ``` For the server portion you can reference this [sample app to stand up generator in one click](https://github.com/zoom/videosdk-auth-endpoint-sample) ## Joining a session In `JoinSession.kt`, we set up a form to take in session details as strings. These strings are then collected when the join session button is clicked and formed into a Config Object. ![](/img/blog/ticorrianheard/joinsessionpage.png) After successfully retrieving our JWT Token, we then initialize the SDK using the zoomSessionViewModel's `.initZoomSDK()` method and send our Config to the `.joinSession()` method to join the Video SDK session. Finally, we use `navController` to navigate to the InSession view: ```kotlin val body = JWTOptions( sessionName = sessionName, role = 1, userIdentity = null.toString(), sessionkey = null.toString(), geo_regions = null.toString(), cloud_recording_option = 0, cloud_recording_election = 0, telemetry_tracking_id = "internal-dev5", video_webrtc_mode = 0, audio_webrtc_mode = 0 ) val signature: String = TokenGenerator.generateToken(body, sdkKey, sdkSecret) val config = Config(sessionName, userName, password, signature) zoomSessionViewModel.initZoomSDK() zoomSessionViewModel.joinSession(config) navController.navigate(Routes.INSESSION) ``` In our ViewModel, here is the code for the `.initZoomSDK()` and `.joinSession()` methods: ```kotlin fun initZoomSDK () { val initParams = ZoomVideoSDKInitParams().apply { domain = "https://zoom.us" } val sdk = ZoomVideoSDK.getInstance() val initResult = sdk.initialize(context, initParams) if (initResult == ZoomVideoSDKErrors.Errors_Success) { println("init success") } else { println("init fail") } val listener = EventListener(this).listener ZoomVideoSDK.getInstance().addListener(listener) } fun joinSession(config: Config) { val joinParams: ZoomVideoSDKSessionContext = ZoomVideoSDKSessionContext().apply { sessionName = config.sessionName userName = config.userName sessionPassword = config.password token = config.jwt } val session: ZoomVideoSDKSession? = ZoomVideoSDK.getInstance().joinSession(joinParams) if (session != null) { _zoomSessionUIState.update { it.copy( sessionName = config.sessionName, userName = config.userName, password = config.password ) } } } fun updateState(state: ZoomSessionUIState) { _zoomSessionUIState.value = state } ``` ## Setup event listener To verify we have successfully joined the session, we have to set up a listener object that will listen for Zoom Events: ```kotlin class EventListener(zoomViewModel: ZoomSessionViewModel) { val listener = object : ZoomVideoSDKDelegate { override fun onSessionJoin() { val state = zoomViewModel.getState() zoomViewModel.updateUsersInView(state.pageNumber) zoomViewModel.stopVideo() zoomViewModel.updateState( state.copy(sessionLoader = false)) } // ... } } ``` ## Session UI layout `InSession.kt` is where we will host the in-session controls and views. These views will utilize UIState to determine when to compose and use `zoomSessionViewModel` when the app logic needs to use the SDK or update the state. The UI Layout will start as a big self view that covers the entire screen when there is only 1 user in the session. When there is more than 1 user, the self view will be a small draggable window which will hover over a 2x2 grid, allowing up to 4 users in the view. If there are more than 5 users in the session, pagination buttons will be shown to click to the next user page. ![](/img/blog/ticorrianheard/portraitvideoon.png) ![](/img/blog/ticorrianheard/portrait2users.png) ![](/img/blog/ticorrianheard/portrait6users.png) For our state declarations, we have an instance `zoomSessionUIState` which is the read-only observable from our `zoomSessionViewModel`. Next have `permissionState`, which returns a `MultiplePermissionsState` object that tracks the status of our permissions. Next is a visible boolean used for making the Controls View fade in and fade out. The rest of our state declarations are remembered Lambdas that return a function. This ensures the code within the function is marked as stable by Compose, avoiding unnecessary recomposition of any composables that execute those functions. You can read more about this pitfall [here](https://multithreaded.stitchfix.com/blog/2022/08/05/jetpack-compose-recomposition/#:~:text=Option%202%20%2D-,Remembered%20Lambdas,-Another%20option%20is) ```kotlin fun InSession(navController: NavController, zoomSessionViewModel: ZoomSessionViewModel) { var visible by remember { mutableStateOf(true) } val zoomSessionUIState by zoomSessionViewModel.zoomSessionUIState.collectAsState() val permissionState: MultiplePermissionsState = rememberMultiplePermissionsState( permissions = listOf( android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAMERA ) ) val user = remember(zoomSessionViewModel) {{ zoomSessionViewModel.getMyself() }} // ... } ``` ## Handling permissions We handle permissions in `LaunchedEffect`, which is fired when the composable is added to the View. If `RECORD_AUDIO` and/or `CAMERA` permissions are not allowed, the prompts will fire for whichever needs to be allowed. ```kotlin LaunchedEffect(key1 = permissionState) { if (!permissionState.allPermissionsGranted) { permissionState.launchMultiplePermissionRequest() } } ``` ![](/img/blog/ticorrianheard/androidpermissionspicture.png) ## User views Lets take a look at the views used for displaying video and audio data for our users: Here, we implement the first view that shows either the users video as a full screen feed or a profile picture with a black background if the video is off. This composable takes the `isVideoOn` boolean, a function that returns the `ZoomVideoSDKUser` object for the desired user, and 2 functions that will call the `.renderView()` and `.rotateVideo()` methods from our `zoomSessionViewModel`. We use an `AndroidView` to create a `ZoomVideoSDKVideoView` instance to place in our view and use the update callback to retrieve the `ZoomVideoSDKVideoView` by its id and pass that to the renderView function with the `ZoomVideoSDKUser` object. This logic is the same for participant views as well. ```kotlin fun BigSelfView( user: () -> ZoomVideoSDKUser, isVideoOn: Boolean, renderView: (ZoomVideoSDKUser, ZoomVideoSDKVideoView) -> Unit, rotateVideo: (Int) -> Unit ) { val rotation: Int = LocalContext.current.display.rotation if (isVideoOn) { AndroidView( factory = { context: Context -> ZoomVideoSDKVideoView(context).apply { setId(4) } }, update = { val myselfView: ZoomVideoSDKVideoView = it.findViewById(4) rotateVideo(rotation) renderView(user(), myselfView) } ) } else { Box( modifier = Modifier .fillMaxSize() .background(Color.Black), contentAlignment = Alignment.Center ) { AsyncImage( model = "https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png", contentDescription = "Profile Pic Placeholder when video off", ) } } } // in ZoomSessionViewModel fun rotateVideo(rotation: Int) { val videoHelper = ZoomVideoSDK.getInstance().videoHelper videoHelper.rotateMyVideo(rotation) } fun renderView(user: ZoomVideoSDKUser, view: ZoomVideoSDKVideoView) { val canvas: ZoomVideoSDKVideoCanvas = user.videoCanvas canvas.subscribe( view, ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Full_Filled, ZoomVideoSDKVideoResolution.VideoResolution_720P ) } ``` `DraggableSelfView.kt`, mirrors the above logic with the additional of using positional offsets and the `detectDragGestures` listener to enable dragging. ```kotlin Box( modifier = Modifier .fillMaxSize() .fillMaxWidth(), ) { var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } Box( modifier = Modifier .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } .width(width + 10.dp) .height(height + 10.dp) .background( color = Color.LightGray, shape = RoundedCornerShape(10.dp) ) .pointerInput(Unit) { detectDragGestures { change, dragAmount -> change.consume() offsetX += dragAmount.x offsetY += dragAmount.y } } .align(Alignment.CenterEnd), contentAlignment = Alignment.Center ) { if (isVideoOn) { Box( modifier = Modifier .width(width) .height(height) ) { AndroidView( factory = { context: Context -> ZoomVideoSDKVideoView(context).apply { // ... } } ) } } } } // in ZoomSessionViewModel fun rotateVideo(rotation: Int) { val videoHelper = ZoomVideoSDK.getInstance().videoHelper videoHelper.rotateMyVideo(rotation) } fun renderView(user: ZoomVideoSDKUser, view: ZoomVideoSDKVideoView) { val canvas: ZoomVideoSDKVideoCanvas = user.videoCanvas canvas.subscribe( view, ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_Full_Filled, ZoomVideoSDKVideoResolution.VideoResolution_720P ) } ``` ## Session controls For controls we have 3 buttons for in-session: Mute/Unmute, Video On/Off, and End/Leave the session. These buttons are placed in a row composable and you can tap anywhere on the screen to hide or show the controls toolbar. When you click a media button, it will check if its corresponding permission has been allowed. If so, it will call the `zoomSessionViewModel` function related to its functionality. For example, the video icon: ```kotlin // Video (Audio has the same structure) IconButton( onClick = { if (!cameraPermission()) { //audio = microphonePermission launchMultiplePermissionRequest() } else { toggleCamera() //zoomSessionViewModel.toggleCamera() or .toggleMicrophone() } }, modifier = Modifier .then(Modifier.size(75.dp)) .clip(CircleShape) .background(Color.Blue) ) { if (cameraPermission()) { if (isVideoOn) { //audio = muted Icon( painter = painterResource(R.drawable.videocam_24px), tint = Color.White, contentDescription = null ) } else { Icon( painter = painterResource(R.drawable.videocam_off_24px), tint = Color.White, contentDescription = null ) } } else { Icon( painter = painterResource(R.drawable.videocam_alert_24px), tint = Color.White, contentDescription = null ) } } // in ZoomSessionViewModel fun toggleCamera() { val user: ZoomVideoSDKUser = ZoomVideoSDK.getInstance().session.mySelf val videoHelper = ZoomVideoSDK.getInstance().videoHelper val isVideoOn: Boolean? = user.videoCanvas?.videoStatus?.isOn if (isVideoOn != null) { if (isVideoOn) { videoHelper.stopVideo() _zoomSessionUIState.update { it.copy(isVideoOn = false) } } else { videoHelper.startVideo() _zoomSessionUIState.update { it.copy(isVideoOn = true) } } } } fun toggleMicrophone() { val user: ZoomVideoSDKUser = ZoomVideoSDK.getInstance().session.mySelf val audioHelper: ZoomVideoSDKAudioHelper = ZoomVideoSDK.getInstance().audioHelper val audioType: ZoomVideoSDKAudioStatus.ZoomVideoSDKAudioType? = user.audioStatus?.audioType if (audioType == ZoomVideoSDKAudioStatus.ZoomVideoSDKAudioType.ZoomVideoSDKAudioType_None) { audioHelper.startAudio() _zoomSessionUIState.update { it.copy( audioConnected = true, muted = true )} } else { val muted: Boolean? = user.audioStatus?.isMuted if (muted != null) { if (muted) { audioHelper.unMuteAudio(user) } else { audioHelper.muteAudio(user) } } } } ``` ## Handling session events In `EventListener.kt`, we listen for the following event corresponding to our media buttons: The `onUserVideoStatusChanged` button, which calls `updateUsersInView`, ensures the right user video and audio status icon are displayed in the view depending on the current page number in our UIState data class. ```kotlin override fun onUserVideoStatusChanged( videoHelper: ZoomVideoSDKVideoHelper?, userList: MutableList? ) { val state = zoomViewModel.getState() val user: ZoomVideoSDKUser = zoomViewModel.getMyself() if (userList?.get(0)?.userID != user.userID) zoomViewModel.updateUsersInView(state.pageNumber) } ``` `onUserAudioStatusChanged` checks the status of the current user's audio and updates the UIState data class to reflect the correct value. This ensures our audio button shows the right icon. ```kotlin override fun onUserAudioStatusChanged( audioHelper: ZoomVideoSDKAudioHelper?, userList: MutableList? ) { pp("onUserAudioStatusChanged") for (user in userList!!) { val state = zoomViewModel.getState() val myself: ZoomVideoSDKUser = zoomViewModel.getMyself() if (user.userID == myself.userID) { val audioType: ZoomVideoSDKAudioStatus.ZoomVideoSDKAudioType? = user.audioStatus?.audioType if (audioType == ZoomVideoSDKAudioStatus .ZoomVideoSDKAudioType.ZoomVideoSDKAudioType_None) { zoomViewModel.updateState(state.copy(audioConnected = false)) } else { val muted: Boolean = user.audioStatus?.isMuted == true zoomViewModel.updateState(state.copy(audioConnected = true, muted = muted)) } } } } ``` The `updateUsersInView` updates the `currentUserInView` List which holds `ZoomVideoSDKUser` objects for the up to 4 users that should be displayed in the view. It also updates the `participantVideoOn` and `participantMuted` Lists which ensures the correct media status is displayed on the view for participants UserViews. The function determines the right users by using the page number given as a parameter. It uses the Video SDK `.getRemoteUsers()` function to retrieve a `userList` of remote users. From there, we calculate the starting index using the page number and start iterating through the `userList` at that starting up to 4 times, adding the `ZoomVideoSDKUser` and their media data to the respective lists. Finally, we update all relevant state values to reflect this change in the GalleryView. ```kotlin fun updateUsersInView(page: Int) { val userList = ZoomVideoSDK.getInstance().session.remoteUsers val newState = ArrayList() val newParticipantVideoOn = ArrayList() val newParticipantMuted = ArrayList() val start: Int = (page - 1) * 4 val size: Int = userList.size if (userList.isNotEmpty()) { for (i in 0..3) { if (start + i < size) { newState.add(userList[start + i]) newParticipantVideoOn.add(userList[start + i].videoCanvas.videoStatus.isOn) newParticipantMuted.add(userList[start + i].audioStatus.isMuted) } else break } this.currentUsersInView = newState.toList() _zoomSessionUIState.update { it.copy( currentUsersInViewCount = this.currentUsersInView.size) } } else { this.currentUsersInView = emptyList() _zoomSessionUIState.update { it.copy( currentUsersInViewCount = 0) } } _zoomSessionUIState.update { it.copy( pageNumber = page, maxPages = ceil((size.toDouble() / 4)).toInt(), participantVideoOn = newParticipantVideoOn.toList(), participantMuted = newParticipantMuted.toList() ) } } ``` We use the `onUserJoined` listener to call `updateUsersInView`, adding a user to the view. We also switch from the `BigSelfView` to the `DraggableSelfView` if there is more than 1 user in the meeting: ```kotlin override fun onUserJoin( userHelper: ZoomVideoSDKUserHelper?, userList: MutableList? ) { pp("onUserJoin") val sdkSession: ZoomVideoSDKSession = ZoomVideoSDK.getInstance().session val state = zoomViewModel.getState() val remoteUsers: List = sdkSession.remoteUsers zoomViewModel.updateUsersInView(state.pageNumber) if (remoteUsers.size == 1) { val user: ZoomVideoSDKUser = zoomViewModel.getMyself() zoomViewModel.stopRenderSelfView(user) if (state.isVideoOn) zoomViewModel.startVideo() } } ``` Similarly, we use `onUserLeave` to call `updateUsersInView`, removing the user from the view. We also switch from the `DraggableSelfView` to the `BigSelfView` if there is only 1 user (current user) in the meeting: ```kotlin override fun onUserLeave( userHelper: ZoomVideoSDKUserHelper?, userList: MutableList? ) { pp("onUserLeave") val sdkSession: ZoomVideoSDKSession = ZoomVideoSDK.getInstance().session val remoteUsers: List = sdkSession.remoteUsers val state = zoomViewModel.getState() zoomViewModel.updateUsersInView(state.pageNumber) if (remoteUsers.isEmpty()) { val user: ZoomVideoSDKUser = zoomViewModel.getMyself() zoomViewModel.stopRenderSelfView(user) if (state.isVideoOn) zoomViewModel.startVideo() } } ``` ## Handling orientation Now we need to handle landscape and portrait changes since the ViewModel is holding our UIState. Only the UIs view dimensions will have to be updated to match the landscape layout. For the video orientation, we call the `.rotateVideo()` method and pass a `Surface.ROTATION` integer we retrieve from our composable to reflect the correct orientation in the Video SDK: ```kotlin fun DraggableSelfView( ... ) { val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT val rotation: Int = LocalContext.current.display.rotation val width = if (isPortrait) 120.dp else 236.dp val height = if (isPortrait) 236.dp else 120.dp ... } // In ZoomSessionViewModel.kt fun rotateVideo(rotation: Int) { val videoHelper = ZoomVideoSDK.getInstance().videoHelper videoHelper.rotateMyVideo(rotation) } ``` ![](/img/blog/ticorrianheard/landscapegalleryview.png) ![](/img/blog/ticorrianheard/landscapevideoon.png) ## Session teardown The End/Leave session button, when clicked, launches the LeavePopup composable which prompts the user to either leave or end the session (if they are the host). In the `.closeSession()` method, we stop all media, reset all UI State and private Lists back to their original values and call the `.leaveSession(end)` function to leave the session and navigate back to the JoinSession page: ```kotlin fun LeavePopup( modifier: Modifier, user: () -> ZoomVideoSDKUser, closeSession: (Boolean) -> Unit, navigate: (String) -> Unit ) { Box( modifier = modifier ) { val color = ButtonDefaults.buttonColors(containerColor = Color.Red) if (user().isHost) { Popup(alignment = Alignment.Center) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Button( colors = color, onClick = { closeSession(false) navigate(Routes.JOINSESSION) }) { Text("Leave Session") } Button( colors = color, onClick = { closeSession(true) navigate(Routes.JOINSESSION) }) { Text("End Session") } } } } else { Popup(alignment = Alignment.Center) { Button( colors = color, onClick = { closeSession(false) navigate(Routes.JOINSESSION) }) { Text("Leave Session") } } } } } // in ZoomSessionViewModel fun closeSession(end: Boolean) { this.stopVideo() this.stopAudio() _zoomSessionUIState.update { it.copy( selfView = null, currentUsersInViewCount = 0, sessionName = "", userName = "", password = "", sessionLoader = true, isVideoOn = false, muted = false, audioConnected = false, pageNumber = 1, participantVideoOn = listOf(false, false, false, false), participantMuted = listOf(false, false, false, false) ) } this.currentUsersInView = emptyList() ZoomVideoSDK.getInstance().leaveSession(end) } ``` With that, you now have a great starting point for building out a fully featured Android Video Conferencing App powered by the Zoom Video SDK!