diff --git a/.changeset/data-received-participant-identity.md b/.changeset/data-received-participant-identity.md new file mode 100644 index 000000000..f91cecf3e --- /dev/null +++ b/.changeset/data-received-participant-identity.md @@ -0,0 +1,9 @@ +--- +"client-sdk-android": patch +--- + +Expose `participantIdentity` on `RoomEvent.DataReceived`. The event's `participant` is nullable and is null whenever the sender is not in the room — data sent through the Server API, or by a participant that has already left. In that case the receiver had no way to tell who sent the data, because the engine dropped the identity before building the event: `onUserPacket` was handed only the inner `UserPacket`, while the authoritative identity lives on the enclosing `DataPacket`. + +The internal engine listener now forwards that identity, and it is reported on every `DataReceived` event whether or not the sender resolves to a `RemoteParticipant`. + +This matches the JavaScript SDK (livekit/client-sdk-js#2092). diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt index b49a85098..7aa0174b0 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt @@ -247,6 +247,19 @@ sealed class RoomEvent(val room: Room) : Event() { /** * Received data published by another participant * + * [participant] is null when the sender is not in the room, such as data published through + * the Server API or by a participant that has already left. Use [participantIdentity] to + * attribute the data in that case. + * + * ``` + * room.events.collect { event -> + * if (event is RoomEvent.DataReceived) { + * val sender = event.participant?.identity?.value ?: event.participantIdentity + * println("$sender sent ${event.data.size} bytes") + * } + * } + * ``` + * * @param data the published data * @param participant the participant if available */ @@ -256,8 +269,27 @@ sealed class RoomEvent(val room: Room) : Event() { val participant: RemoteParticipant?, val topic: String?, val encryptionType: LivekitModels.Encryption.Type, + /** + * Identity of the sender. Reported even when [participant] is null because the + * sender is not (or is no longer) a participant in this room. + */ + val participantIdentity: String = "", ) : - RoomEvent(room) + RoomEvent(room) { + /** + * Retains the pre-[participantIdentity] JVM constructor signature, so modules compiled + * against an earlier SDK keep linking. A Kotlin default argument alone would not: it + * emits the full-arity constructor plus a synthetic one, and the old five-argument + * signature would disappear. + */ + constructor( + room: Room, + data: ByteArray, + participant: RemoteParticipant?, + topic: String?, + encryptionType: LivekitModels.Encryption.Type, + ) : this(room, data, participant, topic, encryptionType, "") + } /** * The connection quality for a participant has changed. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 5ecb40249..2b8e0434c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1186,7 +1186,7 @@ internal constructor( fun onRoomUpdate(update: LivekitModels.Room) fun onConnectionQuality(updates: List) fun onSpeakersChanged(speakers: List) - fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind, encryptionType: LivekitModels.Encryption.Type) + fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind, encryptionType: LivekitModels.Encryption.Type, participantIdentity: String) fun onStreamStateUpdate(streamStates: List) fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) fun onRequestSubscribedCodecRefresh() {} @@ -1550,7 +1550,7 @@ internal constructor( } LivekitModels.DataPacket.ValueCase.USER -> { - listener?.onUserPacket(dp.user, dp.kind, encryptionType) + listener?.onUserPacket(dp.user, dp.kind, encryptionType, dp.participantIdentity) } LivekitModels.DataPacket.ValueCase.SIP_DTMF -> { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index cb0882454..2a420ef96 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -1391,7 +1391,7 @@ constructor( /** * @suppress */ - override fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind, encryptionType: LivekitModels.Encryption.Type) { + override fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind, encryptionType: LivekitModels.Encryption.Type, participantIdentity: String) { val participant = getParticipantBySid(packet.participantSid) as? RemoteParticipant val data = packet.payload.toByteArray() val topic = if (packet.hasTopic()) { @@ -1400,7 +1400,7 @@ constructor( null } - val event = RoomEvent.DataReceived(this, data, participant, topic, encryptionType) + val event = RoomEvent.DataReceived(this, data, participant, topic, encryptionType, participantIdentity) eventBus.postEvent(event, coroutineScope) participant?.onDataReceived(event) } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RoomDataMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomDataMockE2ETest.kt index c3db450f2..51c087805 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomDataMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomDataMockE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import livekit.LivekitModels.DataPacket import livekit.LivekitModels.UserPacket import livekit.org.webrtc.DataChannel import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import java.nio.ByteBuffer @@ -62,4 +63,37 @@ class RoomDataMockE2ETest : MockE2ETest() { val event = events[0] as RoomEvent.DataReceived assertEquals("hello", event.data.decodeToString()) } + + @Test + fun dataReceivedReportsSenderIdentityWithoutParticipant() = runTest { + connect() + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) + subPeerConnection.observer?.onDataChannel(subDataChannel) + + val collector = EventCollector(room.events, coroutineRule.scope) + // Data sent by the Server API, or by a participant that has already left, has no + // RemoteParticipant in the room to attach to. + val dataPacket = with(DataPacket.newBuilder()) { + participantIdentity = "server-api-sender" + user = with(UserPacket.newBuilder()) { + payload = ByteString.copyFrom("hello", Charsets.UTF_8) + build() + } + build() + } + val dataBuffer = DataChannel.Buffer( + ByteBuffer.wrap(dataPacket.toByteArray()), + true, + ) + + subDataChannel.observer?.onMessage(dataBuffer) + val events = collector.stopCollecting() + + assertEquals(1, events.size) + val event = events[0] as RoomEvent.DataReceived + + assertNull(event.participant) + assertEquals("server-api-sender", event.participantIdentity) + } }