davidliu
Committed by GitHub

Move audio handling to background thread to avoid UI freezes. (#715)

  1 +---
  2 +"client-sdk-android": patch
  3 +---
  4 +
  5 +Move audio handling to background thread to avoid UI freezes.
@@ -21,8 +21,10 @@ import android.media.AudioAttributes @@ -21,8 +21,10 @@ import android.media.AudioAttributes
21 import android.media.AudioManager 21 import android.media.AudioManager
22 import android.os.Build 22 import android.os.Build
23 import android.os.Handler 23 import android.os.Handler
  24 +import android.os.HandlerThread
24 import android.os.Looper 25 import android.os.Looper
25 import com.twilio.audioswitch.* 26 import com.twilio.audioswitch.*
  27 +import io.livekit.android.util.LKLog
26 import javax.inject.Inject 28 import javax.inject.Inject
27 import javax.inject.Singleton 29 import javax.inject.Singleton
28 30
@@ -138,13 +140,26 @@ constructor(private val context: Context) : AudioHandler { @@ -138,13 +140,26 @@ constructor(private val context: Context) : AudioHandler {
138 140
139 private var audioSwitch: AbstractAudioSwitch? = null 141 private var audioSwitch: AbstractAudioSwitch? = null
140 142
141 - // AudioSwitch is not threadsafe, so all calls should be done on the main thread.  
142 - private val handler = Handler(Looper.getMainLooper()) 143 + // AudioSwitch is not threadsafe, so all calls should be done through a single thread.
  144 + private var handler: Handler? = null
  145 + private var thread: HandlerThread? = null
143 146
  147 + @Synchronized
144 override fun start() { 148 override fun start() {
  149 + if (handler != null || thread != null) {
  150 + LKLog.i { "AudioSwitchHandler called start multiple times?" }
  151 + }
  152 +
  153 + if (thread == null) {
  154 + thread = HandlerThread("AudioSwitchHandlerThread").also { it.start() }
  155 + }
  156 + if (handler == null) {
  157 + handler = Handler(thread!!.looper)
  158 + }
  159 +
145 if (audioSwitch == null) { 160 if (audioSwitch == null) {
146 - handler.removeCallbacksAndMessages(null)  
147 - handler.postAtFrontOfQueue { 161 + handler?.removeCallbacksAndMessages(null)
  162 + handler?.postAtFrontOfQueue {
148 val switch = 163 val switch =
149 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 164 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
150 AudioSwitch( 165 AudioSwitch(
@@ -176,12 +191,17 @@ constructor(private val context: Context) : AudioHandler { @@ -176,12 +191,17 @@ constructor(private val context: Context) : AudioHandler {
176 } 191 }
177 } 192 }
178 193
  194 + @Synchronized
179 override fun stop() { 195 override fun stop() {
180 - handler.removeCallbacksAndMessages(null)  
181 - handler.postAtFrontOfQueue { 196 + handler?.removeCallbacksAndMessages(null)
  197 + handler?.postAtFrontOfQueue {
182 audioSwitch?.stop() 198 audioSwitch?.stop()
183 audioSwitch = null 199 audioSwitch = null
184 } 200 }
  201 + thread?.quitSafely()
  202 +
  203 + handler = null
  204 + thread = null
185 } 205 }
186 206
187 /** 207 /**
@@ -199,11 +219,12 @@ constructor(private val context: Context) : AudioHandler { @@ -199,11 +219,12 @@ constructor(private val context: Context) : AudioHandler {
199 /** 219 /**
200 * Select a specific audio device. 220 * Select a specific audio device.
201 */ 221 */
  222 + @Synchronized
202 fun selectDevice(audioDevice: AudioDevice?) { 223 fun selectDevice(audioDevice: AudioDevice?) {
203 - if (Looper.myLooper() == Looper.getMainLooper()) { 224 + if (Looper.myLooper() == handler?.looper) {
204 audioSwitch?.selectDevice(audioDevice) 225 audioSwitch?.selectDevice(audioDevice)
205 } else { 226 } else {
206 - handler.post { 227 + handler?.post {
207 audioSwitch?.selectDevice(audioDevice) 228 audioSwitch?.selectDevice(audioDevice)
208 } 229 }
209 } 230 }
1 /* 1 /*
2 - * Copyright 2024 LiveKit, Inc. 2 + * Copyright 2024-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -26,7 +26,7 @@ import androidx.annotation.RequiresApi @@ -26,7 +26,7 @@ import androidx.annotation.RequiresApi
26 import io.livekit.android.dagger.InjectionNames 26 import io.livekit.android.dagger.InjectionNames
27 import io.livekit.android.util.CloseableCoroutineScope 27 import io.livekit.android.util.CloseableCoroutineScope
28 import io.livekit.android.util.LKLog 28 import io.livekit.android.util.LKLog
29 -import kotlinx.coroutines.MainCoroutineDispatcher 29 +import kotlinx.coroutines.CoroutineDispatcher
30 import kotlinx.coroutines.flow.MutableStateFlow 30 import kotlinx.coroutines.flow.MutableStateFlow
31 import kotlinx.coroutines.flow.collectLatest 31 import kotlinx.coroutines.flow.collectLatest
32 import kotlinx.coroutines.flow.combine 32 import kotlinx.coroutines.flow.combine
@@ -83,8 +83,8 @@ constructor() : CommunicationWorkaround { @@ -83,8 +83,8 @@ constructor() : CommunicationWorkaround {
83 internal class CommunicationWorkaroundImpl 83 internal class CommunicationWorkaroundImpl
84 @Inject 84 @Inject
85 constructor( 85 constructor(
86 - @Named(InjectionNames.DISPATCHER_MAIN)  
87 - dispatcher: MainCoroutineDispatcher, 86 + @Named(InjectionNames.DISPATCHER_IO)
  87 + dispatcher: CoroutineDispatcher,
88 ) : CommunicationWorkaround { 88 ) : CommunicationWorkaround {
89 89
90 private val coroutineScope = CloseableCoroutineScope(dispatcher) 90 private val coroutineScope = CloseableCoroutineScope(dispatcher)