Mobile ApplicationSolutions

How to Add Live Streaming in Android Application Using Agora?

Introduction

Agora live Streaming platform is used for analytics tracking and one to many and many to many audio or video live streaming with Agora SDK. In video calling with audio interactive live streaming, users can be host or audience and the host can start live and the audience by joining that streaming.

Why Agora Live Streaming?

Agora live Streaming platform is used to integrate SDK in your mobile app which can help you to explore all the features from Agore such as simple-to-use, developer-friendly, customizable, securable, reliable, good quality audio, and video analytics tracking, customer support, video monetization, reduce cost.

Prerequisite

  • Android Device, Windows, Linux.
  • Tech Stack, We’re using: Kotlin, Java, XML language
  • Tools we are using: Android studio
  • Java Development Kit.
  • Android Studio 3.0 or later.
  • Android SDK API Level 16 or higher.
  • A valid Agora account. integrate the Agora project with an App ID and a temporary token. For details, see Get Started with Agora.

Process of implementing Live Streaming in Android Application Using Agora

1. Register with Agora  https://console.agora.io/

2. Activate your project with app id and temporary token.

3. Add the below dependency to your project.

implementation 'io.agora.rtc:full-sdk:x.y.z'

4. Next Step you need to add the below permission in the android manifest

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />

The below Screenshot shows how live streaming actually works.

live streaming actually works
<com.tyt.tytapp.ui.adapter.VideoGridContainer
       android:id="@+id/live_video_grid_layout"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"/>

5. Next step you need to create a live activity by extending another activity name is ActivityRtcLivebase.

You need to pass both side join and live user bellow details compulsory from the agora.

Live user details

  • Channel Profile=Constants.CLIENT_ROLE_BROADCASTER
  • UID
  • Token
  • Room Name

Join user details

  • Channel Profile=Constants.CLIENT_ROLE_AUDIENCE
  • UID
  • Token
  • Room Name

