Jean Kruger
Committed by GitHub

Improve track publish failure handling (#637)

* Avoid exception on request add track failure

* Stop audio and video track on publish failure

* Start the video capture on connect if requested

* Fire a room event when the track publication failed

* Add changeset

* Fixes unit tests

* Update livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt

* Also return true from setDeviceEnabled

---------

Co-authored-by: davidliu <davidliu@deviange.net>
  1 +---
  2 +"client-sdk-android": patch
  3 +---
  4 +
  5 +Improved handling of track publication failures by introducing a new TrackPublicationFailed event and fixing a broken state issue where the track remained active but inaccessible, causing the microphone or camera to stay on without a published track and leading to unreliable republishing.
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-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.
@@ -23,6 +23,7 @@ import io.livekit.android.room.participant.RemoteParticipant @@ -23,6 +23,7 @@ import io.livekit.android.room.participant.RemoteParticipant
23 import io.livekit.android.room.track.LocalTrackPublication 23 import io.livekit.android.room.track.LocalTrackPublication
24 import io.livekit.android.room.track.RemoteTrackPublication 24 import io.livekit.android.room.track.RemoteTrackPublication
25 import io.livekit.android.room.track.Track 25 import io.livekit.android.room.track.Track
  26 +import io.livekit.android.room.track.TrackException
26 import io.livekit.android.room.track.TrackPublication 27 import io.livekit.android.room.track.TrackPublication
27 import io.livekit.android.room.types.TranscriptionSegment 28 import io.livekit.android.room.types.TranscriptionSegment
28 29
@@ -89,6 +90,15 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { @@ -89,6 +90,15 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() {
89 ParticipantEvent(participant) 90 ParticipantEvent(participant)
90 91
91 /** 92 /**
  93 + * Error had occurred while publishing a track
  94 + */
  95 + class LocalTrackPublicationFailed(
  96 + override val participant: LocalParticipant,
  97 + val track: Track,
  98 + val e: TrackException.PublishException,
  99 + ) : ParticipantEvent(participant)
  100 +
  101 + /**
92 * A [LocalParticipant] has unpublished a track 102 * A [LocalParticipant] has unpublished a track
93 */ 103 */
94 class LocalTrackUnpublished(override val participant: LocalParticipant, val publication: LocalTrackPublication) : 104 class LocalTrackUnpublished(override val participant: LocalParticipant, val publication: LocalTrackPublication) :
@@ -27,6 +27,7 @@ import io.livekit.android.room.participant.RemoteParticipant @@ -27,6 +27,7 @@ import io.livekit.android.room.participant.RemoteParticipant
27 import io.livekit.android.room.track.LocalTrackPublication 27 import io.livekit.android.room.track.LocalTrackPublication
28 import io.livekit.android.room.track.RemoteTrackPublication 28 import io.livekit.android.room.track.RemoteTrackPublication
29 import io.livekit.android.room.track.Track 29 import io.livekit.android.room.track.Track
  30 +import io.livekit.android.room.track.TrackException
30 import io.livekit.android.room.track.TrackPublication 31 import io.livekit.android.room.track.TrackPublication
31 import io.livekit.android.room.types.TranscriptionSegment 32 import io.livekit.android.room.types.TranscriptionSegment
32 import livekit.LivekitModels 33 import livekit.LivekitModels
@@ -139,6 +140,17 @@ sealed class RoomEvent(val room: Room) : Event() { @@ -139,6 +140,17 @@ sealed class RoomEvent(val room: Room) : Event() {
139 class TrackPublished(room: Room, val publication: TrackPublication, val participant: Participant) : RoomEvent(room) 140 class TrackPublished(room: Room, val publication: TrackPublication, val participant: Participant) : RoomEvent(room)
140 141
141 /** 142 /**
  143 + * Error had occurred while publishing a track, for LocalParticipant only
  144 + * not fire for tracks that are already published
  145 + */
  146 + class TrackPublicationFailed(
  147 + room: Room,
  148 + val track: Track,
  149 + val participant: LocalParticipant,
  150 + e: TrackException.PublishException,
  151 + ) : RoomEvent(room)
  152 +
  153 + /**
142 * A [Participant] has unpublished a track 154 * A [Participant] has unpublished a track
143 */ 155 */
144 class TrackUnpublished(room: Room, val publication: TrackPublication, val participant: Participant) : 156 class TrackUnpublished(room: Room, val publication: TrackPublication, val participant: Participant) :
@@ -479,14 +479,21 @@ constructor( @@ -479,14 +479,21 @@ constructor(
479 ensureActive() 479 ensureActive()
480 networkCallbackManager.registerCallback() 480 networkCallbackManager.registerCallback()
481 if (options.audio) { 481 if (options.audio) {
482 - val audioTrack = localParticipant.createAudioTrack() 482 + val audioTrack = localParticipant.getOrCreateDefaultAudioTrack()
483 audioTrack.prewarm() 483 audioTrack.prewarm()
484 - localParticipant.publishAudioTrack(audioTrack) 484 + if (!localParticipant.publishAudioTrack(audioTrack)) {
  485 + audioTrack.stop()
  486 + audioTrack.stopPrewarm()
  487 + }
485 } 488 }
486 ensureActive() 489 ensureActive()
487 if (options.video) { 490 if (options.video) {
488 - val videoTrack = localParticipant.createVideoTrack()  
489 - localParticipant.publishVideoTrack(videoTrack) 491 + val videoTrack = localParticipant.getOrCreateDefaultVideoTrack()
  492 + videoTrack.startCapture()
  493 + if (!localParticipant.publishVideoTrack(videoTrack)) {
  494 + videoTrack.stopCapture()
  495 + videoTrack.stop()
  496 + }
490 } 497 }
491 498
492 coroutineScope.launch { 499 coroutineScope.launch {
@@ -612,6 +619,15 @@ constructor( @@ -612,6 +619,15 @@ constructor(
612 ), 619 ),
613 ) 620 )
614 621
  622 + is ParticipantEvent.LocalTrackPublicationFailed -> emitWhenConnected(
  623 + RoomEvent.TrackPublicationFailed(
  624 + room = this@Room,
  625 + track = it.track,
  626 + participant = it.participant,
  627 + e = it.e,
  628 + ),
  629 + )
  630 +
615 is ParticipantEvent.TrackUnpublished -> emitWhenConnected( 631 is ParticipantEvent.TrackUnpublished -> emitWhenConnected(
616 RoomEvent.TrackUnpublished( 632 RoomEvent.TrackUnpublished(
617 room = this@Room, 633 room = this@Room,
@@ -137,6 +137,29 @@ internal constructor( @@ -137,6 +137,29 @@ internal constructor(
137 137
138 internal val enabledPublishVideoCodecs = Collections.synchronizedList(mutableListOf<Codec>()) 138 internal val enabledPublishVideoCodecs = Collections.synchronizedList(mutableListOf<Codec>())
139 139
  140 + private var defaultAudioTrack: LocalAudioTrack? = null
  141 + private var defaultVideoTrack: LocalVideoTrack? = null
  142 +
  143 + /**
  144 + * Returns the default audio track, or creates one if it doesn't exist.
  145 + * @exception SecurityException will be thrown if [Manifest.permission.RECORD_AUDIO] permission is missing.
  146 + */
  147 + fun getOrCreateDefaultAudioTrack(): LocalAudioTrack {
  148 + return defaultAudioTrack ?: createAudioTrack().also {
  149 + defaultAudioTrack = it
  150 + }
  151 + }
  152 +
  153 + /**
  154 + * Returns the default video track, or creates one if it doesn't exist.
  155 + * @exception SecurityException will be thrown if [Manifest.permission.CAMERA] permission is missing.
  156 + */
  157 + fun getOrCreateDefaultVideoTrack(): LocalVideoTrack {
  158 + return defaultVideoTrack ?: createVideoTrack().also {
  159 + defaultVideoTrack = it
  160 + }
  161 + }
  162 +
140 /** 163 /**
141 * Creates an audio track, recording audio through the microphone with the given [options]. 164 * Creates an audio track, recording audio through the microphone with the given [options].
142 * 165 *
@@ -256,10 +279,11 @@ internal constructor( @@ -256,10 +279,11 @@ internal constructor(
256 * 279 *
257 * @see Room.videoTrackCaptureDefaults 280 * @see Room.videoTrackCaptureDefaults
258 * @see Room.videoTrackPublishDefaults 281 * @see Room.videoTrackPublishDefaults
  282 + * @return true if the change was successful, or false if it failed.
259 */ 283 */
260 @Throws(TrackException.PublishException::class) 284 @Throws(TrackException.PublishException::class)
261 - suspend fun setCameraEnabled(enabled: Boolean) {  
262 - setTrackEnabled(Track.Source.CAMERA, enabled) 285 + suspend fun setCameraEnabled(enabled: Boolean): Boolean {
  286 + return setTrackEnabled(Track.Source.CAMERA, enabled)
263 } 287 }
264 288
265 /** 289 /**
@@ -271,10 +295,11 @@ internal constructor( @@ -271,10 +295,11 @@ internal constructor(
271 * 295 *
272 * @see Room.audioTrackCaptureDefaults 296 * @see Room.audioTrackCaptureDefaults
273 * @see Room.audioTrackPublishDefaults 297 * @see Room.audioTrackPublishDefaults
  298 + * @return true if the change was successful, or false if it failed.
274 */ 299 */
275 @Throws(TrackException.PublishException::class) 300 @Throws(TrackException.PublishException::class)
276 - suspend fun setMicrophoneEnabled(enabled: Boolean) {  
277 - setTrackEnabled(Track.Source.MICROPHONE, enabled) 301 + suspend fun setMicrophoneEnabled(enabled: Boolean): Boolean {
  302 + return setTrackEnabled(Track.Source.MICROPHONE, enabled)
278 } 303 }
279 304
280 /** 305 /**
@@ -292,41 +317,58 @@ internal constructor( @@ -292,41 +317,58 @@ internal constructor(
292 * @see Room.screenShareTrackCaptureDefaults 317 * @see Room.screenShareTrackCaptureDefaults
293 * @see Room.screenShareTrackPublishDefaults 318 * @see Room.screenShareTrackPublishDefaults
294 * @see ScreenAudioCapturer 319 * @see ScreenAudioCapturer
  320 + * @return true if the change was successful, or false if it failed.
295 */ 321 */
296 @Throws(TrackException.PublishException::class) 322 @Throws(TrackException.PublishException::class)
297 suspend fun setScreenShareEnabled( 323 suspend fun setScreenShareEnabled(
298 enabled: Boolean, 324 enabled: Boolean,
299 screenCaptureParams: ScreenCaptureParams? = null, 325 screenCaptureParams: ScreenCaptureParams? = null,
300 - ) {  
301 - setTrackEnabled(Track.Source.SCREEN_SHARE, enabled, screenCaptureParams) 326 + ): Boolean {
  327 + return setTrackEnabled(Track.Source.SCREEN_SHARE, enabled, screenCaptureParams)
302 } 328 }
303 329
304 private suspend fun setTrackEnabled( 330 private suspend fun setTrackEnabled(
305 source: Track.Source, 331 source: Track.Source,
306 enabled: Boolean, 332 enabled: Boolean,
307 screenCaptureParams: ScreenCaptureParams? = null, 333 screenCaptureParams: ScreenCaptureParams? = null,
308 - ) { 334 + ): Boolean {
  335 + var success = false
309 val pubLock = sourcePubLocks[source]!! 336 val pubLock = sourcePubLocks[source]!!
310 pubLock.withLock { 337 pubLock.withLock {
311 val pub = getTrackPublication(source) 338 val pub = getTrackPublication(source)
312 if (enabled) { 339 if (enabled) {
313 if (pub != null) { 340 if (pub != null) {
  341 + // Publication exists, just unmute the existing track.
314 pub.muted = false 342 pub.muted = false
315 if (source == Track.Source.CAMERA && pub.track is LocalVideoTrack) { 343 if (source == Track.Source.CAMERA && pub.track is LocalVideoTrack) {
316 (pub.track as? LocalVideoTrack)?.startCapture() 344 (pub.track as? LocalVideoTrack)?.startCapture()
317 } 345 }
  346 + success = true
318 } else { 347 } else {
  348 + // Not published yet, create the default track and publish.
319 when (source) { 349 when (source) {
320 Track.Source.CAMERA -> { 350 Track.Source.CAMERA -> {
321 - val track = createVideoTrack() 351 + val track = getOrCreateDefaultVideoTrack()
  352 + track.start()
322 track.startCapture() 353 track.startCapture()
323 - publishVideoTrack(track) 354 + if (!publishVideoTrack(track)) {
  355 + track.stopCapture()
  356 + track.stop()
  357 + } else {
  358 + success = true
  359 + }
324 } 360 }
325 361
326 Track.Source.MICROPHONE -> { 362 Track.Source.MICROPHONE -> {
327 - val track = createAudioTrack() 363 + val track = getOrCreateDefaultAudioTrack()
328 track.prewarm() 364 track.prewarm()
329 - publishAudioTrack(track) 365 + track.start()
  366 + if (!publishAudioTrack(track)) {
  367 + track.stop()
  368 + track.stopPrewarm()
  369 + } else {
  370 + success = true
  371 + }
330 } 372 }
331 373
332 Track.Source.SCREEN_SHARE -> { 374 Track.Source.SCREEN_SHARE -> {
@@ -340,7 +382,16 @@ internal constructor( @@ -340,7 +382,16 @@ internal constructor(
340 } 382 }
341 track.startForegroundService(screenCaptureParams.notificationId, screenCaptureParams.notification) 383 track.startForegroundService(screenCaptureParams.notificationId, screenCaptureParams.notification)
342 track.startCapture() 384 track.startCapture()
343 - publishVideoTrack(track, options = VideoTrackPublishOptions(null, screenShareTrackPublishDefaults)) 385 + if (!publishVideoTrack(track, options = VideoTrackPublishOptions(null, screenShareTrackPublishDefaults))) {
  386 + screenCaptureParams.onStop?.invoke()
  387 + track.apply {
  388 + stopCapture()
  389 + stop()
  390 + dispose()
  391 + }
  392 + } else {
  393 + success = true
  394 + }
344 } 395 }
345 396
346 else -> { 397 else -> {
@@ -362,9 +413,12 @@ internal constructor( @@ -362,9 +413,12 @@ internal constructor(
362 } 413 }
363 } 414 }
364 } 415 }
  416 + success = true
365 } 417 }
366 return@withLock 418 return@withLock
367 } 419 }
  420 +
  421 + return success
368 } 422 }
369 423
370 /** 424 /**
@@ -380,7 +434,7 @@ internal constructor( @@ -380,7 +434,7 @@ internal constructor(
380 audioTrackPublishDefaults, 434 audioTrackPublishDefaults,
381 ), 435 ),
382 publishListener: PublishListener? = null, 436 publishListener: PublishListener? = null,
383 - ) { 437 + ): Boolean {
384 val encodings = listOf( 438 val encodings = listOf(
385 RtpParameters.Encoding(null, true, null).apply { 439 RtpParameters.Encoding(null, true, null).apply {
386 if (options.audioBitrate != null && options.audioBitrate > 0) { 440 if (options.audioBitrate != null && options.audioBitrate > 0) {
@@ -408,6 +462,8 @@ internal constructor( @@ -408,6 +462,8 @@ internal constructor(
408 } 462 }
409 jobs[publication] = job 463 jobs[publication] = job
410 } 464 }
  465 +
  466 + return publication != null
411 } 467 }
412 468
413 /** 469 /**
@@ -420,7 +476,7 @@ internal constructor( @@ -420,7 +476,7 @@ internal constructor(
420 track: LocalVideoTrack, 476 track: LocalVideoTrack,
421 options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults), 477 options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults),
422 publishListener: PublishListener? = null, 478 publishListener: PublishListener? = null,
423 - ) { 479 + ): Boolean {
424 @Suppress("NAME_SHADOWING") var options = options 480 @Suppress("NAME_SHADOWING") var options = options
425 481
426 synchronized(enabledPublishVideoCodecs) { 482 synchronized(enabledPublishVideoCodecs) {
@@ -456,7 +512,7 @@ internal constructor( @@ -456,7 +512,7 @@ internal constructor(
456 val videoLayers = 512 val videoLayers =
457 EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings, isSVC) 513 EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings, isSVC)
458 514
459 - publishTrackImpl( 515 + return publishTrackImpl(
460 track = track, 516 track = track,
461 options = options, 517 options = options,
462 requestConfig = { 518 requestConfig = {
@@ -489,7 +545,7 @@ internal constructor( @@ -489,7 +545,7 @@ internal constructor(
489 }, 545 },
490 encodings = encodings, 546 encodings = encodings,
491 publishListener = publishListener, 547 publishListener = publishListener,
492 - ) 548 + ) != null
493 } 549 }
494 550
495 private fun hasPermissionsToPublish(source: Track.Source): Boolean { 551 private fun hasPermissionsToPublish(source: Track.Source): Boolean {
@@ -522,13 +578,22 @@ internal constructor( @@ -522,13 +578,22 @@ internal constructor(
522 encodings: List<RtpParameters.Encoding> = emptyList(), 578 encodings: List<RtpParameters.Encoding> = emptyList(),
523 publishListener: PublishListener? = null, 579 publishListener: PublishListener? = null,
524 ): LocalTrackPublication? { 580 ): LocalTrackPublication? {
  581 + fun onPublishFailure(e: TrackException.PublishException, triggerEvent: Boolean = true) {
  582 + publishListener?.onPublishFailure(e)
  583 + if (triggerEvent) {
  584 + eventBus.postEvent(ParticipantEvent.LocalTrackPublicationFailed(this, track, e), scope)
  585 + }
  586 + }
  587 +
525 val addTrackRequestBuilder = AddTrackRequest.newBuilder().apply { 588 val addTrackRequestBuilder = AddTrackRequest.newBuilder().apply {
526 this.requestConfig() 589 this.requestConfig()
527 } 590 }
528 591
529 val trackSource = Track.Source.fromProto(addTrackRequestBuilder.source ?: LivekitModels.TrackSource.UNRECOGNIZED) 592 val trackSource = Track.Source.fromProto(addTrackRequestBuilder.source ?: LivekitModels.TrackSource.UNRECOGNIZED)
530 if (!hasPermissionsToPublish(trackSource)) { 593 if (!hasPermissionsToPublish(trackSource)) {
531 - throw TrackException.PublishException("Failed to publish track, insufficient permissions") 594 + val exception = TrackException.PublishException("Failed to publish track, insufficient permissions")
  595 + onPublishFailure(exception)
  596 + throw exception
532 } 597 }
533 598
534 @Suppress("NAME_SHADOWING") var options = options 599 @Suppress("NAME_SHADOWING") var options = options
@@ -536,12 +601,12 @@ internal constructor( @@ -536,12 +601,12 @@ internal constructor(
536 @Suppress("NAME_SHADOWING") var encodings = encodings 601 @Suppress("NAME_SHADOWING") var encodings = encodings
537 602
538 if (localTrackPublications.any { it.track == track }) { 603 if (localTrackPublications.any { it.track == track }) {
539 - publishListener?.onPublishFailure(TrackException.PublishException("Track has already been published")) 604 + onPublishFailure(TrackException.PublishException("Track has already been published"), triggerEvent = false)
540 return null 605 return null
541 } 606 }
542 607
543 if (engine.connectionState == ConnectionState.DISCONNECTED) { 608 if (engine.connectionState == ConnectionState.DISCONNECTED) {
544 - publishListener?.onPublishFailure(TrackException.PublishException("Not connected!")) 609 + onPublishFailure(TrackException.PublishException("Not connected!"))
545 } 610 }
546 611
547 val cid = track.rtcTrack.id() 612 val cid = track.rtcTrack.id()
@@ -569,7 +634,7 @@ internal constructor( @@ -569,7 +634,7 @@ internal constructor(
569 634
570 if (transceiver == null) { 635 if (transceiver == null) {
571 val exception = TrackException.PublishException("null sender returned from peer connection") 636 val exception = TrackException.PublishException("null sender returned from peer connection")
572 - publishListener?.onPublishFailure(exception) 637 + onPublishFailure(exception)
573 throw exception 638 throw exception
574 } 639 }
575 640
@@ -603,7 +668,7 @@ internal constructor( @@ -603,7 +668,7 @@ internal constructor(
603 // so no need to call negotiate manually. 668 // so no need to call negotiate manually.
604 } 669 }
605 670
606 - suspend fun requestAddTrack(): TrackInfo { 671 + suspend fun requestAddTrack(): TrackInfo? {
607 return try { 672 return try {
608 engine.addTrack( 673 engine.addTrack(
609 cid = cid, 674 cid = cid,
@@ -613,13 +678,12 @@ internal constructor( @@ -613,13 +678,12 @@ internal constructor(
613 builder = addTrackRequestBuilder, 678 builder = addTrackRequestBuilder,
614 ) 679 )
615 } catch (e: Exception) { 680 } catch (e: Exception) {
616 - val exception = TrackException.PublishException("Failed to publish track", e)  
617 - publishListener?.onPublishFailure(exception)  
618 - throw exception 681 + onPublishFailure(TrackException.PublishException("Failed to publish track", e))
  682 + null
619 } 683 }
620 } 684 }
621 685
622 - val trackInfo: TrackInfo 686 + val trackInfo: TrackInfo?
623 if (enabledPublishVideoCodecs.isNotEmpty()) { 687 if (enabledPublishVideoCodecs.isNotEmpty()) {
624 // Can simultaneous publish and negotiate. 688 // Can simultaneous publish and negotiate.
625 // codec is pre-verified in publishVideoTrack 689 // codec is pre-verified in publishVideoTrack
@@ -633,41 +697,45 @@ internal constructor( @@ -633,41 +697,45 @@ internal constructor(
633 } else { 697 } else {
634 // legacy path. 698 // legacy path.
635 trackInfo = requestAddTrack() 699 trackInfo = requestAddTrack()
636 -  
637 - if (options is VideoTrackPublishOptions) {  
638 - // server might not support the codec the client has requested, in that case, fallback  
639 - // to a supported codec  
640 - val primaryCodecMime = trackInfo.codecsList.firstOrNull()?.mimeType  
641 -  
642 - if (primaryCodecMime != null) {  
643 - val updatedCodec = primaryCodecMime.mimeTypeToVideoCodec()  
644 - if (updatedCodec != null && updatedCodec != options.videoCodec) {  
645 - LKLog.d { "falling back to server selected codec: $updatedCodec" }  
646 - options = options.copy(videoCodec = updatedCodec)  
647 -  
648 - // recompute encodings since bitrates/etc could have changed  
649 - encodings = computeVideoEncodings((track as LocalVideoTrack).dimensions, options) 700 + if (trackInfo != null) {
  701 + if (options is VideoTrackPublishOptions) {
  702 + // server might not support the codec the client has requested, in that case, fallback
  703 + // to a supported codec
  704 + val primaryCodecMime = trackInfo.codecsList.firstOrNull()?.mimeType
  705 +
  706 + if (primaryCodecMime != null) {
  707 + val updatedCodec = primaryCodecMime.mimeTypeToVideoCodec()
  708 + if (updatedCodec != null && updatedCodec != options.videoCodec) {
  709 + LKLog.d { "falling back to server selected codec: $updatedCodec" }
  710 + options = options.copy(videoCodec = updatedCodec)
  711 +
  712 + // recompute encodings since bitrates/etc could have changed
  713 + encodings = computeVideoEncodings((track as LocalVideoTrack).dimensions, options)
  714 + }
650 } 715 }
651 } 716 }
652 - }  
653 717
654 - negotiate() 718 + negotiate()
  719 + }
655 } 720 }
656 721
657 - val publication = LocalTrackPublication(  
658 - info = trackInfo,  
659 - track = track,  
660 - participant = this,  
661 - options = options,  
662 - )  
663 - addTrackPublication(publication)  
664 - LKLog.v { "add track publication $publication" }  
665 -  
666 - publishListener?.onPublishSuccess(publication)  
667 - internalListener?.onTrackPublished(publication, this)  
668 - eventBus.postEvent(ParticipantEvent.LocalTrackPublished(this, publication), scope) 722 + return if (trackInfo != null) {
  723 + val publication = LocalTrackPublication(
  724 + info = trackInfo,
  725 + track = track,
  726 + participant = this,
  727 + options = options,
  728 + )
  729 + addTrackPublication(publication)
  730 + LKLog.v { "add track publication $publication" }
669 731
670 - return publication 732 + publishListener?.onPublishSuccess(publication)
  733 + internalListener?.onTrackPublished(publication, this)
  734 + eventBus.postEvent(ParticipantEvent.LocalTrackPublished(this, publication), scope)
  735 + publication
  736 + } else {
  737 + null
  738 + }
671 } 739 }
672 740
673 private fun computeVideoEncodings( 741 private fun computeVideoEncodings(
@@ -1443,11 +1511,14 @@ internal constructor( @@ -1443,11 +1511,14 @@ internal constructor(
1443 unpublishTrack(track, false) 1511 unpublishTrack(track, false)
1444 // Cannot publish muted tracks. 1512 // Cannot publish muted tracks.
1445 if (!pub.muted) { 1513 if (!pub.muted) {
1446 - when (track) { 1514 + val success = when (track) {
1447 is LocalAudioTrack -> publishAudioTrack(track, pub.options as AudioTrackPublishOptions, null) 1515 is LocalAudioTrack -> publishAudioTrack(track, pub.options as AudioTrackPublishOptions, null)
1448 is LocalVideoTrack -> publishVideoTrack(track, pub.options as VideoTrackPublishOptions, null) 1516 is LocalVideoTrack -> publishVideoTrack(track, pub.options as VideoTrackPublishOptions, null)
1449 else -> throw IllegalStateException("LocalParticipant has a non local track publish?") 1517 else -> throw IllegalStateException("LocalParticipant has a non local track publish?")
1450 } 1518 }
  1519 + if (!success) {
  1520 + track.stop()
  1521 + }
1451 } 1522 }
1452 } 1523 }
1453 } 1524 }
@@ -1479,6 +1550,10 @@ internal constructor( @@ -1479,6 +1550,10 @@ internal constructor(
1479 * @suppress 1550 * @suppress
1480 */ 1551 */
1481 fun cleanup() { 1552 fun cleanup() {
  1553 + defaultAudioTrack?.dispose()
  1554 + defaultAudioTrack = null
  1555 + defaultVideoTrack?.dispose()
  1556 + defaultVideoTrack = null
1482 for (pub in trackPublications.values) { 1557 for (pub in trackPublications.values) {
1483 val track = pub.track 1558 val track = pub.track
1484 1559
@@ -591,20 +591,21 @@ class LocalParticipantMockE2ETest : MockE2ETest() { @@ -591,20 +591,21 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
591 } 591 }
592 592
593 @Test 593 @Test
594 - fun publishWithNoResponseCausesException() = runTest { 594 + fun publishWithNoResponseReturnFalseWithoutException() = runTest {
595 connect() 595 connect()
596 596
597 wsFactory.unregisterSignalRequestHandler(wsFactory.defaultSignalRequestHandler) 597 wsFactory.unregisterSignalRequestHandler(wsFactory.defaultSignalRequestHandler)
598 var didThrow = false 598 var didThrow = false
  599 + var success: Boolean? = null
599 launch { 600 launch {
600 try { 601 try {
601 - room.localParticipant.publishVideoTrack(createLocalTrack()) 602 + success = room.localParticipant.publishVideoTrack(createLocalTrack())
602 } catch (e: TrackException.PublishException) { 603 } catch (e: TrackException.PublishException) {
603 didThrow = true 604 didThrow = true
604 } 605 }
605 } 606 }
606 607
607 coroutineRule.dispatcher.scheduler.advanceUntilIdle() 608 coroutineRule.dispatcher.scheduler.advanceUntilIdle()
608 - assertTrue(didThrow) 609 + assertTrue(!didThrow && success == false)
609 } 610 }
610 } 611 }