From 46d29d90c36f634a3b70e605ece3aed7eb3e96fc Mon Sep 17 00:00:00 2001 From: Shijing Xian Date: Fri, 23 Oct 2026 15:30:42 -0700 Subject: [PATCH 1/2] Expose participantIdentity on RoomEvent.DataReceived DataReceived carries a nullable RemoteParticipant, which 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. The identity was discarded before the event was built: the engine passed only the inner UserPacket to onUserPacket, while the authoritative participantIdentity lives on the enclosing DataPacket. Forward it through the listener and report it on every DataReceived event. Port of livekit/client-sdk-js#2092. Co-Authored-By: Claude Opus 5 (1M context) --- .../data-received-participant-identity.md | 9 +++++ .../io/livekit/android/events/RoomEvent.kt | 5 +++ .../java/io/livekit/android/room/RTCEngine.kt | 4 +-- .../main/java/io/livekit/android/room/Room.kt | 4 +-- .../android/room/RoomDataMockE2ETest.kt | 34 +++++++++++++++++++ 5 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 .changeset/data-received-participant-identity.md 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..578ea3cfc 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 @@ -256,6 +256,11 @@ 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) 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..16be58067 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 @@ -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) + } } From 7f191c2e47e8e600f26218f1a78f5382437b8963 Mon Sep 17 00:00:00 2001 From: Shijing Xian Date: Fri, 23 Oct 2026 16:51:51 -0700 Subject: [PATCH 2/2] Keep the old DataReceived constructor signature and document the new field Addresses review feedback: - A Kotlin default argument emits the full-arity constructor plus a synthetic one, so the previous five-argument JVM signature would have disappeared and modules compiled against an earlier SDK would fail to link. Add an explicit five-argument secondary constructor. - AGENTS.md asks for a short example with a new public API, so show attributing data when `participant` is null. - Update the copyright year range that spotless requires. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/livekit/android/events/RoomEvent.kt | 29 ++++++++++++++++++- .../android/room/RoomDataMockE2ETest.kt | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) 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 578ea3cfc..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 */ @@ -262,7 +275,21 @@ sealed class RoomEvent(val room: Room) : Event() { */ 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-test/src/test/java/io/livekit/android/room/RoomDataMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomDataMockE2ETest.kt index 16be58067..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.