ActivityLive.kt

       appLiveTokenIs =intent.getStringExtra(AppConstants.PREF_USER_APP_TOKEN_INTENT)
       channelName = intent.getStringExtra(AppConstants.CHANNEL_MESSAGE)
       userId=intent.getStringExtra(AppConstants.PREF_USERS_ID)
       experienceId=intent.getIntExtra(AppConstants.PREF_EXPERIENCE_ID, -1)
       channelProfile = intent.getIntExtra(AppConstants.PROFILE_MESSAGE,-1)
       profileImage=intent.getStringExtra(AppConstants.PREF_USER_PROFILE_LIVE)
       profilerName=intent.getStringExtra(AppConstants.PREF_USER_PROFILER_NAME)
       uidIs=intent.getLongExtra(AppConstants.UID,0).toInt()
       uidClode=intent.getIntExtra(AppConstants.CLOUD_UID,0)
       cloudeUrl=intent.getStringExtra(AppConstants.CLOUD_URL)
       sid=intent.getStringExtra(AppConstants.CLOUD_SID)
       expAddress= intent.getStringExtra(AppConstants.LIVE_ADDRESS)
       description= intent.getStringExtra(AppConstants.LIVE_DESCRIPTION)
       createDate= intent.getStringExtra(AppConstants.LIVE_EXP_DATE)
       screenUid=intent.getStringExtra(AppConstants.UID_SCREEN_SHOT)


       if (intent.hasExtra(AppConstants.LIVE_EXPERIENCE_KEY)) {
           intent.getParcelableExtra<ModelResponseExperienceDetailsData>(AppConstants.LIVE_EXPERIENCE_KEY)
               ?.let {
                   dataExperienceDetails= it
                   Log.d("jigdata==","live="+dataExperienceDetails)
     
        

                   if(channelProfile==2){
                       if(!appLiveTokenIs.isNullOrEmpty()){
                           initUI()
                           initData()
                       }
                   }

               }
       }
       if(channelProfile==1){
           if(!appLiveTokenIs.isNullOrEmpty()){
               initUI()
               initData()
           }
       }
       if(channelProfile==2) {
           //toolbar
           initToolbar()
           baseActivityBinding.baseActivityToolbar.visibility=View.VISIBLE
           baseActivityBinding.baseActivityToolbarTextviewTitleCenter.visibility = View.VISIBLE
           baseActivityBinding.baseActivityToolbarTextviewTitleCenter.text = resources.getString(R.string.text_title_experience_title)
           baseActivityBinding.baseActivityToolbarImageviewBack.visibility = View.VISIBLE

       }
       if(channelProfile==1)
       {
           baseActivityBinding.baseActivityToolbar.visibility=View.GONE
       }





       activityLiveBinding.activityLiveButtonLearnMore.setSafeOnClickListener {


           if (wikiPediaKey.isNullOrEmpty()) {

               showLocationCoordinatesInvalidDialogForWikiPedia()
           }else{
               val intent = Intent(this, ActivityWebView::class.java)
               intent.putExtra(AppConstants.PREF_KEY_WIKI_PEDIA, wikiPediaKey)
               startActivity(intent)
           }
       }


   }
   override fun onStop() {
       super.onStop()

   }

   override fun onResume() {
       super.onResume()
       if(cTimer!=null){
           cTimer?.cancel()
       }
      
   }


   override fun onDestroy() {
       super.onDestroy()
       if(cTimer!=null){
           cTimer?.cancel()
       }
   }

   override fun onPause() {
       super.onPause()
       startOTPTimer()


   }


   private fun initUI() {
      val roomName: TextView = findViewById(R.id.activity_live_text_view_text_name)
       roomName.text =profilerName


       if(channelProfile==2)
       {
           roomName.text = profilerName
       }
      roomName.isSelected = true
       initUserIcon()
       val role: Int = intent.getIntExtra(
           Constants.KEY_CLIENT_ROLE,
           Constants.CLIENT_ROLE_AUDIENCE
       )
       val isBroadcaster:Boolean = (channelProfile == Constants.CLIENT_ROLE_BROADCASTER)
      mMuteVideoBtn = findViewById(R.id.live_btn_mute_video)
       mMuteVideoBtn.setOnClickListener() {
           onMuteVideoClicked(it)
       }
       mMuteVideoBtn.isActivated = isBroadcaster
       mMuteAudioBtn = findViewById(R.id.live_btn_mute_audio)
       mMuteAudioBtn.setOnClickListener() {
           onMuteAudioClicked(it)
       }
       mMuteAudioBtn.isActivated = isBroadcaster
       mLiveButton= findViewById(R.id.live_btn_switch_camera)

       if(channelProfile==2){
           activityLiveBinding.activityLiveBottomLayout.visibility=View.GONE
           activityLiveBinding.activitLiveTopLayout.visibility=View.VISIBLE
           activityLiveBinding.activityLiveBottomExploreAreaConstraintLayout.visibility=View.VISIBLE
           //toolbar
           initToolbar()
           baseActivityBinding.baseActivityToolbarTextviewTitleCenter.visibility = View.VISIBLE
           baseActivityBinding.baseActivityToolbarTextviewTitleCenter.text = resources.getString(R.string.text_title_experience_title)
           baseActivityBinding.baseActivityToolbarImageviewBack.visibility = View.VISIBLE
           activityLiveBinding.activityLiveTextViewTextLocationDescription.text=dataExperienceDetails.experienceDetails.experienceDescription
           activityLiveBinding.activityLiveTextViewTextLocationName.text=dataExperienceDetails.experienceDetails.experienceAddress
           activityLiveBinding.activityLiveTextViewTextDate.text = AppDateFormatUtils.convertDate(
               dataExperienceDetails.createdAt,
               DateFormat.NUMERICAL_REVERSE_DATE_WITH_DASH_AND_TIME_MIDDLE_T,
               DateFormat.FULL_DATE_AND_TIME,
               true
           )
           if(dataExperienceDetails.isOriginal){

               Glide.with(context)
                   .load(dataExperienceDetails.owner)
                   .error(R.drawable.ic_defaultpic)
                   .placeholder(R.drawable.ic_defaultpic)
                   .into(activityLiveBinding.liveProfileImage)
           }else{

               Glide.with(context)
                   .load(dataExperienceDetails.originalOwner!!.profilePic)
                   .error(R.drawable.ic_defaultpic)
                   .placeholder(R.drawable.ic_defaultpic)
                   .into(activityLiveBinding.liveProfileImage)
           }



           mMuteAudioBtn.visibility=View.GONE
           mMuteVideoBtn.visibility=View.GONE
           mLiveButton.visibility=View.GONE
       }

       if(channelProfile==1){
           initToolbar()
           baseActivityBinding.baseActivityToolbarTextviewTitleCenter.visibility = View.GONE
           baseActivityBinding.baseActivityToolbarImageviewBack.visibility = View.GONE
           activityLiveBinding.activitLiveTopLayout.visibility=View.GONE
           mMuteAudioBtn.visibility=View.VISIBLE
           mMuteVideoBtn.visibility=View.VISIBLE
           mLiveButton.visibility=View.VISIBLE
           Glide.with(context)
               .load(appPreferences.getAppPrefString(AppConstants.PREF_USER_PROFILE_IMAGE))
               .error(R.drawable.ic_defaultpic)
               .placeholder(R.drawable.ic_defaultpic)
               .into(activityLiveBinding.liveProfileImage)
       }

       mVideoGridContainer = findViewById(R.id.live_video_grid_layout)

       mVideoGridContainer!!.setStatsManager(statsManager())
       rtcEngine().setClientRole(channelProfile)
       if (isBroadcaster) startBroadcast()
   }



   private fun initUserIcon() {
       val origin = BitmapFactory.decodeResource(resources, R.drawable.fake_user_icon)
       val drawable = RoundedBitmapDrawableFactory.create(resources, origin)
       drawable.isCircular = true


   }

   private fun initData() {
       mVideoDimension =
           Constants.VIDEO_DIMENSIONS.get(config().videoDimenIndex)

   }


   private fun startBroadcast() {

       rtcEngine().setClientRole(Constants.CLIENT_ROLE_BROADCASTER)
       val surface: SurfaceView = prepareRtcVideo(uidIs, true)

       surfaceViewCached = surface
       executeAfterSomeTime(5000) {
           takeScreenShotAndSend()//07-03-22
       }

       mVideoGridContainer!!.addUserVideoSurface(uidIs.toInt(), surface, true)
       mMuteAudioBtn.isActivated = true//change
   }

   private fun stopBroadcast() {
       rtcEngine().setClientRole(Constants.CLIENT_ROLE_AUDIENCE)
        removeRtcVideo(uidIs.toInt(), true)
       mVideoGridContainer!!.removeUserVideo(uidIs.toInt(), true)
       mMuteAudioBtn.isActivated = true

   }

   override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
       Log.d("==>","onJoinChannelSuccess"+uid)

       // Do nothing at the moment

   }

   override fun onUserJoined(uid: Int, elapsed: Int) {
       Log.d("==>","onUserJoined="+uid)
       // Do nothing at the moment
   }
   override fun onUserOffline(uid: Int, reason: Int) {
       runOnUiThread(Runnable { removeRemoteUser(uid)
       if (reason == 0)
       {
           showLiveStreamOffDialog()
       }
       })
   }

   override fun onFirstRemoteVideoDecoded(uid: Int, width: Int, height: Int, elapsed: Int) {
       runOnUiThread(Runnable { renderRemoteUser(uid) })
   }

   private fun renderRemoteUser(uid: Int) {
       val surface: SurfaceView = prepareRtcVideo(uid, false)
       Log.d("==>","surface="+surface)

       surFaceIs = surface.toString()
       surfaceViewCached = surface
       if(channelProfile==2){
           surfaceViewCachedJoin=surface
       }

       mVideoGridContainer!!.addUserVideoSurface(uid, surface, false)
       if(channelProfile==2){
           surfaceViewCachedJoin?.let { surfaceView ->
               LiveSuccess=true
           }
       }
   }




   private fun removeRemoteUser(uid: Int) {
       removeRtcVideo(uid, false)
       //dashboardViewModel.endLiveApi(experienceId, userId)
       mVideoGridContainer?.removeUserVideo(uid, false)
   }
   override   fun onLocalVideoStats(stats: LocalVideoStats?) {
       Log.d("==>","LiveSuccessF="+LiveSuccess)
       if(channelProfile==2){
           surfaceViewCachedJoin?.let { surfaceView ->
               Log.d("==>","dooo="+surfaceView.toString())

               if(surfaceView.toString().isEmpty()){
                   if (intent.hasExtra(AppConstants.LIVE_EXPERIENCE_KEY)) {
                       intent.getParcelableExtra<ModelResponseExperienceDetailsData>(AppConstants.LIVE_EXPERIENCE_KEY)
                           ?.let {
                               dataExperienceDetails= it
                               initUI()
                               initData()
                               joinChannel()
                            
                           }
                   }


               }
           }
       }


       if(!LiveSuccess){

           if (intent.hasExtra(AppConstants.LIVE_EXPERIENCE_KEY)) {
               LIVE_EXPERIENCE_KEY)
                   ?.let {
                       dataExperienceDetails= it
                       Log.d("==>","dooo3="+intent.hasExtra(AppConstants.LIVE_EXPERIENCE_KEY))
                       initUI()
                       initData()
                       joinChannel()


                   }
           }

       }
       if (!statsManager()!!.isEnabled) return
       val data = statsManager()!!.getStatsData(uidIs.toInt()) as LocalStatsData ?: return
       data.width = mVideoDimension.width
       data.height = mVideoDimension.height
       data.framerate = stats!!.sentFrameRate
   }

   override fun onBackPressed() {

       if(backPress) {
           super.onBackPressed()
           if (channelProfile == 1) {
            dashboardViewModel.endLiveApi(experienceId, userId, fileName)
               setResult(RESULT_OK, Intent().putExtra("experienceId",experienceId).putExtra("userId",userId).putExtra("fileName",fileName))
               backPress=false
           }
       }
      // finish()
   }
   override fun onRtcStats(stats: RtcStats?) {
       Log.d("==>","onRtcStats="+stats)
       if (!statsManager()!!.isEnabled) return
       val data = statsManager()!!.getStatsData(uidIs) as LocalStatsData ?: return
       data.lastMileDelay = stats!!.lastmileDelay
       data.videoSendBitrate = stats.txVideoKBitRate
       data.videoRecvBitrate = stats.rxVideoKBitRate
       data.audioSendBitrate = stats.txAudioKBitRate
       data.audioRecvBitrate = stats.rxAudioKBitRate
       data.cpuApp = stats.cpuAppUsage
       data.cpuTotal = stats.cpuAppUsage
       data.sendLoss = stats.txPacketLossRate
       data.recvLoss = stats.rxPacketLossRate
   }




   override fun onNetworkQuality(uid: Int, txQuality: Int, rxQuality:Int) {
       if (!statsManager()!!.isEnabled()) return
       val data: StatsData = statsManager()!!.getStatsData(uid) ?: return
       data.setSendQuality(statsManager()!!.qualityToString(txQuality))
       data.setRecvQuality(statsManager()!!.qualityToString(rxQuality))
   }

   override fun onRemoteVideoStats(stats: RemoteVideoStats?) {
       Log.d("==>","onRemoteVideoStats="+stats)
       if (!statsManager()!!.isEnabled()) return
       val data: RemoteStatsData = statsManager()!!.getStatsData(stats!!.uid) as RemoteStatsData ?: return
       data.setWidth(stats.width)
       data.setHeight(stats.height)
       data.setFramerate(stats.rendererOutputFrameRate)
       data.setVideoDelay(stats.delay)
   }
   override fun onRemoteAudioStats(stats: RemoteAudioStats?) {
       if (!statsManager()!!.isEnabled) return
       val data = statsManager()!!.getStatsData(stats!!.uid) as RemoteStatsData
           ?: return
       data.audioNetDelay = stats.networkTransportDelay
       data.audioNetJitter = stats.jitterBufferDelay
       data.audioLoss = stats.audioLossRate
       data.audioQuality = statsManager()!!.qualityToString(stats.quality)
   }
   //start timer function
   fun startOTPTimer() {
       cancelTimer()


       cTimer = object : CountDownTimer(60000, 1000) {
           override fun onTick(millisUntilFinished: Long) {

               val minutes: Long = millisUntilFinished / 1000 / 60
               val seconds = (millisUntilFinished / 1000 % 60)

           }
           override fun onFinish() {
               if(channelProfile==1) {
                   dashboardViewModel.endLiveApi(experienceId, userId,fileName)
               }

           }
       }
       cTimer?.start()
   }

   private fun cancelTimer() {
       if(cTimer!=null){
           cTimer?.cancel()
       }
   }



   override fun finish() {
       statsManager()!!.clearAllData()
       if(channelProfile==1) {

       dashboardViewModel.endLiveApi(experienceId, userId, fileName)

               endLiveSuccess=false
               appLiveTokenIs=""
               channelName=""
               dashboardViewModel.booleanGoLiveSuccess.value=false
           }



       super.finish()


   }



   fun onLeaveClicked(view: View?) {

          // finishLive()
           finish()

   }



   fun onSwitchCameraClicked(view: View?) {
       if (view!!.isActivated) {
           mMuteVideoBtn.setImageResource(R.drawable.ic_videoofff)

       } else {

           mMuteVideoBtn.setImageResource(R.drawable.ic_videoonf)
       }
       rtcEngine().switchCamera()
   }

   fun onBeautyClicked(view: View) {
       view.isActivated = !view.isActivated
       rtcEngine().setBeautyEffectOptions(
           view.isActivated,
           Constants.DEFAULT_BEAUTY_OPTIONS
       )
   }



   fun onMuteAudioClicked(view: View) {
//        if (!mMuteVideoBtn.isActivated) return

       if (view.isActivated) {

           mMuteAudioBtn.setImageResource(R.drawable.ic_mutemikef)

       } else {
           rtcEngine().enableVideo()// startBroadcast()
           mMuteAudioBtn.setImageResource(R.drawable.ic_mikef)
       }
       rtcEngine().muteLocalAudioStream(view.isActivated)
       view.isActivated = !view.isActivated
   }

   fun onMuteVideoClicked(view: View) {
       if (view.isActivated) {
           rtcEngine().disableVideo()//stopBroadcast()
           mMuteVideoBtn.setImageResource(R.drawable.ic_videoofff)

       } else {
           rtcEngine().enableVideo()// startBroadcast()
           mMuteVideoBtn.setImageResource(R.drawable.ic_videoonf)
       }
       view.isActivated = !view.isActivated
   }

   companion object {
       private val TAG = LiveActivity::class.java.simpleName
   }}}

