davidliu
Committed by GitHub

Permissions API (#37)

* permissions API

* naming

* don't add track if disallowed

* hold on to track for now

* room event for subscription permission update

* update test

* language cleanup

* Move track permission handling to RemoteParticipant to allow emission of ParticipantEvent

* Subscription status enum

* revert preventing subscription to disallowed track

* keep subscribe boolean and add extra subscriptionStatus

* fix build
1 package io.livekit.android.events 1 package io.livekit.android.events
2 2
  3 +import io.livekit.android.room.Room
3 import io.livekit.android.room.participant.LocalParticipant 4 import io.livekit.android.room.participant.LocalParticipant
4 import io.livekit.android.room.participant.Participant 5 import io.livekit.android.room.participant.Participant
5 import io.livekit.android.room.participant.RemoteParticipant 6 import io.livekit.android.room.participant.RemoteParticipant
@@ -107,4 +108,14 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { @@ -107,4 +108,14 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() {
107 val trackPublication: TrackPublication, 108 val trackPublication: TrackPublication,
108 val streamState: Track.StreamState 109 val streamState: Track.StreamState
109 ) : ParticipantEvent(participant) 110 ) : ParticipantEvent(participant)
  111 +
  112 +
  113 + /**
  114 + * A remote track's subscription permissions have changed.
  115 + */
  116 + class TrackSubscriptionPermissionChanged(
  117 + override val participant: RemoteParticipant,
  118 + val trackPublication: RemoteTrackPublication,
  119 + val subscriptionAllowed: Boolean
  120 + ) : ParticipantEvent(participant)
110 } 121 }
@@ -6,6 +6,7 @@ import io.livekit.android.room.participant.LocalParticipant @@ -6,6 +6,7 @@ import io.livekit.android.room.participant.LocalParticipant
6 import io.livekit.android.room.participant.Participant 6 import io.livekit.android.room.participant.Participant
7 import io.livekit.android.room.participant.RemoteParticipant 7 import io.livekit.android.room.participant.RemoteParticipant
8 import io.livekit.android.room.track.LocalTrackPublication 8 import io.livekit.android.room.track.LocalTrackPublication
  9 +import io.livekit.android.room.track.RemoteTrackPublication
9 import io.livekit.android.room.track.Track 10 import io.livekit.android.room.track.Track
10 import io.livekit.android.room.track.TrackPublication 11 import io.livekit.android.room.track.TrackPublication
11 12
@@ -131,6 +132,16 @@ sealed class RoomEvent(val room: Room) : Event() { @@ -131,6 +132,16 @@ sealed class RoomEvent(val room: Room) : Event() {
131 ) : RoomEvent(room) 132 ) : RoomEvent(room)
132 133
133 /** 134 /**
  135 + * A remote track's subscription permissions have changed.
  136 + */
  137 + class TrackSubscriptionPermissionChanged(
  138 + room: Room,
  139 + val participant: RemoteParticipant,
  140 + val trackPublication: RemoteTrackPublication,
  141 + val subscriptionAllowed: Boolean
  142 + ) : RoomEvent(room)
  143 +
  144 + /**
134 * Received data published by another participant 145 * Received data published by another participant
135 */ 146 */
136 class DataReceived(room: Room, val data: ByteArray, val participant: RemoteParticipant) : RoomEvent(room) 147 class DataReceived(room: Room, val data: ByteArray, val participant: RemoteParticipant) : RoomEvent(room)
@@ -3,6 +3,7 @@ package io.livekit.android.room @@ -3,6 +3,7 @@ package io.livekit.android.room
3 import android.os.SystemClock 3 import android.os.SystemClock
4 import io.livekit.android.ConnectOptions 4 import io.livekit.android.ConnectOptions
5 import io.livekit.android.dagger.InjectionNames 5 import io.livekit.android.dagger.InjectionNames
  6 +import io.livekit.android.room.participant.ParticipantTrackPermission
6 import io.livekit.android.room.track.TrackException 7 import io.livekit.android.room.track.TrackException
7 import io.livekit.android.room.util.* 8 import io.livekit.android.room.util.*
8 import io.livekit.android.util.CloseableCoroutineScope 9 import io.livekit.android.util.CloseableCoroutineScope
@@ -229,6 +230,13 @@ internal constructor( @@ -229,6 +230,13 @@ internal constructor(
229 } 230 }
230 } 231 }
231 232
  233 + fun updateSubscriptionPermissions(
  234 + allParticipants: Boolean,
  235 + participantTrackPermissions: List<ParticipantTrackPermission>
  236 + ) {
  237 + client.sendUpdateSubscriptionPermissions(allParticipants, participantTrackPermissions)
  238 + }
  239 +
