davidliu

room reusable after disconnect

@@ -34,7 +34,8 @@ class RTCEngine @@ -34,7 +34,8 @@ class RTCEngine
34 internal constructor( 34 internal constructor(
35 val client: SignalClient, 35 val client: SignalClient,
36 private val pctFactory: PeerConnectionTransport.Factory, 36 private val pctFactory: PeerConnectionTransport.Factory,
37 - @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher, 37 + @Named(InjectionNames.DISPATCHER_IO)
  38 + private val ioDispatcher: CoroutineDispatcher,
38 ) : SignalClient.Listener, DataChannel.Observer { 39 ) : SignalClient.Listener, DataChannel.Observer {
39 internal var listener: Listener? = null 40 internal var listener: Listener? = null
40 41
@@ -77,8 +78,20 @@ internal constructor( @@ -77,8 +78,20 @@ internal constructor(
77 78
78 private val publisherObserver = PublisherTransportObserver(this, client) 79 private val publisherObserver = PublisherTransportObserver(this, client)
79 private val subscriberObserver = SubscriberTransportObserver(this, client) 80 private val subscriberObserver = SubscriberTransportObserver(this, client)
80 - internal lateinit var publisher: PeerConnectionTransport  
81 - internal lateinit var subscriber: PeerConnectionTransport 81 +
  82 + private var _publisher: PeerConnectionTransport? = null
  83 + internal val publisher: PeerConnectionTransport
  84 + get() {
  85 + return _publisher
  86 + ?: throw UninitializedPropertyAccessException("publisher has not been initialized yet.")
  87 + }
  88 + private var _subscriber: PeerConnectionTransport? = null
  89 + internal val subscriber: PeerConnectionTransport
  90 + get() {
  91 + return _subscriber
  92 + ?: throw UninitializedPropertyAccessException("subscriber has not been initialized yet.")
  93 + }
  94 +
82 private var reliableDataChannel: DataChannel? = null 95 private var reliableDataChannel: DataChannel? = null
83 private var reliableDataChannelSub: DataChannel? = null 96 private var reliableDataChannelSub: DataChannel? = null
84 private var lossyDataChannel: DataChannel? = null 97 private var lossyDataChannel: DataChannel? = null
@@ -89,13 +102,15 @@ internal constructor( @@ -89,13 +102,15 @@ internal constructor(
89 102
90 private var hasPublished = false 103 private var hasPublished = false
91 104
92 - private val coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher) 105 + private var coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher)
93 106
94 init { 107 init {
95 client.listener = this 108 client.listener = this
96 } 109 }
97 110
98 suspend fun join(url: String, token: String, options: ConnectOptions): LivekitRtc.JoinResponse { 111 suspend fun join(url: String, token: String, options: ConnectOptions): LivekitRtc.JoinResponse {
  112 + coroutineScope.close()
  113 + coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher)
99 sessionUrl = url 114 sessionUrl = url
100 sessionToken = token 115 sessionToken = token
101 val joinResponse = client.join(url, token, options) 116 val joinResponse = client.join(url, token, options)
@@ -104,9 +119,8 @@ internal constructor( @@ -104,9 +119,8 @@ internal constructor(
104 119
105 isSubscriberPrimary = joinResponse.subscriberPrimary 120 isSubscriberPrimary = joinResponse.subscriberPrimary
106 121
107 - if (!this::publisher.isInitialized) {  
108 - configure(joinResponse, options)  
109 - } 122 + configure(joinResponse, options)
  123 +
110 // create offer 124 // create offer
111 if (!this.isSubscriberPrimary) { 125 if (!this.isSubscriberPrimary) {
112 negotiate() 126 negotiate()
@@ -116,7 +130,7 @@ internal constructor( @@ -116,7 +130,7 @@ internal constructor(
116 } 130 }
117 131
118 private fun configure(joinResponse: LivekitRtc.JoinResponse, connectOptions: ConnectOptions?) { 132 private fun configure(joinResponse: LivekitRtc.JoinResponse, connectOptions: ConnectOptions?) {
119 - if (this::publisher.isInitialized || this::subscriber.isInitialized) { 133 + if (_publisher != null && _subscriber != null) {
120 // already configured 134 // already configured
121 return 135 return
122 } 136 }
@@ -160,13 +174,14 @@ internal constructor( @@ -160,13 +174,14 @@ internal constructor(
160 enableDtlsSrtp = true 174 enableDtlsSrtp = true
161 } 175 }
162 176
163 -  
164 - publisher = pctFactory.create( 177 + _publisher?.close()
  178 + _publisher = pctFactory.create(
165 rtcConfig, 179 rtcConfig,
166 publisherObserver, 180 publisherObserver,
167 publisherObserver, 181 publisherObserver,
168 ) 182 )
169 - subscriber = pctFactory.create( 183 + _subscriber?.close()
  184 + _subscriber = pctFactory.create(
170 rtcConfig, 185 rtcConfig,
171 subscriberObserver, 186 subscriberObserver,
172 null, 187 null,
@@ -248,10 +263,15 @@ internal constructor( @@ -248,10 +263,15 @@ internal constructor(
248 } 263 }
249 264
250 fun close() { 265 fun close() {
  266 + if (isClosed) {
  267 + return
  268 + }
251 isClosed = true 269 isClosed = true
252 coroutineScope.close() 270 coroutineScope.close()
253 - publisher.close()  
254 - subscriber.close() 271 + _publisher?.close()
  272 + _publisher = null
  273 + _subscriber?.close()
  274 + _subscriber = null
255 client.close() 275 client.close()
256 } 276 }
257 277
@@ -318,8 +338,8 @@ internal constructor( @@ -318,8 +338,8 @@ internal constructor(
318 } 338 }
319 339
320 340
321 - listener?.onEngineDisconnected("failed reconnecting.")  
322 close() 341 close()
  342 + listener?.onEngineDisconnected("failed reconnecting.")
323 } 343 }
324 344
325 reconnectingJob = job 345 reconnectingJob = job
@@ -361,8 +381,8 @@ internal constructor( @@ -361,8 +381,8 @@ internal constructor(
361 return 381 return
362 } 382 }
363 383
364 - if (!this::publisher.isInitialized) {  
365 - throw RoomException.ConnectException("Publisher is not connected!") 384 + if (_publisher == null) {
  385 + throw RoomException.ConnectException("Publisher isn't setup yet! Is room not connected?!")
366 } 386 }
367 387
368 if (!publisher.peerConnection.isConnected() && 388 if (!publisher.peerConnection.isConnected() &&
@@ -572,7 +592,7 @@ internal constructor( @@ -572,7 +592,7 @@ internal constructor(
572 592
573 // Signal error 593 // Signal error
574 override fun onError(error: Throwable) { 594 override fun onError(error: Throwable) {
575 - if (isClosed) { 595 + if (connectionState == ConnectionState.CONNECTING) {
576 listener?.onFailToConnect(error) 596 listener?.onFailToConnect(error)
577 } 597 }
578 } 598 }
@@ -142,7 +142,7 @@ constructor( @@ -142,7 +142,7 @@ constructor(
142 } 142 }
143 coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob()) 143 coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob())
144 state = State.CONNECTING 144 state = State.CONNECTING
145 - this.connectOptions = connectOptions 145 + connectOptions = options
146 val response = engine.join(url, token, options) 146 val response = engine.join(url, token, options)
147 LKLog.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" } 147 LKLog.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" }
148 148
@@ -9,6 +9,9 @@ import org.mockito.junit.MockitoJUnit @@ -9,6 +9,9 @@ import org.mockito.junit.MockitoJUnit
9 9
10 @ExperimentalCoroutinesApi 10 @ExperimentalCoroutinesApi
11 abstract class BaseTest { 11 abstract class BaseTest {
  12 + // Uncomment to enable logging in tests.
  13 + //@get:Rule
  14 + //var loggingRule = LoggingRule()
12 15
13 @get:Rule 16 @get:Rule
14 var mockitoRule = MockitoJUnit.rule() 17 var mockitoRule = MockitoJUnit.rule()
@@ -2,10 +2,12 @@ package io.livekit.android @@ -2,10 +2,12 @@ package io.livekit.android
2 2
3 import android.content.Context 3 import android.content.Context
4 import androidx.test.core.app.ApplicationProvider 4 import androidx.test.core.app.ApplicationProvider
  5 +import io.livekit.android.mock.MockPeerConnection
5 import io.livekit.android.mock.MockWebSocketFactory 6 import io.livekit.android.mock.MockWebSocketFactory
6 import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent 7 import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent
7 import io.livekit.android.mock.dagger.TestCoroutinesModule 8 import io.livekit.android.mock.dagger.TestCoroutinesModule
8 import io.livekit.android.mock.dagger.TestLiveKitComponent 9 import io.livekit.android.mock.dagger.TestLiveKitComponent
  10 +import io.livekit.android.room.PeerConnectionTransport
9 import io.livekit.android.room.Room 11 import io.livekit.android.room.Room
10 import io.livekit.android.room.SignalClientTest 12 import io.livekit.android.room.SignalClientTest
11 import io.livekit.android.util.toOkioByteString 13 import io.livekit.android.util.toOkioByteString
@@ -15,14 +17,16 @@ import okhttp3.Protocol @@ -15,14 +17,16 @@ import okhttp3.Protocol
15 import okhttp3.Request 17 import okhttp3.Request
16 import okhttp3.Response 18 import okhttp3.Response
17 import org.junit.Before 19 import org.junit.Before
  20 +import org.webrtc.PeerConnection
18 21
19 @ExperimentalCoroutinesApi 22 @ExperimentalCoroutinesApi
20 abstract class MockE2ETest : BaseTest() { 23 abstract class MockE2ETest : BaseTest() {
21 24
22 internal lateinit var component: TestLiveKitComponent 25 internal lateinit var component: TestLiveKitComponent
23 - lateinit var context: Context  
24 - lateinit var room: Room  
25 - lateinit var wsFactory: MockWebSocketFactory 26 + internal lateinit var context: Context
  27 + internal lateinit var room: Room
  28 + internal lateinit var wsFactory: MockWebSocketFactory
  29 + internal lateinit var subscriber: PeerConnectionTransport
26 30
27 @Before 31 @Before
28 fun setup() { 32 fun setup() {
@@ -37,6 +41,11 @@ abstract class MockE2ETest : BaseTest() { @@ -37,6 +41,11 @@ abstract class MockE2ETest : BaseTest() {
37 } 41 }
38 42
39 suspend fun connect() { 43 suspend fun connect() {
  44 + connectSignal()
  45 + connectPeerConnection()
  46 + }
  47 +
  48 + suspend fun connectSignal() {
40 val job = coroutineRule.scope.launch { 49 val job = coroutineRule.scope.launch {
41 room.connect( 50 room.connect(
42 url = SignalClientTest.EXAMPLE_URL, 51 url = SignalClientTest.EXAMPLE_URL,
@@ -49,6 +58,13 @@ abstract class MockE2ETest : BaseTest() { @@ -49,6 +58,13 @@ abstract class MockE2ETest : BaseTest() {
49 job.join() 58 job.join()
50 } 59 }
51 60
  61 + suspend fun connectPeerConnection() {
  62 + subscriber = component.rtcEngine().subscriber
  63 + wsFactory.listener.onMessage(wsFactory.ws, SignalClientTest.OFFER.toOkioByteString())
  64 + val subPeerConnection = subscriber.peerConnection as MockPeerConnection
  65 + subPeerConnection.moveToIceConnectionState(PeerConnection.IceConnectionState.CONNECTED)
  66 + }
  67 +
52 fun createOpenResponse(request: Request): Response { 68 fun createOpenResponse(request: Request): Response {
53 return Response.Builder() 69 return Response.Builder()
54 .request(request) 70 .request(request)
@@ -6,7 +6,6 @@ import io.livekit.android.mock.MockWebSocket @@ -6,7 +6,6 @@ import io.livekit.android.mock.MockWebSocket
6 import io.livekit.android.util.LoggingRule 6 import io.livekit.android.util.LoggingRule
7 import io.livekit.android.util.toPBByteString 7 import io.livekit.android.util.toPBByteString
8 import kotlinx.coroutines.ExperimentalCoroutinesApi 8 import kotlinx.coroutines.ExperimentalCoroutinesApi
9 -import kotlinx.coroutines.test.runTest  
10 import livekit.LivekitRtc 9 import livekit.LivekitRtc
11 import org.junit.Assert 10 import org.junit.Assert
12 import org.junit.Before 11 import org.junit.Before
@@ -14,18 +13,12 @@ import org.junit.Rule @@ -14,18 +13,12 @@ import org.junit.Rule
14 import org.junit.Test 13 import org.junit.Test
15 import org.junit.runner.RunWith 14 import org.junit.runner.RunWith
16 import org.robolectric.RobolectricTestRunner 15 import org.robolectric.RobolectricTestRunner
17 -import org.webrtc.PeerConnection  
18 -import org.webrtc.SessionDescription  
19 16
20 17
21 @ExperimentalCoroutinesApi 18 @ExperimentalCoroutinesApi
22 @RunWith(RobolectricTestRunner::class) 19 @RunWith(RobolectricTestRunner::class)
23 class RTCEngineMockE2ETest : MockE2ETest() { 20 class RTCEngineMockE2ETest : MockE2ETest() {
24 21
25 -  
26 - @get:Rule  
27 - var loggingRule = LoggingRule()  
28 -  
29 lateinit var rtcEngine: RTCEngine 22 lateinit var rtcEngine: RTCEngine
30 23
31 @Before 24 @Before
@@ -36,11 +29,10 @@ class RTCEngineMockE2ETest : MockE2ETest() { @@ -36,11 +29,10 @@ class RTCEngineMockE2ETest : MockE2ETest() {
36 @Test 29 @Test
37 fun iceSubscriberConnect() = runTest { 30 fun iceSubscriberConnect() = runTest {
38 connect() 31 connect()
39 -  
40 - val remoteOffer = SessionDescription(SessionDescription.Type.OFFER, "remote_offer")  
41 - rtcEngine.onOffer(remoteOffer)  
42 -  
43 - Assert.assertEquals(remoteOffer, rtcEngine.subscriber.peerConnection.remoteDescription) 32 + Assert.assertEquals(
  33 + SignalClientTest.OFFER.offer.sdp,
  34 + rtcEngine.subscriber.peerConnection.remoteDescription.description
  35 + )
44 36
45 val ws = wsFactory.ws as MockWebSocket 37 val ws = wsFactory.ws as MockWebSocket
46 val sentRequest = LivekitRtc.SignalRequest.newBuilder() 38 val sentRequest = LivekitRtc.SignalRequest.newBuilder()
@@ -52,9 +44,6 @@ class RTCEngineMockE2ETest : MockE2ETest() { @@ -52,9 +44,6 @@ class RTCEngineMockE2ETest : MockE2ETest() {
52 Assert.assertTrue(sentRequest.hasAnswer()) 44 Assert.assertTrue(sentRequest.hasAnswer())
53 Assert.assertEquals(localAnswer.description, sentRequest.answer.sdp) 45 Assert.assertEquals(localAnswer.description, sentRequest.answer.sdp)
54 Assert.assertEquals(localAnswer.type.canonicalForm(), sentRequest.answer.type) 46 Assert.assertEquals(localAnswer.type.canonicalForm(), sentRequest.answer.type)
55 -  
56 - subPeerConnection.moveToIceConnectionState(PeerConnection.IceConnectionState.CONNECTED)  
57 -  
58 Assert.assertEquals(ConnectionState.CONNECTED, rtcEngine.connectionState) 47 Assert.assertEquals(ConnectionState.CONNECTED, rtcEngine.connectionState)
59 } 48 }
60 49
@@ -3,6 +3,7 @@ package io.livekit.android.room @@ -3,6 +3,7 @@ package io.livekit.android.room
3 import android.net.Network 3 import android.net.Network
4 import io.livekit.android.MockE2ETest 4 import io.livekit.android.MockE2ETest
5 import io.livekit.android.events.EventCollector 5 import io.livekit.android.events.EventCollector
  6 +import io.livekit.android.events.FlowCollector
6 import io.livekit.android.events.RoomEvent 7 import io.livekit.android.events.RoomEvent
7 import io.livekit.android.mock.MockAudioStreamTrack 8 import io.livekit.android.mock.MockAudioStreamTrack
8 import io.livekit.android.mock.MockMediaStream 9 import io.livekit.android.mock.MockMediaStream
@@ -10,6 +11,8 @@ import io.livekit.android.mock.TestData @@ -10,6 +11,8 @@ import io.livekit.android.mock.TestData
10 import io.livekit.android.mock.createMediaStreamId 11 import io.livekit.android.mock.createMediaStreamId
11 import io.livekit.android.room.participant.ConnectionQuality 12 import io.livekit.android.room.participant.ConnectionQuality
12 import io.livekit.android.room.track.Track 13 import io.livekit.android.room.track.Track
  14 +import io.livekit.android.util.delegate
  15 +import io.livekit.android.util.flow
13 import io.livekit.android.util.toOkioByteString 16 import io.livekit.android.util.toOkioByteString
14 import kotlinx.coroutines.ExperimentalCoroutinesApi 17 import kotlinx.coroutines.ExperimentalCoroutinesApi
15 import kotlinx.coroutines.launch 18 import kotlinx.coroutines.launch
@@ -25,7 +28,13 @@ class RoomMockE2ETest : MockE2ETest() { @@ -25,7 +28,13 @@ class RoomMockE2ETest : MockE2ETest() {
25 28
26 @Test 29 @Test
27 fun connectTest() = runTest { 30 fun connectTest() = runTest {
  31 + val collector = FlowCollector(room::state.flow, coroutineRule.scope)
28 connect() 32 connect()
  33 + val events = collector.stopCollecting()
  34 + Assert.assertEquals(3, events.size)
  35 + Assert.assertEquals(Room.State.DISCONNECTED, events[0])
  36 + Assert.assertEquals(Room.State.CONNECTING, events[1])
  37 + Assert.assertEquals(Room.State.CONNECTED, events[2])
29 } 38 }
30 39
31 @Test 40 @Test
@@ -247,4 +256,11 @@ class RoomMockE2ETest : MockE2ETest() { @@ -247,4 +256,11 @@ class RoomMockE2ETest : MockE2ETest() {
247 Assert.assertEquals(true, events[0] is RoomEvent.Disconnected) 256 Assert.assertEquals(true, events[0] is RoomEvent.Disconnected)
248 } 257 }
249 258
  259 + @Test
  260 + fun reconnectAfterDisconnect() = runTest {
  261 + connect()
  262 + room.disconnect()
  263 + connect()
  264 + Assert.assertEquals(room.state, Room.State.CONNECTED)
  265 + }
250 } 266 }
@@ -174,7 +174,7 @@ class SignalClientTest : BaseTest() { @@ -174,7 +174,7 @@ class SignalClientTest : BaseTest() {
174 174
175 val OFFER = with(LivekitRtc.SignalResponse.newBuilder()) { 175 val OFFER = with(LivekitRtc.SignalResponse.newBuilder()) {
176 offer = with(offerBuilder) { 176 offer = with(offerBuilder) {
177 - sdp = "" 177 + sdp = "remote_offer"
178 type = "offer" 178 type = "offer"
179 build() 179 build()
180 } 180 }
@@ -7,9 +7,12 @@ import org.junit.runner.Description @@ -7,9 +7,12 @@ import org.junit.runner.Description
7 import org.junit.runners.model.Statement 7 import org.junit.runners.model.Statement
8 import timber.log.Timber 8 import timber.log.Timber
9 9
  10 +/**
  11 + * Add this rule to a test class to turn on logs.
  12 + */
10 class LoggingRule : TestRule { 13 class LoggingRule : TestRule {
11 14
12 - val logTree = object : Timber.Tree() { 15 + val logTree = object : Timber.DebugTree() {
13 override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 16 override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
14 val priorityChar = when (priority) { 17 val priorityChar = when (priority) {
15 Log.VERBOSE -> "v" 18 Log.VERBOSE -> "v"
@@ -32,8 +35,8 @@ class LoggingRule : TestRule { @@ -32,8 +35,8 @@ class LoggingRule : TestRule {
32 override fun apply(base: Statement, description: Description?) = object : Statement() { 35 override fun apply(base: Statement, description: Description?) = object : Statement() {
33 override fun evaluate() { 36 override fun evaluate() {
34 val oldLoggingLevel = LiveKit.loggingLevel 37 val oldLoggingLevel = LiveKit.loggingLevel
35 - LiveKit.loggingLevel = LoggingLevel.VERBOSE  
36 Timber.plant(logTree) 38 Timber.plant(logTree)
  39 + LiveKit.loggingLevel = LoggingLevel.VERBOSE
37 base.evaluate() 40 base.evaluate()
38 Timber.uproot(logTree) 41 Timber.uproot(logTree)
39 LiveKit.loggingLevel = oldLoggingLevel 42 LiveKit.loggingLevel = oldLoggingLevel
@@ -248,13 +248,24 @@ class CallViewModel( @@ -248,13 +248,24 @@ class CallViewModel(
248 room.value?.localParticipant?.setTrackSubscriptionPermissions(mutablePermissionAllowed.value) 248 room.value?.localParticipant?.setTrackSubscriptionPermissions(mutablePermissionAllowed.value)
249 } 249 }
250 250
251 - fun simulateMigration(){ 251 + fun simulateMigration() {
252 room.value?.sendSimulateScenario( 252 room.value?.sendSimulateScenario(
253 LivekitRtc.SimulateScenario.newBuilder() 253 LivekitRtc.SimulateScenario.newBuilder()
254 .setMigration(true) 254 .setMigration(true)
255 .build() 255 .build()
256 ) 256 )
257 } 257 }
  258 +
  259 + fun reconnect() {
  260 + room.value?.disconnect()
  261 +
  262 + viewModelScope.launch {
  263 + room.value?.connect(
  264 + url,
  265 + token
  266 + )
  267 + }
  268 + }
258 } 269 }
259 270
260 private fun <T> LiveData<T>.hide(): LiveData<T> = this 271 private fun <T> LiveData<T>.hide(): LiveData<T> = this
@@ -111,6 +111,7 @@ class CallActivity : AppCompatActivity() { @@ -111,6 +111,7 @@ class CallActivity : AppCompatActivity() {
111 onExitClick = { finish() }, 111 onExitClick = { finish() },
112 onSendMessage = { viewModel.sendData(it) }, 112 onSendMessage = { viewModel.sendData(it) },
113 onSimulateMigration = { viewModel.simulateMigration() }, 113 onSimulateMigration = { viewModel.simulateMigration() },
  114 + fullReconnect = { viewModel.reconnect() },
114 ) 115 )
115 } 116 }
116 } 117 }
@@ -159,6 +160,7 @@ class CallActivity : AppCompatActivity() { @@ -159,6 +160,7 @@ class CallActivity : AppCompatActivity() {
159 onSnackbarDismiss: () -> Unit = {}, 160 onSnackbarDismiss: () -> Unit = {},
160 onSendMessage: (String) -> Unit = {}, 161 onSendMessage: (String) -> Unit = {},
161 onSimulateMigration: () -> Unit = {}, 162 onSimulateMigration: () -> Unit = {},
  163 + fullReconnect: () -> Unit = {},
162 ) { 164 ) {
163 AppTheme(darkTheme = true) { 165 AppTheme(darkTheme = true) {
164 ConstraintLayout( 166 ConstraintLayout(
@@ -410,7 +412,8 @@ class CallActivity : AppCompatActivity() { @@ -410,7 +412,8 @@ class CallActivity : AppCompatActivity() {
410 if (showDebugDialog) { 412 if (showDebugDialog) {
411 DebugMenuDialog( 413 DebugMenuDialog(
412 onDismissRequest = { showDebugDialog = false }, 414 onDismissRequest = { showDebugDialog = false },
413 - simulateMigration = { onSimulateMigration() } 415 + simulateMigration = { onSimulateMigration() },
  416 + fullReconnect = { fullReconnect() },
414 ) 417 )
415 } 418 }
416 } 419 }
@@ -17,7 +17,8 @@ import androidx.compose.ui.window.Dialog @@ -17,7 +17,8 @@ import androidx.compose.ui.window.Dialog
17 @Composable 17 @Composable
18 fun DebugMenuDialog( 18 fun DebugMenuDialog(
19 onDismissRequest: () -> Unit = {}, 19 onDismissRequest: () -> Unit = {},
20 - simulateMigration: () -> Unit = {} 20 + simulateMigration: () -> Unit = {},
  21 + fullReconnect: () -> Unit = {},
21 ) { 22 ) {
22 Dialog(onDismissRequest = onDismissRequest) { 23 Dialog(onDismissRequest = onDismissRequest) {
23 Column( 24 Column(
@@ -36,6 +37,11 @@ fun DebugMenuDialog( @@ -36,6 +37,11 @@ fun DebugMenuDialog(
36 }) { 37 }) {
37 Text("Simulate Migration") 38 Text("Simulate Migration")
38 } 39 }
  40 + Button(onClick = {
  41 + fullReconnect()
  42 + }) {
  43 + Text("Reconnect to room")
  44 + }
39 } 45 }
40 } 46 }
41 } 47 }