ActivityRtcLivebase

abstract class ActivityRtcLiveBase : LiveBaseActivity(), EventHandler {
   var channelNameis: String? = null
   var channelProfile:Int = 0
   var uid:Long=0
   var temp:Long=0
   var appLiveToken: String? = null
   private lateinit var dataExperienceDetails: ModelResponseExperienceDetailsData
   private var surFaceIs:String?=null
   private var liveSuccessFull:Boolean=false
   private var surfaceViewJoin:SurfaceView?=null

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       registerRtcEventHandler(this)
       val intent = intent
      appLiveToken =intent.getStringExtra(AppConstants.PREF_USER_APP_TOKEN_INTENT)
      channelNameis =intent.getStringExtra(AppConstants.CHANNEL_MESSAGE)
      channelProfile = intent.getIntExtra(AppConstants.PROFILE_MESSAGE,-1)
       uid=intent.getLongExtra(AppConstants.UID,0)

       if (intent.hasExtra(AppConstants.LIVE_EXPERIENCE_KEY)) {
           intent.getParcelableExtra<ModelResponseExperienceDetailsData>(AppConstants.LIVE_EXPERIENCE_KEY)
               ?.let {
                   if(it!=null){
                       dataExperienceDetails= it
                       Log.d("jigData==","live="+dataExperienceDetails)

                   }

               }
       }
       if(!appLiveToken.isNullOrEmpty()){
           joinChannel()
       }

   }

   private fun configVideo() {
       val configuration = VideoEncoderConfiguration(
           Constants.VIDEO_DIMENSIONS.get(config().videoDimenIndex),
           VideoEncoderConfiguration.FRAME_RATE.FRAME_RATE_FPS_15,
           VideoEncoderConfiguration.STANDARD_BITRATE,
           VideoEncoderConfiguration.ORIENTATION_MODE.ORIENTATION_MODE_FIXED_PORTRAIT
       )

       Log.d("==>","configuration="+configuration)
       configuration.mirrorMode = Constants.VIDEO_MIRROR_MODES.get(config().mirrorEncodeIndex)
       rtcEngine().setVideoEncoderConfiguration(configuration)

   }

   open fun joinChannel() {

       var token=appLiveToken
       if (TextUtils.isEmpty(token) || TextUtils.equals(token, "#YOUR ACCESS TOKEN#")) {
           token = null // default, no token
       }
       rtcEngine().setChannelProfile(Constants.CHANNEL_PROFILE_LIVE_BROADCASTING)
       rtcEngine().enableVideo()
       configVideo()
       rtcEngine().joinChannel(token,channelNameis, "", uid.toInt())


   }

   protected fun prepareRtcVideo(uid: Int, local: Boolean): SurfaceView {

       val surface = RtcEngine.CreateRendererView(applicationContext)
     surFaceIs= surface.toString()
       surfaceViewJoin=surface
       surfaceViewJoin?.let { surfaceView ->
           liveSuccessFull=true
       } ?:
       if(channelProfile==2){
           if(surface==null)
           {
               joinChannel()
           }

       }
       if(!liveSuccessFull){
           if(channelProfile==2){
               if(surface==null)
               {
                   joinChannel()
               }

           }
       }

       if (local) {
           rtcEngine().setupLocalVideo(
               VideoCanvas(
                   surface,
                   VideoCanvas.RENDER_MODE_HIDDEN,
                   uid,
                   Constants.VIDEO_MIRROR_MODES.get(config().mirrorLocalIndex)
               )
           )
       } else {
           Log.d("==>","surface="+surface)


           rtcEngine().setupRemoteVideo(
               VideoCanvas(
                   surface,
                   VideoCanvas.RENDER_MODE_HIDDEN,
                   uid,
                   Constants.VIDEO_MIRROR_MODES.get(config().mirrorRemoteIndex)
               )
           )


       }
       return surface
   }

   private fun reApiCallOfLive() {
       intent.putExtra(AppConstants.LIVE_EXPERIENCE_KEY, dataExperienceDetails)
       val channelProfile = io.agora.rtc.Constants.CLIENT_ROLE_AUDIENCE
       val intent = Intent(this, LiveActivity::class.java)
       val name = dataExperienceDetails.owner.firstName + " " + dataExperienceDetails.owner.lastName
       intent.putExtra(
           AppConstants.PREF_USER_APP_TOKEN_INTENT,
           appPreferences.getAppPrefString(AppConstants.PREF_USER_JOIN_LIVE_APP_TOKEN)
       )
       intent.putExtra(
           AppConstants.CHANNEL_MESSAGE, dataExperienceDetails.experienceDetails.roomName
       )
           intent.putExtra(
           AppConstants.PREF_USER_PROFILE_LIVE,
           appPreferences.getAppPrefString(AppConstants.PREF_USER_EXPERIENCE_USER_DETAILS_PROFILE_IMAGE)
       )



       intent.putExtra(
           AppConstants.DATA_EXPERIENCE_DETAILS,
           dataExperienceDetails
       )
       intent.putExtra(AppConstants.PREF_USER_PROFILER_NAME, name)
       intent.putExtra(
           AppConstants.PROFILE_MESSAGE,
           channelProfile
       ) //Constants.CLIENT_ROLE_BROADCASTER;//for host Constants.CLIENT_ROLE_AUDIENCE;//for audience

       intent.putExtra(
           AppConstants.UID,
           appPreferences.getAppPrefString(AppConstants.PREF_USER_DETAILS_Uid_JOIN_LIVE)!!
               .toLongOrNull()
       )
       intent.putExtra(
           AppConstants.LIVE_ADDRESS,
           dataExperienceDetails.experienceDetails.experienceAddress
       )
       intent.putExtra(
           AppConstants.LIVE_DESCRIPTION,
           dataExperienceDetails.experienceDetails.experienceDescription
       )
       intent.putExtra(AppConstants.LIVE_EXP_DATE, dataExperienceDetails.createdAt)
       shouldDoTopToBottomTransitionOnScreenChange = true
       startActivity(intent)
       finish()

   }

   protected fun removeRtcVideo(uid: Int, local: Boolean) {

       if (local) {
           rtcEngine().setupLocalVideo(null)
       } else {
           Log.d("==>","localrm="+local)

           rtcEngine().setupRemoteVideo(VideoCanvas(null, VideoCanvas.RENDER_MODE_HIDDEN, uid))

       }
   }

   override fun onDestroy() {
       super.onDestroy()
       removeRtcEventHandler(this)
       rtcEngine().leaveChannel()
   }
}