232 fun updateMuteStatus(sid: String, muted: Boolean) { 240 fun updateMuteStatus(sid: String, muted: Boolean) {
233 client.sendMuteTrack(sid, muted) 241 client.sendMuteTrack(sid, muted)
234 } 242 }
@@ -386,6 +394,7 @@ internal constructor( @@ -386,6 +394,7 @@ internal constructor(
386 fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind) 394 fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind)
387 fun onStreamStateUpdate(streamStates: List<LivekitRtc.StreamStateInfo>) 395 fun onStreamStateUpdate(streamStates: List<LivekitRtc.StreamStateInfo>)
388 fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) 396 fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate)
  397 + fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate)
389 } 398 }
390 399
391 companion object { 400 companion object {
@@ -531,6 +540,10 @@ internal constructor( @@ -531,6 +540,10 @@ internal constructor(
531 listener?.onSubscribedQualityUpdate(subscribedQualityUpdate) 540 listener?.onSubscribedQualityUpdate(subscribedQualityUpdate)
532 } 541 }
533 542
  543 + override fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) {
  544 + listener?.onSubscriptionPermissionUpdate(subscriptionPermissionUpdate)
  545 + }
  546 +
534 //--------------------------------- DataChannel.Observer ------------------------------------// 547 //--------------------------------- DataChannel.Observer ------------------------------------//
535 548
536 override fun onBufferedAmountChange(previousAmount: Long) { 549 override fun onBufferedAmountChange(previousAmount: Long) {
@@ -226,6 +226,14 @@ constructor( @@ -226,6 +226,14 @@ constructor(
226 it.streamState 226 it.streamState
227 ) 227 )
228 ) 228 )
  229 + is ParticipantEvent.TrackSubscriptionPermissionChanged -> eventBus.postEvent(
  230 + RoomEvent.TrackSubscriptionPermissionChanged(
  231 + this@Room,
  232 + it.participant,
  233 + it.trackPublication,
  234 + it.subscriptionAllowed
  235 + )
  236 + )
229 } 237 }
230 } 238 }
231 } 239 }
@@ -483,6 +491,11 @@ constructor( @@ -483,6 +491,11 @@ constructor(
483 localParticipant.handleSubscribedQualityUpdate(subscribedQualityUpdate) 491 localParticipant.handleSubscribedQualityUpdate(subscribedQualityUpdate)
484 } 492 }
485 493
  494 + override fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) {
  495 + val participant = getParticipant(subscriptionPermissionUpdate.participantSid) as? RemoteParticipant ?: return
  496 + participant.onSubscriptionPermissionUpdate(subscriptionPermissionUpdate)
  497 + }
  498 +
486 /** 499 /**
487 * @suppress 500 * @suppress
488 */ 501 */
@@ -5,6 +5,7 @@ import com.vdurmont.semver4j.Semver @@ -5,6 +5,7 @@ import com.vdurmont.semver4j.Semver
5 import io.livekit.android.ConnectOptions 5 import io.livekit.android.ConnectOptions
6 import io.livekit.android.Version 6 import io.livekit.android.Version
7 import io.livekit.android.dagger.InjectionNames 7 import io.livekit.android.dagger.InjectionNames
  8 +import io.livekit.android.room.participant.ParticipantTrackPermission
8 import io.livekit.android.room.track.Track 9 import io.livekit.android.room.track.Track
9 import io.livekit.android.util.CloseableCoroutineScope 10 import io.livekit.android.util.CloseableCoroutineScope
10 import io.livekit.android.util.Either 11 import io.livekit.android.util.Either
@@ -329,6 +330,21 @@ constructor( @@ -329,6 +330,21 @@ constructor(
329 sendRequest(request) 330 sendRequest(request)
330 } 331 }
331 332
  333 + fun sendUpdateSubscriptionPermissions(
  334 + allParticipants: Boolean,
  335 + participantTrackPermissions: List<ParticipantTrackPermission>
  336 + ) {
  337 + val update = LivekitRtc.UpdateSubscriptionPermissions.newBuilder()
  338 + .setAllParticipants(allParticipants)
  339 + .addAllTrackPermissions(participantTrackPermissions.map { it.toProto() })
  340 +
  341 + val request = LivekitRtc.SignalRequest.newBuilder()
  342 + .setSubscriptionPermissions(update)
  343 + .build()
  344 +
  345 + sendRequest(request)
  346 + }
  347 +
