davidliu
Committed by GitHub

Fix network request leak on pre-8.1 devices (#389)

* Fix network request leak

* spotless
... ... @@ -20,9 +20,8 @@ package io.livekit.android.room
import android.content.Context
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import androidx.annotation.VisibleForTesting
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
... ... @@ -40,6 +39,7 @@ import io.livekit.android.e2ee.E2EEOptions
import io.livekit.android.events.*
import io.livekit.android.memory.CloseableManager
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.network.NetworkCallbackManager
import io.livekit.android.room.participant.*
import io.livekit.android.room.track.*
import io.livekit.android.util.FlowObservable
... ... @@ -371,11 +371,7 @@ constructor(
ioDispatcher + emptyCoroutineExceptionHandler,
) {
engine.join(url, token, options, roomOptions)
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(networkRequest, networkCallback)
networkCallbackManager.registerCallback()
ensureActive()
if (options.audio) {
... ... @@ -658,12 +654,7 @@ constructor(
if (state == State.DISCONNECTED) {
return@runBlocking
}
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
cm.unregisterNetworkCallback(networkCallback)
} catch (e: IllegalArgumentException) {
// do nothing, may happen on older versions if attempting to unregister twice.
}
networkCallbackManager.unregisterCallback()
state = State.DISCONNECTED
cleanupRoom()
... ... @@ -763,28 +754,25 @@ constructor(
}
// ------------------------------------- NetworkCallback -------------------------------------//
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
/**
* @suppress
*/
override fun onLost(network: Network) {
// lost connection, flip to reconnecting
hasLostConnectivity = true
}
private val networkCallbackManager = NetworkCallbackManager(
object : NetworkCallback() {
override fun onLost(network: Network) {
// lost connection, flip to reconnecting
hasLostConnectivity = true
}
/**
* @suppress
*/
override fun onAvailable(network: Network) {
// only actually reconnect after connection is re-established
if (!hasLostConnectivity) {
return
override fun onAvailable(network: Network) {
// only actually reconnect after connection is re-established
if (!hasLostConnectivity) {
return
}
LKLog.i { "network connection available, reconnecting" }
reconnect()
hasLostConnectivity = false
}
LKLog.i { "network connection available, reconnecting" }
reconnect()
hasLostConnectivity = false
}
}
},
connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
).apply { closeableManager.registerClosable(this) }
// ----------------------------------- RTCEngine.Listener ------------------------------------//
... ...
/*
* Copyright 2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.room.network
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import io.livekit.android.util.LKLog
import java.io.Closeable
import java.util.concurrent.atomic.AtomicBoolean
/**
* Manages a [ConnectivityManager.NetworkCallback] so that it is never
* registered multiple times. A NetworkCallback is allowed to be registered
* multiple times by the ConnectivityService, but the underlying network
* requests will leak on 8.0 and earlier.
*
* There's a 100 request hard limit, so leaks here are particularly dangerous.
*/
class NetworkCallbackManager(
private val networkCallback: ConnectivityManager.NetworkCallback,
private val connectivityManager: ConnectivityManager,
) : Closeable {
private val isRegistered = AtomicBoolean(false)
private val isClosed = AtomicBoolean(false)
@Synchronized
fun registerCallback() {
if (!isClosed.get() && isRegistered.compareAndSet(false, true)) {
try {
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
} catch (e: Exception) {
LKLog.w(e) { "Exception when trying to register network callback, reconnection may be impaired." }
}
}
}
@Synchronized
fun unregisterCallback() {
if (!isClosed.get() && isRegistered.compareAndSet(true, false)) {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (e: IllegalArgumentException) {
// do nothing, may happen on older versions if attempting to unregister twice.
// This shouldn't happen though, so log it just in case.
LKLog.w { "NetworkCallback was unregistered multiple times?" }
}
}
}
@Synchronized
override fun close() {
if (isClosed.get()) {
return
}
if (isRegistered.get()) {
unregisterCallback()
}
isClosed.set(true)
}
}
... ...
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-parcelize'
}
def getDefaultUrl() {
... ...
... ... @@ -16,6 +16,7 @@
package io.livekit.android.sample
import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.media.projection.MediaProjectionManager
... ... @@ -42,8 +43,11 @@ import io.livekit.android.room.track.CameraPosition
import io.livekit.android.room.track.LocalScreencastVideoTrack
import io.livekit.android.room.track.LocalVideoTrack
import io.livekit.android.room.track.Track
import io.livekit.android.sample.model.StressTest
import io.livekit.android.sample.service.ForegroundService
import io.livekit.android.util.LKLog
import io.livekit.android.util.flow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
... ... @@ -51,6 +55,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class CallViewModel(
... ... @@ -60,6 +65,7 @@ class CallViewModel(
val e2ee: Boolean = false,
val e2eeKey: String? = "",
val audioProcessorOptions: AudioProcessorOptions? = null,
val stressTest: StressTest = StressTest.None,
) : AndroidViewModel(application) {
private fun getE2EEOptions(): E2EEOptions? {
... ... @@ -169,7 +175,10 @@ class CallViewModel(
}
}
connectToRoom()
when (stressTest) {
is StressTest.SwitchRoom -> launch { stressTest.execute() }
is StressTest.None -> connectToRoom()
}
}
// Start a foreground service to keep the call from being interrupted if the
... ... @@ -383,6 +392,53 @@ class CallViewModel(
connectToRoom()
}
}
private suspend fun StressTest.SwitchRoom.execute() = coroutineScope {
launch {
while (isActive) {
delay(2000)
dumpReferenceTables()
}
}
while (isActive) {
Timber.d { "Stress test -> connect to first room" }
launch { quickConnectToRoom(firstToken) }
delay(200)
room.disconnect()
delay(50)
Timber.d { "Stress test -> connect to second room" }
launch { quickConnectToRoom(secondToken) }
delay(200)
room.disconnect()
delay(50)
}
}
private suspend fun quickConnectToRoom(token: String) {
try {
room.connect(
url = url,
token = token,
)
} catch (e: Throwable) {
Timber.e(e) { "Failed to connect to room" }
}
}
@SuppressLint("DiscouragedPrivateApi")
private fun dumpReferenceTables() {
try {
val cls = Class.forName("android.os.Debug")
val method = cls.getDeclaredMethod("dumpReferenceTables")
val con = cls.getDeclaredConstructor().apply {
isAccessible = true
}
method.invoke(con.newInstance())
} catch (e: Exception) {
LKLog.e(e) { "Unable to dump reference tables, you can try `adb shell settings put global hidden_api_policy 1`" }
}
}
}
private fun <T> LiveData<T>.hide(): LiveData<T> = this
... ...
/*
* Copyright 2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.sample.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
sealed class StressTest : Parcelable {
@Parcelize
data class SwitchRoom(
val firstToken: String,
val secondToken: String,
) : StressTest()
@Parcelize
object None : StressTest()
}
... ...
... ... @@ -48,6 +48,7 @@ import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
import io.livekit.android.sample.CallViewModel
import io.livekit.android.sample.common.R
import io.livekit.android.sample.model.StressTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
... ... @@ -62,6 +63,7 @@ class CallActivity : AppCompatActivity() {
token = args.token,
e2ee = args.e2eeOn,
e2eeKey = args.e2eeKey,
stressTest = args.stressTest,
application = application,
)
}
... ... @@ -478,5 +480,6 @@ class CallActivity : AppCompatActivity() {
val token: String,
val e2eeKey: String,
val e2eeOn: Boolean,
val stressTest: StressTest,
) : Parcelable
}
... ...
/*
* Copyright 2023 LiveKit, Inc.
* Copyright 2023-2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
... ... @@ -17,6 +17,8 @@
package io.livekit.android.composesample
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
... ... @@ -28,7 +30,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
... ... @@ -55,11 +56,28 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import io.livekit.android.composesample.ui.theme.AppTheme
import io.livekit.android.sample.MainViewModel
import io.livekit.android.sample.common.R
import io.livekit.android.sample.model.StressTest
import io.livekit.android.sample.util.requestNeededPermissions
import io.livekit.android.util.LKLog
@ExperimentalPagerApi
class MainActivity : ComponentActivity() {
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
/**
* @suppress
*/
override fun onLost(network: Network) {
LKLog.i { "network connection lost" }
}
/**
* @suppress
*/
override fun onAvailable(network: Network) {
LKLog.i { "network connection available, reconnecting" }
}
}
val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
... ... @@ -71,7 +89,7 @@ class MainActivity : ComponentActivity() {
defaultToken = viewModel.getSavedToken(),
defaultE2eeKey = viewModel.getSavedE2EEKey(),
defaultE2eeOn = viewModel.getE2EEOptionsOn(),
onConnect = { url, token, e2eeKey, e2eeOn ->
onConnect = { url, token, e2eeKey, e2eeOn, stressTest ->
val intent = Intent(this@MainActivity, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
... ... @@ -80,7 +98,8 @@ class MainActivity : ComponentActivity() {
token,
e2eeKey,
e2eeOn,
)
stressTest,
),
)
}
startActivity(intent)
... ... @@ -94,7 +113,7 @@ class MainActivity : ComponentActivity() {
Toast.makeText(
this@MainActivity,
"Values saved.",
Toast.LENGTH_SHORT
Toast.LENGTH_SHORT,
).show()
},
onReset = {
... ... @@ -102,9 +121,9 @@ class MainActivity : ComponentActivity() {
Toast.makeText(
this@MainActivity,
"Values reset.",
Toast.LENGTH_SHORT
Toast.LENGTH_SHORT,
).show()
}
},
)
}
}
... ... @@ -117,9 +136,10 @@ class MainActivity : ComponentActivity() {
fun MainContent(
defaultUrl: String = MainViewModel.URL,
defaultToken: String = MainViewModel.TOKEN,
defaultSecondToken: String = MainViewModel.TOKEN,
defaultE2eeKey: String = MainViewModel.E2EE_KEY,
defaultE2eeOn: Boolean = false,
onConnect: (url: String, token: String, e2eeKey: String, e2eeOn: Boolean) -> Unit = { _, _, _, _ -> },
onConnect: (url: String, token: String, e2eeKey: String, e2eeOn: Boolean, stressTest: StressTest) -> Unit = { _, _, _, _, _ -> },
onSave: (url: String, token: String, e2eeKey: String, e2eeOn: Boolean) -> Unit = { _, _, _, _ -> },
onReset: () -> Unit = {},
) {
... ... @@ -128,21 +148,23 @@ class MainActivity : ComponentActivity() {
var token by remember { mutableStateOf(defaultToken) }
var e2eeKey by remember { mutableStateOf(defaultE2eeKey) }
var e2eeOn by remember { mutableStateOf(defaultE2eeOn) }
var stressTest by remember { mutableStateOf(false) }
var secondToken by remember { mutableStateOf(defaultSecondToken) }
val scrollState = rememberScrollState()
// A surface container using the 'background' color from the theme
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier
.fillMaxSize()
.fillMaxSize(),
) {
Box(
modifier = Modifier
.verticalScroll(scrollState)
.verticalScroll(scrollState),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(10.dp)
.padding(10.dp),
) {
Spacer(modifier = Modifier.height(50.dp))
Image(
... ... @@ -171,7 +193,18 @@ class MainActivity : ComponentActivity() {
onValueChange = { e2eeKey = it },
label = { Text("E2EE Key") },
modifier = Modifier.fillMaxWidth(),
enabled = e2eeOn
enabled = e2eeOn,
)
}
if (stressTest) {
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = secondToken,
onValueChange = { secondToken = it },
label = { Text("Second token") },
modifier = Modifier.fillMaxWidth(),
enabled = stressTest,
)
}
... ... @@ -179,18 +212,38 @@ class MainActivity : ComponentActivity() {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Text("Enable E2EE")
Switch(
checked = e2eeOn,
onCheckedChange = { e2eeOn = it },
modifier = Modifier.defaultMinSize(minHeight = 100.dp)
)
}
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = { onConnect(url, token, e2eeKey, e2eeOn) }) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Stress test")
Switch(
checked = stressTest,
onCheckedChange = { stressTest = it },
)
}
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = {
val stressTestCmd = if (stressTest) {
StressTest.SwitchRoom(token, secondToken)
} else {
StressTest.None
}
onConnect(url, token, e2eeKey, e2eeOn, stressTestCmd)
},
) {
Text("Connect")
}
... ... @@ -200,11 +253,13 @@ class MainActivity : ComponentActivity() {
}
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = {
onReset()
url = MainViewModel.URL
token = MainViewModel.TOKEN
}) {
Button(
onClick = {
onReset()
url = MainViewModel.URL
token = MainViewModel.TOKEN
},
) {
Text("Reset Values")
}
}
... ...
/*
* Copyright 2023 LiveKit, Inc.
* Copyright 2023-2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
... ... @@ -25,6 +25,6 @@ class SampleApplication : Application() {
override fun onCreate() {
super.onCreate()
LiveKit.loggingLevel = LoggingLevel.VERBOSE
LiveKit.enableWebRTCLogging = true
// LiveKit.enableWebRTCLogging = true
}
}
... ...
... ... @@ -28,18 +28,15 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.ajalt.timberkt.Timber
import com.xwray.groupie.GroupieAdapter
import io.livekit.android.audio.AudioProcessorInterface
import io.livekit.android.audio.AudioProcessorOptions
import io.livekit.android.sample.common.R
import io.livekit.android.sample.databinding.CallActivityBinding
import io.livekit.android.sample.dialog.showAudioProcessorSwitchDialog
import io.livekit.android.sample.dialog.showDebugMenuDialog
import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog
import io.livekit.android.sample.model.StressTest
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer
class CallActivity : AppCompatActivity() {
... ... @@ -47,34 +44,14 @@ class CallActivity : AppCompatActivity() {
val args = intent.getParcelableExtra<BundleArgs>(KEY_ARGS)
?: throw NullPointerException("args is null!")
val audioProcessor = object : AudioProcessorInterface {
override fun isEnabled(): Boolean {
Timber.d { "${getName()} isEnabled" }
return true
}
override fun getName(): String {
return "fake_noise_cancellation"
}
override fun initializeAudioProcessing(sampleRateHz: Int, numChannels: Int) {
Timber.d { "${getName()} initialize" }
}
override fun resetAudioProcessing(newRate: Int) {
Timber.d { "${getName()} reset" }
}
override fun processAudio(numBands: Int, numFrames: Int, buffer: ByteBuffer) {
Timber.d { "${getName()} process" }
}
}
val audioProcessorOptions = AudioProcessorOptions(
capturePostProcessor = audioProcessor,
CallViewModel(
url = args.url,
token = args.token,
e2ee = args.e2eeOn,
e2eeKey = args.e2eeKey,
stressTest = args.stressTest,
application = application,
)
CallViewModel(args.url, args.token, application, args.e2ee, args.e2eeKey, audioProcessorOptions)
}
private lateinit var binding: CallActivityBinding
private val screenCaptureIntentLauncher =
... ... @@ -247,5 +224,11 @@ class CallActivity : AppCompatActivity() {
}
@Parcelize
data class BundleArgs(val url: String, val token: String, val e2ee: Boolean, val e2eeKey: String) : Parcelable
data class BundleArgs(
val url: String,
val token: String,
val e2eeKey: String,
val e2eeOn: Boolean,
val stressTest: StressTest,
) : Parcelable
}
... ...
/*
* Copyright 2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.sample
import android.content.Intent
... ... @@ -7,11 +23,12 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import io.livekit.android.sample.databinding.MainActivityBinding
import io.livekit.android.sample.model.StressTest
import io.livekit.android.sample.util.requestNeededPermissions
class MainActivity : AppCompatActivity() {
val viewModel by viewModels<MainViewModel>()
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
... ... @@ -21,6 +38,7 @@ class MainActivity : AppCompatActivity() {
val tokenString = viewModel.getSavedToken()
val e2EEOn = viewModel.getE2EEOptionsOn()
val e2EEKey = viewModel.getSavedE2EEKey()
binding.run {
url.editText?.text = SpannableStringBuilder(urlString)
token.editText?.text = SpannableStringBuilder(tokenString)
... ... @@ -31,11 +49,12 @@ class MainActivity : AppCompatActivity() {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
url.editText?.text.toString(),
token.editText?.text.toString(),
e2eeEnabled.isChecked,
e2eeKey.editText?.text.toString()
)
url = url.editText?.text.toString(),
token = token.editText?.text.toString(),
e2eeOn = e2eeEnabled.isChecked,
e2eeKey = e2eeKey.editText?.text.toString(),
stressTest = StressTest.None,
),
)
}
... ... @@ -51,7 +70,7 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(
this@MainActivity,
"Values saved.",
Toast.LENGTH_SHORT
Toast.LENGTH_SHORT,
).show()
}
... ... @@ -65,7 +84,7 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(
this@MainActivity,
"Values reset.",
Toast.LENGTH_SHORT
Toast.LENGTH_SHORT,
).show()
}
}
... ...