LiveBaseActivity

abstract class LiveBaseActivity : BaseActivity(), EventHandler {
   protected var mDisplayMetrics = DisplayMetrics()
   protected var mStatusBarHeight = 0
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setGlobalLayoutListener()
       displayMetrics
       initStatusBarHeight()
   }

   private fun setGlobalLayoutListener() {
       val layout = findViewById<View>(Window.ID_ANDROID_CONTENT)
       val observer = layout.viewTreeObserver
       observer.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
           override fun onGlobalLayout() {
               layout.viewTreeObserver.removeOnGlobalLayoutListener(this)
               onGlobalLayoutCompleted()
           }
       })
   }

   protected open fun onGlobalLayoutCompleted() {}
   private val displayMetrics: Unit
       private get() {
           windowManager.defaultDisplay.getMetrics(mDisplayMetrics)
       }

   private fun initStatusBarHeight() {
       mStatusBarHeight = WindowUtil.getSystemStatusBarHeight(this)
   }

   protected fun application(): MyApplication {
       return application as MyApplication
   }

   protected fun rtcEngine(): RtcEngine {
       return application().rtcEngine()!!
   }

   protected fun config(): EngineConfig {
       return application().engineConfig()!!
   }

   protected fun statsManager(): StatsManager? {
       return application().statsManager()
   }

   protected fun registerRtcEventHandler(handler: EventHandler?) {
       application().registerEventHandler(handler)
   }

   protected fun removeRtcEventHandler(handler: EventHandler?) {
       application().removeEventHandler(handler)
   }
   override fun onFirstRemoteVideoDecoded(uid: Int, width: Int, height: Int, elapsed: Int) {}
   override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
   }


   override fun onLeaveChannel(stats: RtcStats?) {}


   override fun onUserOffline(uid: Int, reason: Int) {}
   override fun onUserJoined(uid: Int, elapsed: Int) {
   }
   override fun onLastmileQuality(quality: Int) {}

     override fun onLastmileProbeResult(result: LastmileProbeResult?) {}
   override fun onLocalVideoStats(stats: LocalVideoStats?) {}

    override fun onRtcStats(stats: RtcStats?) {}
   override fun onNetworkQuality(uid: Int, txQuality: Int, rxQuality: Int) {}
   override fun onRemoteVideoStats(stats: RemoteVideoStats?) {}
   override fun onRemoteAudioStats(stats: RemoteAudioStats?) {}
}