332 fun sendLeave() { 348 fun sendLeave() {
333 val request = LivekitRtc.SignalRequest.newBuilder() 349 val request = LivekitRtc.SignalRequest.newBuilder()
334 .setLeave(LivekitRtc.LeaveRequest.newBuilder().build()) 350 .setLeave(LivekitRtc.LeaveRequest.newBuilder().build())
@@ -433,7 +449,7 @@ constructor( @@ -433,7 +449,7 @@ constructor(
433 listener?.onSubscribedQualityUpdate(response.subscribedQualityUpdate) 449 listener?.onSubscribedQualityUpdate(response.subscribedQualityUpdate)
434 } 450 }
435 LivekitRtc.SignalResponse.MessageCase.SUBSCRIPTION_PERMISSION_UPDATE -> { 451 LivekitRtc.SignalResponse.MessageCase.SUBSCRIPTION_PERMISSION_UPDATE -> {
436 - // TODO 452 + listener?.onSubscriptionPermissionUpdate(response.subscriptionPermissionUpdate)
437 } 453 }
438 LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, 454 LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET,
439 null -> { 455 null -> {
@@ -463,6 +479,7 @@ constructor( @@ -463,6 +479,7 @@ constructor(
463 fun onError(error: Throwable) 479 fun onError(error: Throwable)
464 fun onStreamStateUpdate(streamStates: List<LivekitRtc.StreamStateInfo>) 480 fun onStreamStateUpdate(streamStates: List<LivekitRtc.StreamStateInfo>)
465 fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) 481 fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate)
  482 + fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate)
466 } 483 }
467 484
468 companion object { 485 companion object {
@@ -419,6 +419,30 @@ internal constructor( @@ -419,6 +419,30 @@ internal constructor(
419 } 419 }
420 } 420 }
421 421
  422 + /**
  423 + * Control who can subscribe to LocalParticipant's published tracks.
  424 + *
  425 + * By default, all participants can subscribe. This allows fine-grained control over
  426 + * who is able to subscribe at a participant and track level.
  427 + *
  428 + * Note: if access is given at a track-level (i.e. both [allParticipantsAllowed] and
  429 + * [ParticipantTrackPermission.allTracksAllowed] are false), any newer published tracks
  430 + * will not grant permissions to any participants and will require a subsequent
  431 + * permissions update to allow subscription.
  432 + *
  433 + * @param allParticipantsAllowed Allows all participants to subscribe all tracks.
  434 + * Takes precedence over [participantTrackPermissions] if set to true.
  435 + * By default this is set to true.
  436 + * @param participantTrackPermissions Full list of individual permissions per
  437 + * participant/track. Any omitted participants will not receive any permissions.
  438 + */
  439 + fun setTrackSubscriptionPermissions(
  440 + allParticipantsAllowed: Boolean,
  441 + participantTrackPermissions: List<ParticipantTrackPermission> = emptyList()
  442 + ) {
  443 + engine.updateSubscriptionPermissions(allParticipantsAllowed, participantTrackPermissions)
  444 + }
  445 +
422 fun unpublishTrack(track: Track) { 446 fun unpublishTrack(track: Track) {
423 val publication = localTrackPublications.firstOrNull { it.track == track } 447 val publication = localTrackPublications.firstOrNull { it.track == track }
424 if (publication === null) { 448 if (publication === null) {
@@ -616,4 +640,29 @@ data class AudioTrackPublishOptions( @@ -616,4 +640,29 @@ data class AudioTrackPublishOptions(
616 base.audioBitrate, 640 base.audioBitrate,
617 base.dtx 641 base.dtx
618 ) 642 )
  643 +}
  644 +
  645 +data class ParticipantTrackPermission(
  646 + /**
  647 + * The participant id this permission applies to.
  648 + */
  649 + val participantSid: String,
  650 + /**
  651 + * If set to true, the target participant can subscribe to all tracks from the local participant.
  652 + *
  653 + * Takes precedence over [allowedTrackSids].
  654 + */
  655 + val allTracksAllowed: Boolean,
  656 + /**
  657 + * The list of track ids that the target participant can subscribe to.
  658 + */
  659 + val allowedTrackSids: List<String> = emptyList()
  660 +) {
  661 + fun toProto(): LivekitRtc.TrackPermission {
  662 + return LivekitRtc.TrackPermission.newBuilder()
  663 + .setParticipantSid(participantSid)
  664 + .setAllTracks(allTracksAllowed)
  665 + .addAllTrackSids(allowedTrackSids)
  666 + .build()
  667 + }
619 } 668 }
@@ -10,6 +10,7 @@ import kotlinx.coroutines.SupervisorJob @@ -10,6 +10,7 @@ import kotlinx.coroutines.SupervisorJob
10 import kotlinx.coroutines.delay 10 import kotlinx.coroutines.delay
11 import kotlinx.coroutines.launch 11 import kotlinx.coroutines.launch
12 import livekit.LivekitModels 12 import livekit.LivekitModels
  13 +import livekit.LivekitRtc
13 import org.webrtc.AudioTrack 14 import org.webrtc.AudioTrack
14 import org.webrtc.MediaStreamTrack 15 import org.webrtc.MediaStreamTrack
15 import org.webrtc.VideoTrack 16 import org.webrtc.VideoTrack
@@ -19,8 +20,8 @@ class RemoteParticipant( @@ -19,8 +20,8 @@ class RemoteParticipant(
19 identity: String? = null, 20 identity: String? = null,
20 val signalClient: SignalClient, 21 val signalClient: SignalClient,
21 private val ioDispatcher: CoroutineDispatcher, 22 private val ioDispatcher: CoroutineDispatcher,
22 - defaultdispatcher: CoroutineDispatcher,  
23 -) : Participant(sid, identity, defaultdispatcher) { 23 + defaultDispatcher: CoroutineDispatcher,
  24 +) : Participant(sid, identity, defaultDispatcher) {
24 /** 25 /**
25 * @suppress 26 * @suppress
26 */ 27 */
@@ -28,18 +29,18 @@ class RemoteParticipant( @@ -28,18 +29,18 @@ class RemoteParticipant(
28 info: LivekitModels.ParticipantInfo, 29 info: LivekitModels.ParticipantInfo,
29 signalClient: SignalClient, 30 signalClient: SignalClient,
30 ioDispatcher: CoroutineDispatcher, 31 ioDispatcher: CoroutineDispatcher,
31 - defaultdispatcher: CoroutineDispatcher, 32 + defaultDispatcher: CoroutineDispatcher,
32 ) : this( 33 ) : this(
33 info.sid, 34 info.sid,
34 info.identity, 35 info.identity,
35 signalClient, 36 signalClient,
36 ioDispatcher, 37 ioDispatcher,
37 - defaultdispatcher 38 + defaultDispatcher
38 ) { 39 ) {
39 updateFromInfo(info) 40 updateFromInfo(info)
40 } 41 }
41 42
42 - private val coroutineScope = CloseableCoroutineScope(SupervisorJob()) 43 + private val coroutineScope = CloseableCoroutineScope(defaultDispatcher + SupervisorJob())
43 44
44 fun getTrackPublication(sid: String): RemoteTrackPublication? = tracks[sid] as? RemoteTrackPublication 45 fun getTrackPublication(sid: String): RemoteTrackPublication? = tracks[sid] as? RemoteTrackPublication
45 46
@@ -98,17 +99,8 @@ class RemoteParticipant( @@ -98,17 +99,8 @@ class RemoteParticipant(
98 triesLeft: Int = 20 99 triesLeft: Int = 20
99 ) { 100 ) {
100 val publication = getTrackPublication(sid) 101 val publication = getTrackPublication(sid)
101 - val track: Track = when (val kind = mediaTrack.kind()) {  
102 - KIND_AUDIO -> AudioTrack(rtcTrack = mediaTrack as AudioTrack, name = "")  
103 - KIND_VIDEO -> RemoteVideoTrack(  
104 - rtcTrack = mediaTrack as VideoTrack,  
105 - name = "",  
106 - autoManageVideo = autoManageVideo,  
107 - dispatcher = ioDispatcher  
108 - )  
109 - else -> throw TrackException.InvalidTrackTypeException("invalid track type: $kind")  
110 - }  
111 102
  103 + // We may receive subscribed tracks before publications come in. Retry until then.
112 if (publication == null) { 104 if (publication == null) {
113 if (triesLeft == 0) { 105 if (triesLeft == 0) {
114 val message = "Could not find published track with sid: $sid" 106 val message = "Could not find published track with sid: $sid"
@@ -127,6 +119,17 @@ class RemoteParticipant( @@ -127,6 +119,17 @@ class RemoteParticipant(
127 return 119 return
128 } 120 }
129 121
  122 + val track: Track = when (val kind = mediaTrack.kind()) {
  123 + KIND_AUDIO -> AudioTrack(rtcTrack = mediaTrack as AudioTrack, name = "")
  124 + KIND_VIDEO -> RemoteVideoTrack(
  125 + rtcTrack = mediaTrack as VideoTrack,
  126 + name = "",
  127 + autoManageVideo = autoManageVideo,
  128 + dispatcher = ioDispatcher
  129 + )
  130 + else -> throw TrackException.InvalidTrackTypeException("invalid track type: $kind")
  131 + }
  132 +
130 publication.track = track 133 publication.track = track
131 track.name = publication.name 134 track.name = publication.name
132 track.sid = publication.sid 135 track.sid = publication.sid
@@ -158,6 +161,19 @@ class RemoteParticipant( @@ -158,6 +161,19 @@ class RemoteParticipant(
158 } 161 }
159 } 162 }
160 163
  164 + internal fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) {
  165 + val pub = tracks[subscriptionPermissionUpdate.trackSid] as? RemoteTrackPublication ?: return
  166 +
  167 + if (pub.subscriptionAllowed != subscriptionPermissionUpdate.allowed) {
  168 + pub.subscriptionAllowed = subscriptionPermissionUpdate.allowed
  169 +
  170 + eventBus.postEvent(
  171 + ParticipantEvent.TrackSubscriptionPermissionChanged(this, pub, pub.subscriptionAllowed),
  172 + coroutineScope
  173 + )
  174 + }
  175 + }
  176 +
161 // Internal methods just for posting events. 177 // Internal methods just for posting events.
162 internal fun onDataReceived(data: ByteArray) { 178 internal fun onDataReceived(data: ByteArray) {
163 listener?.onDataReceived(data, this) 179 listener?.onDataReceived(data, this)
@@ -42,20 +42,6 @@ class RemoteTrackPublication( @@ -42,20 +42,6 @@ class RemoteTrackPublication(
42 } 42 }
43 } 43 }
44 44
45 - private fun handleVisibilityChanged(trackEvent: TrackEvent.VisibilityChanged) {  
46 - disabled = !trackEvent.isVisible  
47 - sendUpdateTrackSettings.invoke()  
48 - }  
49 -  
50 - private fun handleVideoDimensionsChanged(trackEvent: TrackEvent.VideoDimensionsChanged) {  
51 - videoDimensions = trackEvent.newDimensions  
52 - sendUpdateTrackSettings.invoke()  
53 - }  
54 -  
55 - private fun handleStreamStateChanged(trackEvent: TrackEvent.StreamStateChanged) {  
56 - participant.get()?.onTrackStreamStateChanged(trackEvent)  
57 - }  
58 -  
59 private var trackJob: Job? = null 45 private var trackJob: Job? = null
60 46
61 private var unsubscribed: Boolean = false 47 private var unsubscribed: Boolean = false
@@ -63,16 +49,36 @@ class RemoteTrackPublication( @@ -63,16 +49,36 @@ class RemoteTrackPublication(
63 private var videoQuality: LivekitModels.VideoQuality? = LivekitModels.VideoQuality.HIGH 49 private var videoQuality: LivekitModels.VideoQuality? = LivekitModels.VideoQuality.HIGH
64 private var videoDimensions: Track.Dimensions? = null 50 private var videoDimensions: Track.Dimensions? = null
65 51
  52 + var subscriptionAllowed: Boolean = true
  53 + internal set
  54 +
66 val isAutoManaged: Boolean 55 val isAutoManaged: Boolean
67 get() = (track as? RemoteVideoTrack)?.autoManageVideo ?: false 56 get() = (track as? RemoteVideoTrack)?.autoManageVideo ?: false
68 57
  58 + /**
  59 + * Returns true if track is subscribed, and ready for playback
  60 + *
  61 + * @see [subscriptionStatus]
  62 + */
69 override val subscribed: Boolean 63 override val subscribed: Boolean
70 get() { 64 get() {
71 - if (unsubscribed) { 65 + if (unsubscribed || !subscriptionAllowed) {
72 return false 66 return false
73 } 67 }
74 return super.subscribed 68 return super.subscribed
75 } 69 }
  70 +
  71 + val subscriptionStatus: SubscriptionStatus
  72 + get() {
  73 + return if (!unsubscribed || track == null) {
  74 + SubscriptionStatus.UNSUBSCRIBED
  75 + } else if (!subscriptionAllowed) {
  76 + SubscriptionStatus.SUBSCRIBED_AND_NOT_ALLOWED
  77 + } else {
  78 + SubscriptionStatus.SUBSCRIBED
  79 + }
  80 + }
  81 +
76 override var muted: Boolean = false 82 override var muted: Boolean = false
77 set(v) { 83 set(v) {
78 if (field == v) { 84 if (field == v) {
@@ -88,12 +94,11 @@ class RemoteTrackPublication( @@ -88,12 +94,11 @@ class RemoteTrackPublication(
88 } 94 }
89 95
90 /** 96 /**
91 - * subscribe or unsubscribe from this track 97 + * Subscribe or unsubscribe from this track
92 */ 98 */
93 fun setSubscribed(subscribed: Boolean) { 99 fun setSubscribed(subscribed: Boolean) {
94 unsubscribed = !subscribed 100 unsubscribed = !subscribed
95 val participant = this.participant.get() as? RemoteParticipant ?: return 101 val participant = this.participant.get() as? RemoteParticipant ?: return
96 -  
97 participant.signalClient.sendUpdateSubscription(sid, !unsubscribed) 102 participant.signalClient.sendUpdateSubscription(sid, !unsubscribed)
98 } 103 }
99 104
@@ -147,6 +152,20 @@ class RemoteTrackPublication( @@ -147,6 +152,20 @@ class RemoteTrackPublication(
147 sendUpdateTrackSettings.invoke() 152 sendUpdateTrackSettings.invoke()
148 } 153 }
149 154
  155 + private fun handleVisibilityChanged(trackEvent: TrackEvent.VisibilityChanged) {
  156 + disabled = !trackEvent.isVisible
  157 + sendUpdateTrackSettings.invoke()
  158 + }
  159 +
  160 + private fun handleVideoDimensionsChanged(trackEvent: TrackEvent.VideoDimensionsChanged) {
  161 + videoDimensions = trackEvent.newDimensions
  162 + sendUpdateTrackSettings.invoke()
  163 + }
  164 +
  165 + private fun handleStreamStateChanged(trackEvent: TrackEvent.StreamStateChanged) {
  166 + participant.get()?.onTrackStreamStateChanged(trackEvent)
  167 + }
  168 +
150 // Debounce just in case multiple settings get changed at once. 169 // Debounce just in case multiple settings get changed at once.
151 private val sendUpdateTrackSettings = debounce<Unit, Unit>(100L, CoroutineScope(ioDispatcher)) { 170 private val sendUpdateTrackSettings = debounce<Unit, Unit>(100L, CoroutineScope(ioDispatcher)) {
152 sendUpdateTrackSettingsImpl() 171 sendUpdateTrackSettingsImpl()
@@ -162,4 +181,21 @@ class RemoteTrackPublication( @@ -162,4 +181,21 @@ class RemoteTrackPublication(
162 videoQuality 181 videoQuality
163 ) 182 )
164 } 183 }
  184 +
  185 + enum class SubscriptionStatus {
  186 + /**
  187 + * Has a valid track, receiving data.
  188 + */
  189 + SUBSCRIBED,
  190 +
  191 + /**
  192 + * Has a track, but no data will be received due to permissions.
  193 + */
  194 + SUBSCRIBED_AND_NOT_ALLOWED,
  195 +
  196 + /**
  197 + * Not subscribed.
  198 + */
  199 + UNSUBSCRIBED
  200 + }
165 } 201 }
@@ -4,6 +4,9 @@ import org.webrtc.AudioTrack @@ -4,6 +4,9 @@ import org.webrtc.AudioTrack
4 import org.webrtc.MediaStream 4 import org.webrtc.MediaStream
5 import org.webrtc.VideoTrack 5 import org.webrtc.VideoTrack
6 6
  7 +fun createMediaStreamId(participantSid: String, trackSid: String) =
  8 + "${TestData.REMOTE_PARTICIPANT.sid}|${TestData.REMOTE_AUDIO_TRACK.sid}"
  9 +
7 class MockMediaStream(private val id: String = "id") : MediaStream(1L) { 10 class MockMediaStream(private val id: String = "id") : MediaStream(1L) {
8 11
9 override fun addTrack(track: AudioTrack): Boolean { 12 override fun addTrack(track: AudioTrack): Boolean {
@@ -9,10 +9,10 @@ import kotlinx.coroutines.test.TestCoroutineDispatcher @@ -9,10 +9,10 @@ import kotlinx.coroutines.test.TestCoroutineDispatcher
9 import javax.inject.Named 9 import javax.inject.Named
10 10
11 @Module 11 @Module
12 -object TestCoroutinesModule {  
13 - 12 +class TestCoroutinesModule(
14 @OptIn(ExperimentalCoroutinesApi::class) 13 @OptIn(ExperimentalCoroutinesApi::class)
15 val coroutineDispatcher: CoroutineDispatcher = TestCoroutineDispatcher() 14 val coroutineDispatcher: CoroutineDispatcher = TestCoroutineDispatcher()
  15 +) {
16 16
17 @Provides 17 @Provides
18 @Named(InjectionNames.DISPATCHER_DEFAULT) 18 @Named(InjectionNames.DISPATCHER_DEFAULT)
@@ -23,6 +23,6 @@ interface TestLiveKitComponent : LiveKitComponent { @@ -23,6 +23,6 @@ interface TestLiveKitComponent : LiveKitComponent {
23 23
24 @Component.Factory 24 @Component.Factory
25 interface Factory { 25 interface Factory {
26 - fun create(@BindsInstance appContext: Context): TestLiveKitComponent 26 + fun create(@BindsInstance appContext: Context, coroutinesModule: TestCoroutinesModule = TestCoroutinesModule()): TestLiveKitComponent
27 } 27 }
28 } 28 }
@@ -4,16 +4,17 @@ import android.content.Context @@ -4,16 +4,17 @@ import android.content.Context
4 import androidx.test.core.app.ApplicationProvider 4 import androidx.test.core.app.ApplicationProvider
5 import io.livekit.android.coroutines.TestCoroutineRule 5 import io.livekit.android.coroutines.TestCoroutineRule
6 import io.livekit.android.events.EventCollector 6 import io.livekit.android.events.EventCollector
  7 +import io.livekit.android.events.ParticipantEvent
7 import io.livekit.android.events.RoomEvent 8 import io.livekit.android.events.RoomEvent
8 import io.livekit.android.mock.* 9 import io.livekit.android.mock.*
9 import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent 10 import io.livekit.android.mock.dagger.DaggerTestLiveKitComponent
  11 +import io.livekit.android.mock.dagger.TestCoroutinesModule
10 import io.livekit.android.room.participant.ConnectionQuality 12 import io.livekit.android.room.participant.ConnectionQuality
11 import io.livekit.android.room.track.Track 13 import io.livekit.android.room.track.Track
12 import io.livekit.android.util.toOkioByteString 14 import io.livekit.android.util.toOkioByteString
13 import kotlinx.coroutines.ExperimentalCoroutinesApi 15 import kotlinx.coroutines.ExperimentalCoroutinesApi
14 import kotlinx.coroutines.launch 16 import kotlinx.coroutines.launch
15 import kotlinx.coroutines.test.runBlockingTest 17 import kotlinx.coroutines.test.runBlockingTest
16 -import livekit.LivekitRtc  
17 import org.junit.Assert 18 import org.junit.Assert
18 import org.junit.Before 19 import org.junit.Before
19 import org.junit.Rule 20 import org.junit.Rule
@@ -41,7 +42,7 @@ class RoomMockE2ETest { @@ -41,7 +42,7 @@ class RoomMockE2ETest {
41 context = ApplicationProvider.getApplicationContext() 42 context = ApplicationProvider.getApplicationContext()
42 val component = DaggerTestLiveKitComponent 43 val component = DaggerTestLiveKitComponent
43 .factory() 44 .factory()
44 - .create(context) 45 + .create(context, TestCoroutinesModule(coroutineRule.dispatcher))
45 46
46 room = component.roomFactory() 47 room = component.roomFactory()
47 .create(context) 48 .create(context)
@@ -55,9 +56,10 @@ class RoomMockE2ETest { @@ -55,9 +56,10 @@ class RoomMockE2ETest {
55 token = "", 56 token = "",
56 ) 57 )
57 } 58 }
58 -  
59 wsFactory.listener.onMessage(wsFactory.ws, SignalClientTest.JOIN.toOkioByteString()) 59 wsFactory.listener.onMessage(wsFactory.ws, SignalClientTest.JOIN.toOkioByteString())
60 60
  61 + // PeerTransport negotiation is on a debounce delay.
  62 + coroutineRule.dispatcher.advanceTimeBy(1000L)
61 runBlockingTest { 63 runBlockingTest {
62 job.join() 64 job.join()
63 } 65 }
@@ -69,7 +71,7 @@ class RoomMockE2ETest { @@ -69,7 +71,7 @@ class RoomMockE2ETest {
69 } 71 }
70 72
71 @Test 73 @Test
72 - fun connectFailureProperlyContinues(){ 74 + fun connectFailureProperlyContinues() {
73 75
74 var didThrowException = false 76 var didThrowException = false
75 val job = coroutineRule.scope.launch { 77 val job = coroutineRule.scope.launch {
@@ -91,6 +93,7 @@ class RoomMockE2ETest { @@ -91,6 +93,7 @@ class RoomMockE2ETest {
91 93
92 Assert.assertTrue(didThrowException) 94 Assert.assertTrue(didThrowException)
93 } 95 }
  96 +
94 @Test 97 @Test
95 fun roomUpdateTest() { 98 fun roomUpdateTest() {
96 connect() 99 connect()
@@ -203,7 +206,14 @@ class RoomMockE2ETest { @@ -203,7 +206,14 @@ class RoomMockE2ETest {
203 // add track. 206 // add track.
204 room.onAddTrack( 207 room.onAddTrack(
205 MockAudioStreamTrack(), 208 MockAudioStreamTrack(),
206 - arrayOf(MockMediaStream(id = "${TestData.REMOTE_PARTICIPANT.sid}|${TestData.REMOTE_AUDIO_TRACK.sid}")) 209 + arrayOf(
  210 + MockMediaStream(
  211 + id = createMediaStreamId(
  212 + TestData.REMOTE_PARTICIPANT.sid,
  213 + TestData.REMOTE_AUDIO_TRACK.sid
  214 + )
  215 + )
  216 + )
207 ) 217 )
208 val eventCollector = EventCollector(room.events, coroutineRule.scope) 218 val eventCollector = EventCollector(room.events, coroutineRule.scope)
209 wsFactory.listener.onMessage( 219 wsFactory.listener.onMessage(
@@ -220,6 +230,41 @@ class RoomMockE2ETest { @@ -220,6 +230,41 @@ class RoomMockE2ETest {
220 } 230 }
221 231
222 @Test 232 @Test
  233 + fun trackSubscriptionPermissionChanged() {
  234 + connect()
  235 +
  236 + wsFactory.listener.onMessage(
  237 + wsFactory.ws,
  238 + SignalClientTest.PARTICIPANT_JOIN.toOkioByteString()
  239 + )
  240 + room.onAddTrack(
  241 + MockAudioStreamTrack(),
  242 + arrayOf(
  243 + MockMediaStream(
  244 + id = createMediaStreamId(
  245 + TestData.REMOTE_PARTICIPANT.sid,
  246 + TestData.REMOTE_AUDIO_TRACK.sid
  247 + )
  248 + )
  249 + )
  250 + )
  251 + val eventCollector = EventCollector(room.events, coroutineRule.scope)
  252 + wsFactory.listener.onMessage(
  253 + wsFactory.ws,
  254 + SignalClientTest.SUBSCRIPTION_PERMISSION_UPDATE.toOkioByteString()
  255 + )
  256 + val events = eventCollector.stopCollecting()
  257 +
  258 + Assert.assertEquals(1, events.size)
  259 + Assert.assertEquals(true, events[0] is RoomEvent.TrackSubscriptionPermissionChanged)
  260 +
  261 + val event = events[0] as RoomEvent.TrackSubscriptionPermissionChanged
  262 + Assert.assertEquals(TestData.REMOTE_PARTICIPANT.sid, event.participant.sid)
  263 + Assert.assertEquals(TestData.REMOTE_AUDIO_TRACK.sid, event.trackPublication.sid)
  264 + Assert.assertEquals(false, event.subscriptionAllowed)
  265 + }
  266 +
  267 + @Test
223 fun leave() { 268 fun leave() {
224 connect() 269 connect()
225 val eventCollector = EventCollector(room.events, coroutineRule.scope) 270 val eventCollector = EventCollector(room.events, coroutineRule.scope)
@@ -241,6 +241,16 @@ class SignalClientTest { @@ -241,6 +241,16 @@ class SignalClientTest {
241 } 241 }
242 build() 242 build()
243 } 243 }
  244 +
  245 + val SUBSCRIPTION_PERMISSION_UPDATE = with(LivekitRtc.SignalResponse.newBuilder()) {
  246 + subscriptionPermissionUpdate = with(LivekitRtc.SubscriptionPermissionUpdate.newBuilder()) {
  247 + participantSid = TestData.REMOTE_PARTICIPANT.sid
  248 + trackSid = TestData.REMOTE_AUDIO_TRACK.sid
  249 + allowed = false
  250 + build()
  251 + }
  252 + build()
  253 + }
244 val LEAVE = with(LivekitRtc.SignalResponse.newBuilder()) { 254 val LEAVE = with(LivekitRtc.SignalResponse.newBuilder()) {
245 leave = with(leaveBuilder) { 255 leave = with(leaveBuilder) {
246 build() 256 build()
@@ -24,7 +24,7 @@ class RemoteParticipantTest { @@ -24,7 +24,7 @@ class RemoteParticipantTest {
24 "sid", 24 "sid",
25 signalClient = signalClient, 25 signalClient = signalClient,
26 ioDispatcher = coroutineRule.dispatcher, 26 ioDispatcher = coroutineRule.dispatcher,
27 - defaultdispatcher = coroutineRule.dispatcher, 27 + defaultDispatcher = coroutineRule.dispatcher,
28 ) 28 )
29 } 29 }
30 30
@@ -38,7 +38,7 @@ class RemoteParticipantTest { @@ -38,7 +38,7 @@ class RemoteParticipantTest {
38 info, 38 info,
39 signalClient, 39 signalClient,
40 ioDispatcher = coroutineRule.dispatcher, 40 ioDispatcher = coroutineRule.dispatcher,
41 - defaultdispatcher = coroutineRule.dispatcher, 41 + defaultDispatcher = coroutineRule.dispatcher,
42 ) 42 )
43 43
44 assertEquals(1, participant.tracks.values.size) 44 assertEquals(1, participant.tracks.values.size)