5. Now VideoGridContainer Adapter you Need to Create.

public class VideoGridContainer extends RelativeLayout implements Runnable {
   private static final int MAX_USER = 4;
   private static final int STATS_REFRESH_INTERVAL = 2000;
   private static final int STAT_LEFT_MARGIN = 34;
   private static final int STAT_TEXT_SIZE = 10;

   private SparseArray<ViewGroup> mUserViewList = new SparseArray<>(MAX_USER);
   private List<Integer> mUidList = new ArrayList<>(MAX_USER);
   private StatsManager mStatsManager;
   private Handler mHandler;
   private int mStatMarginBottom;

   public VideoGridContainer(Context context) {
       super(context);
       init();
   }

   public VideoGridContainer(Context context, AttributeSet attrs) {
       super(context, attrs);
       init();
   }

   public VideoGridContainer(Context context, AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
       init();
   }

   private void init() {
       setBackgroundResource(R.drawable.live_room_bg);
       mStatMarginBottom = getResources().getDimensionPixelSize(
               R.dimen.live_stat_margin_bottom);
       mHandler = new Handler(getContext().getMainLooper());
   }

   public void setStatsManager(StatsManager manager) {
       mStatsManager = manager;
   }

   public void addUserVideoSurface(int uid, SurfaceView surface, boolean isLocal) {
       if (surface == null) {
           return;
       }

       int id = -1;
       if (isLocal) {
           if (mUidList.contains(0)) {
               mUidList.remove((Integer) 0);
               mUserViewList.remove(0);
           }

           if (mUidList.size() == MAX_USER) {
               mUidList.remove(0);
               mUserViewList.remove(0);
           }
           id = 0;
       } else {
           if (mUidList.contains(uid)) {
               mUidList.remove((Integer) uid);
               mUserViewList.remove(uid);
           }

           if (mUidList.size() < MAX_USER) {
               id = uid;
           }
       }

       if (id == 0) mUidList.add(0, uid);
       else mUidList.add(uid);

       if (id != -1) {
           mUserViewList.append(uid, createVideoView(surface));

           if (mStatsManager != null) {
               mStatsManager.addUserStats(uid, isLocal);
               if (mStatsManager.isEnabled()) {
                   mHandler.removeCallbacks(this);
                   mHandler.postDelayed(this, STATS_REFRESH_INTERVAL);
               }
           }

           requestGridLayout();
       }
   }

   private ViewGroup createVideoView(SurfaceView surface) {
       RelativeLayout layout = new RelativeLayout(getContext());

       layout.setId(surface.hashCode());

       LayoutParams videoLayoutParams =
               new LayoutParams(
                       ViewGroup.LayoutParams.MATCH_PARENT,
                       ViewGroup.LayoutParams.MATCH_PARENT);
       layout.addView(surface, videoLayoutParams);

       TextView text = new TextView(getContext());
       text.setId(layout.hashCode());
       LayoutParams textParams =
               new LayoutParams(
                       ViewGroup.LayoutParams.MATCH_PARENT,
                       ViewGroup.LayoutParams.WRAP_CONTENT);
       textParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE);
       textParams.bottomMargin = mStatMarginBottom;
       textParams.leftMargin = STAT_LEFT_MARGIN;
       text.setTextColor(Color.WHITE);
       text.setTextSize(STAT_TEXT_SIZE);

       layout.addView(text, textParams);
       return layout;
   }

   public void removeUserVideo(int uid, boolean isLocal) {
       if (isLocal && mUidList.contains(0)) {
           mUidList.remove((Integer) 0);
           mUserViewList.remove(0);
       } else if (mUidList.contains(uid)) {
           mUidList.remove((Integer) uid);
           mUserViewList.remove(uid);
       }

       mStatsManager.removeUserStats(uid);
       requestGridLayout();

       if (getChildCount() == 0) {
           mHandler.removeCallbacks(this);
       }
   }

   private void requestGridLayout() {
       removeAllViews();
       layout(mUidList.size());
   }

   private void layout(int size) {
       LayoutParams[] params = getParams(size);
       for (int i = 0; i < size; i++) {
           addView(mUserViewList.get(mUidList.get(i)), params[i]);
       }
   }

   private LayoutParams[] getParams(int size) {
       int width = getMeasuredWidth();
       int height = getMeasuredHeight();

       LayoutParams[] array =
               new LayoutParams[size];

       for (int i = 0; i < size; i++) {
           if (i == 0) {
               array[0] = new LayoutParams(
                       LayoutParams.MATCH_PARENT,
                       LayoutParams.MATCH_PARENT);
               array[0].addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE);
               array[0].addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
           } else if (i == 1) {
               array[1] = new LayoutParams(width, height / 2);
               array[0].height = array[1].height;
               array[1].addRule(RelativeLayout.BELOW, mUserViewList.get(mUidList.get(0)).getId());
               array[1].addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
           } else if (i == 2) {
               array[i] = new LayoutParams(width / 2, height / 2);
               array[i - 1].width = array[i].width;
               array[i].addRule(RelativeLayout.RIGHT_OF, mUserViewList.get(mUidList.get(i - 1)).getId());
               array[i].addRule(RelativeLayout.ALIGN_TOP, mUserViewList.get(mUidList.get(i - 1)).getId());
           } else if (i == 3) {
               array[i] = new LayoutParams(width / 2, height / 2);
               array[0].width = width / 2;
               array[1].addRule(RelativeLayout.BELOW, 0);
               array[1].addRule(RelativeLayout.ALIGN_PARENT_LEFT, 0);
               array[1].addRule(RelativeLayout.RIGHT_OF, mUserViewList.get(mUidList.get(0)).getId());
               array[1].addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE);
               array[2].addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
               array[2].addRule(RelativeLayout.RIGHT_OF, 0);
               array[2].addRule(RelativeLayout.ALIGN_TOP, 0);
               array[2].addRule(RelativeLayout.BELOW, mUserViewList.get(mUidList.get(0)).getId());
               array[3].addRule(RelativeLayout.BELOW, mUserViewList.get(mUidList.get(1)).getId());
               array[3].addRule(RelativeLayout.RIGHT_OF, mUserViewList.get(mUidList.get(2)).getId());
           }
       }

       return array;
   }

   @Override
   protected void onDetachedFromWindow() {
       super.onDetachedFromWindow();
       clearAllVideo();
   }

   private void clearAllVideo() {
       removeAllViews();
       mUserViewList.clear();
       mUidList.clear();
       mHandler.removeCallbacks(this);
   }

   @Override
   public void run() {
       if (mStatsManager != null && mStatsManager.isEnabled()) {
           int count = getChildCount();
           for (int i = 0; i < count; i++) {
               RelativeLayout layout = (RelativeLayout) getChildAt(i);
               TextView text = layout.findViewById(layout.hashCode());
               if (text != null) {
                   StatsData data = mStatsManager.getStatsData(mUidList.get(i));
                   String info = data != null ? data.toString() : null;
                   if (info != null) text.setText(info);
               }
           }

           mHandler.postDelayed(this, STATS_REFRESH_INTERVAL);
       }
   }
}

6. Now you need to create three util class below the name.

  1. AgoraEventHandler
  2. EngineConfig
  3. EventHandler

1. AgoraEventHandler util class

public class AgoraEventHandler extends IRtcEngineEventHandler {
    private ArrayList<EventHandler> mHandler = new ArrayList<>();

    public void addHandler(EventHandler handler) {
        mHandler.add(handler);
    }

    public void removeHandler(EventHandler handler) {
        mHandler.remove(handler);
    }

    @Override
    public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
        for (EventHandler handler : mHandler) {
            handler.onJoinChannelSuccess(channel, uid, elapsed);
        }
    }

    @Override
    public void onLeaveChannel(RtcStats stats) {
        for (EventHandler handler : mHandler) {
            handler.onLeaveChannel(stats);
        }
    }

    @Override
    public void onFirstRemoteVideoDecoded(int uid, int width, int height, int elapsed) {
        for (EventHandler handler : mHandler) {
            handler.onFirstRemoteVideoDecoded(uid, width, height, elapsed);
        }
    }

    @Override
    public void onUserJoined(int uid, int elapsed) {
        for (EventHandler handler : mHandler) {
            handler.onUserJoined(uid, elapsed);
        }
    }

    @Override
    public void onUserOffline(int uid, int reason) {
        for (EventHandler handler : mHandler) {
            handler.onUserOffline(uid, reason);
        }


    }

    @Override
    public void onLocalVideoStats(LocalVideoStats stats) {
        for (EventHandler handler : mHandler) {
            handler.onLocalVideoStats(stats);
        }
    }

    @Override
    public void onRtcStats(RtcStats stats) {
        for (EventHandler handler : mHandler) {
            handler.onRtcStats(stats);
        }
    }

    @Override
    public void onNetworkQuality(int uid, int txQuality, int rxQuality) {
        for (EventHandler handler : mHandler) {
            handler.onNetworkQuality(uid, txQuality, rxQuality);
        }
    }

    @Override
    public void onRemoteVideoStats(RemoteVideoStats stats) {
        for (EventHandler handler : mHandler) {
            handler.onRemoteVideoStats(stats);
        }
    }

    @Override
    public void onRemoteAudioStats(RemoteAudioStats stats) {
        for (EventHandler handler : mHandler) {
            handler.onRemoteAudioStats(stats);
        }
    }

    @Override
    public void onLastmileQuality(int quality) {
        for (EventHandler handler : mHandler) {
            handler.onLastmileQuality(quality);
        }
    }

    @Override
    public void onLastmileProbeResult(LastmileProbeResult result) {
        for (EventHandler handler : mHandler) {
            handler.onLastmileProbeResult(result);
        }
    }
}

2. EngineConfig util class

public class EngineConfig {
   // private static final int DEFAULT_UID = 0;
   // private int mUid = DEFAULT_UID;

   private String mChannelName;
   private boolean mShowVideoStats;
   private int mDimenIndex = Constants.DEFAULT_PROFILE_IDX;
   private int mMirrorLocalIndex;
   private int mMirrorRemoteIndex;
   private int mMirrorEncodeIndex;


   public int getVideoDimenIndex() {
       return mDimenIndex;
   }

   public void setVideoDimenIndex(int index) {
       mDimenIndex = index;
   }

   public String getChannelName() {
       return mChannelName;
   }

   public void setChannelName(String mChannel) {
       this.mChannelName = mChannel;
   }

   public boolean ifShowVideoStats() {
       return mShowVideoStats;
   }

   public void setIfShowVideoStats(boolean show) {
       mShowVideoStats = show;
   }

   public int getMirrorLocalIndex() {
       return mMirrorLocalIndex;
   }

   public void setMirrorLocalIndex(int index) {
       mMirrorLocalIndex = index;
   }

   public int getMirrorRemoteIndex() {
       return mMirrorRemoteIndex;
   }

   public void setMirrorRemoteIndex(int index) {
       mMirrorRemoteIndex = index;
   }

   public int getMirrorEncodeIndex() {
       return mMirrorEncodeIndex;
   }

   public void setMirrorEncodeIndex(int index) {
       mMirrorEncodeIndex = index;
   }
}

3. EventHandler util class

public interface EventHandler {
   void onFirstRemoteVideoDecoded(int uid, int width, int height, int elapsed);

   void onLeaveChannel(IRtcEngineEventHandler.RtcStats stats);

   void onJoinChannelSuccess(String channel, int uid, int elapsed);

   void onUserOffline(int uid, int reason);

   void onUserJoined(int uid, int elapsed);

   void onLastmileQuality(int quality);

   void onLastmileProbeResult(IRtcEngineEventHandler.LastmileProbeResult result);

   void onLocalVideoStats(IRtcEngineEventHandler.LocalVideoStats stats);

   void onRtcStats(IRtcEngineEventHandler.RtcStats stats);

   void onNetworkQuality(int uid, int txQuality, int rxQuality);

   void onRemoteVideoStats(IRtcEngineEventHandler.RemoteVideoStats stats);

   void onRemoteAudioStats(IRtcEngineEventHandler.RemoteAudioStats stats);

}

7. Now you need to create below four utill class

  1. LocalStatsData
  2. RemoteStatsData
  3. StatsData
  4. StatsManager

1. LocalStatsData

public class LocalStatsData extends StatsData {
   private static final String FORMAT = "Local(%d)\n\n" +
           "%dx%d %dfps\n" +
           "LastMile delay: %d ms\n" +
           "Video tx/rx (kbps): %d/%d\n" +
           "Audio tx/rx (kbps): %d/%d\n" +
           "CPU: app/total %.1f%%/%.1f%%\n" +
           "Quality tx/rx: %s/%s\n" +
           "Loss tx/rx: %d%%/%d%%";

   private int lastMileDelay;
   private int videoSend;
   private int videoRecv;
   private int audioSend;
   private int audioRecv;
   private double cpuApp;
   private double cpuTotal;
   private int sendLoss;
   private int recvLoss;

   @Override
   public String toString() {
       return String.format(Locale.getDefault(), FORMAT,
               getUid(),
               getWidth(), getHeight(), getFramerate(),
               getLastMileDelay(),
               getVideoSendBitrate(), getVideoRecvBitrate(),
               getAudioSendBitrate(), getAudioRecvBitrate(),
               getCpuApp(), getCpuTotal(),
               getSendQuality(), getRecvQuality(),
               getSendLoss(), getRecvLoss());
   }

   public int getLastMileDelay() {
       return lastMileDelay;
   }

   public void setLastMileDelay(int lastMileDelay) {
       this.lastMileDelay = lastMileDelay;
   }

   public int getVideoSendBitrate() {
       return videoSend;
   }

   public void setVideoSendBitrate(int videoSend) {
       this.videoSend = videoSend;
   }

   public int getVideoRecvBitrate() {
       return videoRecv;
   }

   public void setVideoRecvBitrate(int videoRecv) {
       this.videoRecv = videoRecv;
   }

   public int getAudioSendBitrate() {
       return audioSend;
   }

   public void setAudioSendBitrate(int audioSend) {
       this.audioSend = audioSend;
   }

   public int getAudioRecvBitrate() {
       return audioRecv;
   }

   public void setAudioRecvBitrate(int audioRecv) {
       this.audioRecv = audioRecv;
   }

   public double getCpuApp() {
       return cpuApp;
   }

   public void setCpuApp(double cpuApp) {
       this.cpuApp = cpuApp;
   }

   public double getCpuTotal() {
       return cpuTotal;
   }

   public void setCpuTotal(double cpuTotal) {
       this.cpuTotal = cpuTotal;
   }

   public int getSendLoss() {
       return sendLoss;
   }

   public void setSendLoss(int sendLoss) {
       this.sendLoss = sendLoss;
   }

   public int getRecvLoss() {
       return recvLoss;
   }

   public void setRecvLoss(int recvLoss) {
       this.recvLoss = recvLoss;
   }

}

2. RemoteStatsData

public class RemoteStatsData extends StatsData {
   private static final String FORMAT = "Remote(%d)\n\n" +
           "%dx%d %dfps\n" +
           "Quality tx/rx: %s/%s\n" +
           "Video delay: %d ms\n" +
           "Audio net delay/jitter: %dms/%dms\n" +
           "Audio loss/quality: %d%%/%s";

   private int videoDelay;
   private int audioNetDelay;
   private int audioNetJitter;
   private int audioLoss;
   private String audioQuality;

   @Override
   public String toString() {
       return String.format(Locale.getDefault(), FORMAT,
               getUid(),
               getWidth(), getHeight(), getFramerate(),
               getSendQuality(), getRecvQuality(),
               getVideoDelay(),
               getAudioNetDelay(), getAudioNetJitter(),
               getAudioLoss(), getAudioQuality());
   }

   public static String getFORMAT() {
       return FORMAT;
   }

   public int getVideoDelay() {
       return videoDelay;
   }

   public void setVideoDelay(int videoDelay) {
       this.videoDelay = videoDelay;
   }

   public int getAudioNetDelay() {
       return audioNetDelay;
   }

   public void setAudioNetDelay(int audioNetDelay) {
       this.audioNetDelay = audioNetDelay;
   }

   public int getAudioNetJitter() {
       return audioNetJitter;
   }

   public void setAudioNetJitter(int audioNetJitter) {
       this.audioNetJitter = audioNetJitter;
   }

   public int getAudioLoss() {
       return audioLoss;
   }

   public void setAudioLoss(int audioLoss) {
       this.audioLoss = audioLoss;
   }

   public String getAudioQuality() {
       return audioQuality;
   }

   public void setAudioQuality(String audioQuality) {
       this.audioQuality = audioQuality;
   }
}

3. StatsData

public class StatsData {
   private long uid;
   private int width;
   private int height;
   private int framerate;
   private String recvQuality;
   private String sendQuality;

   public long getUid() {
       return uid;
   }

   public void setUid(long uid) {
       this.uid = uid;
   }

   public int getWidth() {
       return width;
   }

   public void setWidth(int width) {
       this.width = width;
   }

   public int getHeight() {
       return height;
   }

   public void setHeight(int height) {
       this.height = height;
   }

   public int getFramerate() {
       return framerate;
   }

   public void setFramerate(int framerate) {
       this.framerate = framerate;
   }

   public String getRecvQuality() {
       return recvQuality;
   }

   public void setRecvQuality(String recvQuality) {
       this.recvQuality = recvQuality;
   }

   public String getSendQuality() {
       return sendQuality;
   }

   public void setSendQuality(String sendQuality) {
       this.sendQuality = sendQuality;
   }
}

4. StatsManager

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import io.agora.rtc.Constants;

public class StatsManager {
   private List<Integer> mUidList = new ArrayList<>();
   private Map<Integer, StatsData> mDataMap = new HashMap<>();
   private boolean mEnable = false;

   public void addUserStats(int uid, boolean ifLocal) {
       if (mUidList.contains(uid) && mDataMap.containsKey(uid)) {
           return;
       }

       StatsData data = ifLocal
               ? new LocalStatsData()
               : new RemoteStatsData();
       // in case 32-bit unsigned integer uid is received
       data.setUid(uid & 0xFFFFFFFFL);

       if (ifLocal) mUidList.add(0, uid);
       else mUidList.add(uid);

       mDataMap.put(uid, data);
   }

   public void removeUserStats(int uid) {
       if (mUidList.contains(uid) && mDataMap.containsKey(uid)) {
           mUidList.remove((Integer) uid);
           mDataMap.remove(uid);
       }
   }

   public StatsData getStatsData(int uid) {
       if (mUidList.contains(uid) && mDataMap.containsKey(uid)) {
           return mDataMap.get(uid);
       } else {
           return null;
       }
   }

   public String qualityToString(int quality) {
       switch (quality) {
           case Constants.QUALITY_EXCELLENT:
               return "Exc";
           case Constants.QUALITY_GOOD:
               return "Good";
           case Constants.QUALITY_POOR:
               return "Poor";
           case Constants.QUALITY_BAD:
               return "Bad";
           case Constants.QUALITY_VBAD:
               return "VBad";
           case Constants.QUALITY_DOWN:
               return "Down";
           default:
               return "Unk";
       }
   }

   public void enableStats(boolean enabled) {
       mEnable = enabled;
   }

   public boolean isEnabled() {
       return mEnable;
   }

   public void clearAllData() {
       mUidList.clear();
       mDataMap.clear();

   }
}

Why Agora is the Best Solution for Live Audio & Video Streaming?

Agora live Streaming is used for analytics tracking, customer support, video monetization, and cost-effectiveness. Agora live Streaming is used for analytics tracking and one to many and many to many audio or video live streaming with agora SDK, in a video calling with audio interactive live streaming. users can be host or audiences, the host can start live and the audience joins that streaming one too many and many too many audio or video live streaming also possible.

Android Custom Library CTA1

Conclusion

I would like to conclude this blog is used for Live streaming which improves Ease Convenience and cost-effectiveness, used for analytics tracking, and one to many and many to many audio or Video Live Streaming Using Agora SDK and Live Streaming Internet Video Can Whip Up Online Interaction.

Social media and live streaming platforms have made it easy and effortless for anyone to live stream from their smart devices. With live streaming, building brands from new ideas can make it easier for their senior employees and team leads to communicate and exchange information with the team.

FAQs

1. Can I Make a Custom UI?

Yes, it is possible you can make it.

2. Can we use live streams on iOS and Apple both devices with the same app?

Yes, it is possible on both devices.

3. Why does a blank screen come when a user joins a live stream?

Sometimes the first time a live stream frame is not initialized because of some token invalid issue etc.

4. What is the channel?

Channel used for transmitting real-time data in the agora

5. What is video SDK?

It enables users to build unique live videos.

6. What is agora live streaming?

It enables one-to-many and many-to-many audio or video live streaming.

lets start your project

Jigar Viradiya

I am currently doing job as an Android developer in OneClick IT Consultancy past one year, I am very good in solving various types of problems to help our clients.

Related Articles

Back to top button