Skip to content

feat: pre-call testing - #2235

Merged
greenfrvr merged 29 commits into
mainfrom
loopback-subscription
Jun 24, 2026
Merged

feat: pre-call testing#2235
greenfrvr merged 29 commits into
mainfrom
loopback-subscription

Conversation

@greenfrvr

@greenfrvr greenfrvr commented May 12, 2026

Copy link
Copy Markdown
Contributor

💡 Overview

This PR contains pre-call testing functionality for React Native SDK and dogfood app example how it can be used by end user. The idea of pre-call testing is to receive loopback tracks and record them to a local video file to be able to check the call from the callee's perspective.

📝 Implementation notes

To subscribe to their own published tracks, the caller should send selfSubAudioVideo during publishing. From public API allowOwnTracksLoopback should be passed during join invocation.
When connected, loopback streams are routed to localParticipant (instead of default camera/microphone tracks).

All pre-call testing functionality is provided with a single hook useLoopbackRecording. It allows to start/stop recording of loopback video, getting the loopback tracks refs, check local recordings directory and clear previously recorded videos.

🎫 Ticket: https://linear.app/stream/issue/RN-389/pre-call-testing

📑 Docs: https://github.com/GetStream/docs-content/pull/1325

Summary by CodeRabbit

Summary

  • New Features

    • Added loopback recording support (audio and optional video) via a new useLoopbackRecording hook.
    • Introduced native track-recording bridges on iOS and Android, exposing start/stop/clear/list recording APIs.
    • Added a dogfood “Test Recording” flow with recording, playback/results, and sharing screens.
  • Bug Fixes

    • Improved self-subscription loopback behavior to prevent echo by muting loopback audio and tracking loopback streams.
    • Enhanced join flow options so loopback recording is applied correctly and reset on leave.

@greenfrvr greenfrvr self-assigned this May 12, 2026
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds loopback recording support across the client, native recorders, a React Native hook, and a dogfood test flow.

Changes

SFU Loopback Track Recording

Layer / File(s) Summary
Call loopback flag and self-sub handling
packages/client/src/Call.ts, packages/client/src/rtc/Publisher.ts, packages/client/src/rtc/Subscriber.ts, packages/react-native-sdk/src/utils/internal/callingx/callingx.ts
Call adds the allowOwnTracksLoopback join option and exposes loopback state; Publisher marks self-subscription in track info; Subscriber tracks loopback streams, mutes loopback audio, and skips cleanup for untracked self-sub streams; Callingx setup is bypassed when loopback is enabled.
Android recording contracts and pipelines
packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/*, .../StreamVideoReactNativeModule.kt
Android adds recorder constants, sink adapters, audio/video pipelines, a manager with muxer gating and file management, and a React Native bridge for start/stop/clear/list recording calls.
iOS recording contracts and pipelines
packages/react-native-sdk/ios/TracksRecorder/*, StreamVideoReactNative-Bridging-Header.h, StreamVideoReactNative.m
iOS adds recorder helpers, audio/video sinks, audio/video pipelines, a manager with writer gating and file management, bridging-header audio imports, and exported track-recording methods.
useLoopbackRecording hook
packages/react-native-sdk/src/hooks/useLoopbackRecording.ts, packages/react-native-sdk/src/hooks/index.ts
The hook waits for loopback streams, starts native recording with the loopback option enabled, exposes recording state and storage helpers, and is re-exported from the hooks barrel.
Dogfood TestRecording flow
sample-apps/react-native/dogfood/...
The dogfood app adds a TestRecording mode, navigator, recording and results screens, playback/recording UI, inline stats, translations, and sharing support.

Sequence Diagram(s)

sequenceDiagram
  participant App as TestRecordingScreen
  participant Hook as useLoopbackRecording
  participant Call as Call
  participant Native as TracksRecorderManager
  participant SFU

  App->>Call: getOrCreate()
  App->>Hook: startRecording({ includeVideo })
  Hook->>Call: join({ allowOwnTracksLoopback: true })
  Call->>SFU: publish selfSubAudioVideo
  SFU-->>Call: loopback tracks
  Hook->>Native: startTrackRecording(...)
  Native->>Native: encode audio/video to MP4
  App->>Hook: stopRecording()
  Hook->>Native: stopTrackRecording()
  Native-->>Hook: file:// URI
  App->>App: navigate to results screen
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • GetStream/stream-video-js#2261: Modifies the same Call.join() lifecycle in packages/client/src/Call.ts, overlapping with the loopback join option added here.

Suggested reviewers

  • oliverlaz

Poem

🐰 Hop-hop, the loopback path is now alive,
My own little tracks can record and thrive.
The bunny joins, the streams appear,
Then hops to playback with a cheer. 🎧

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description matches the required template and includes Overview, Implementation notes, Ticket, and Docs sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and relevant to the PR’s main addition: pre-call loopback testing and recording flow.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch loopback-subscription

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@changeset-bot

changeset-bot Bot commented May 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6403706

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Comment thread packages/client/src/helpers/client-details.ts Outdated
Comment thread packages/client/src/rtc/Subscriber.ts Outdated
Comment thread packages/client/src/Call.ts Outdated
@greenfrvr
greenfrvr marked this pull request as ready for review June 24, 2026 12:34

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (4)
sample-apps/react-native/dogfood/src/screens/TestRecording/components/LoopbackPanel.tsx (1)

46-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: use a theme token for the active indicator color.

The "ready" green #4CAF50 is hardcoded here while the rest of the panel uses appTheme.colors. Pulling it from the theme keeps status colors consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@sample-apps/react-native/dogfood/src/screens/TestRecording/components/LoopbackPanel.tsx`
around lines 46 - 69, The active status indicator color in LoopbackPanel is
hardcoded, which is inconsistent with the rest of the component’s theme usage.
Update the dot styling in the badge/status rows to use an existing
appTheme.colors token for the “ready” green instead of the literal value, and
keep the Mic and Video indicator logic unchanged while applying the themed color
wherever the active state is rendered.
sample-apps/react-native/dogfood/src/screens/TestRecording/TestRecordingScreen.tsx (1)

42-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Debug global assignment left in.

globalThis.call = call (with the @ts-expect-error) is a debugging artifact. Consider gating it behind __DEV__ or removing it so it doesn't leak the call instance in every build.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@sample-apps/react-native/dogfood/src/screens/TestRecording/TestRecordingScreen.tsx`
around lines 42 - 43, The TestRecordingScreen debug global assignment is left
enabled in all builds, leaking the call instance via globalThis.call. Update the
logic around the globalThis.call assignment in TestRecordingScreen so it is
either removed entirely or wrapped in a __DEV__-only guard, and keep the
`@ts-expect-error` scoped only if the debug path remains necessary.
sample-apps/react-native/dogfood/types.ts (1)

19-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize TestRecordingStackParamList usage from this file.

The new shared type is good, but related files still redeclare the same shape. Import this exported type in those files to avoid route-param drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sample-apps/react-native/dogfood/types.ts` around lines 19 - 23, The route
param shape is now exported as TestRecordingStackParamList, but other files
still duplicate the same definition and can drift. Update the related React
Native dogfood files to import and reuse TestRecordingStackParamList from this
module instead of redeclaring it, and keep any screen/stack typings wired
through that shared type.
packages/react-native-sdk/ios/TracksRecorder/AudioPipeline.swift (1)

104-110: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid the second PCM deep copy on the audio callback path.

RecorderAudioRenderTap already creates and fills a fresh AVAudioPCMBuffer; capture that buffer into the queue block instead of allocating/copying it again.

Suggested change
-        guard let copy = AudioPipeline.deepCopyPCMBuffer(pcmBuffer) else { return }
         guard let host = host else { return }
 
         // `DispatchTime.now().uptimeNanoseconds` is the monotonic clock
@@
         // shared time origin works coherently across both pipelines.
         let captureTimeNs = DispatchTime.now().uptimeNanoseconds
-        host.queue.async { [weak self] in
-            self?.handleAudioBufferOnQueue(pcmBuffer: copy, captureTimeNs: captureTimeNs)
+        host.queue.async { [weak self, pcmBuffer] in
+            self?.handleAudioBufferOnQueue(pcmBuffer: pcmBuffer, captureTimeNs: captureTimeNs)
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-sdk/ios/TracksRecorder/AudioPipeline.swift` around
lines 104 - 110, The audio callback path in
`AudioPipeline.handleAudioBuffer(pcmBuffer:)` is doing an unnecessary second
deep copy even though `RecorderAudioRenderTap` already provides a freshly
allocated and filled `AVAudioPCMBuffer`. Update the queue handoff to capture and
use that existing buffer directly instead of calling
`AudioPipeline.deepCopyPCMBuffer`, while preserving the lifetime guarantees
needed across the async boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/client/src/Call.ts`:
- Around line 1091-1097: Set and clear the loopback flag around the ringing flow
in Call’s call setup path: `ringingSubject.next(true)` can synchronously trigger
`handleRingingCall()`/`registerOutgoingCall()` before `allowOwnTracksLoopback`
is assigned, so move the flag assignment to happen before any ringing side
effects and make sure it is reset when the call ends or leaves. Update the
relevant call lifecycle logic in `Call` so reusable instances do not carry
`allowOwnTracksLoopback` forward into later calls.

In
`@packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt`:
- Around line 163-177: The stop/cleanup path in TracksRecorderManager should not
resolve a terminal state with no MP4 as success. Update the empty-recording
branch that calls fireTerminalCompletion and the later completion path so they
emit a RecordingError when resolved is null and no file was produced, instead of
passing null/null through to startTrackRecording(). Ensure the logic around
muxerStarted, muxerInstance, and the terminal completion callback always
distinguishes “no output” from a successful recording.
- Around line 313-323: The fatal cleanup path in cleanupAfterFailure() stops and
releases pipelines and the muxer, but leaves the partially created outputFile
behind. Update TracksRecorderManager.cleanupAfterFailure() to also remove the
failed recording file before resetting transient state, so rejected start/encode
failures do not leave a corrupt MP4 that getStreamRecordings() can later return.
- Around line 230-239: The clear-recordings flow in
TracksRecorderManager.clearRecordingsDirectory can delete the currently active
recording output, so update this path to guard against active sessions before
clearing or skip the in-use outputFile when iterating over StreamRecordings. Use
the existing TracksRecorderManager recording state and outputFile references to
detect an active recording, then either reject clearStreamRecordings() during
recording or preserve the current file while deleting only inactive recordings.

In `@packages/react-native-sdk/ios/TracksRecorder/RecorderAudioRenderTap.swift`:
- Around line 96-107: Move the per-buffer diagnostics out of
`audioProcessingProcess(audioBuffer:)` in `RecorderAudioRenderTap` because
`NSLog` on the WebRTC audio thread can block the render callback. Keep the
`_callCount` bookkeeping there, but remove the first-call and every-100th
logging from this method and emit equivalent diagnostics from `logSummary()` or
another off-thread debug path instead.

In `@packages/react-native-sdk/ios/TracksRecorder/TracksRecorderManager.swift`:
- Around line 77-79: The `TracksRecorderManager` video track lookup is too
permissive: when `videoTrackId` is provided but `webRTCModule.track(forId:)`
returns nil or a non-`RTCVideoTrack`, recording should fail instead of silently
continuing with audio-only. Update the validation around `videoTrackId` and
`videoTrack` so the recorder rejects the request with an error when the
requested track cannot be resolved or is not a video track, and keep the success
path only for a valid `RTCVideoTrack`.
- Around line 211-223: Prevent clearRecordingsDirectory from deleting an
in-progress recording by adding a guard in
TracksRecorderManager.clearRecordingsDirectory that checks whether recording is
currently active and skips clearing when it is. Use the existing recording state
and current outputURL/recording session tracking in TracksRecorderManager to
detect an active recording, and only enumerate/remove files from
recordingsDirectory when no recording is underway; keep the completion callback
behavior consistent for the skipped case.

In `@packages/react-native-sdk/src/hooks/useLoopbackRecording.ts`:
- Around line 247-255: Guard the native module accesses in useLoopbackRecording
by using optional chaining consistently for startTrackRecording,
clearStreamRecordings, and getStreamRecordings on StreamVideoReactNative,
matching the existing stopTrackRecording pattern. Wrap each native call in
try/catch inside the related callbacks so bridge rejections are handled
gracefully, and return safe fallbacks (or rethrow through your existing error
handling path) when the module is unavailable.
- Around line 175-185: Replace the two raw console.warn calls in
useLoopbackRecording with the existing scoped logger from
videoLoggerSystem.getLogger('useLoopbackRecording'). Keep the same early-return
behavior, but route both “already running” and “other participants present”
diagnostics through the hook’s logger so no unconditional console noise ships in
production. Use the same logger instance already used elsewhere in
useLoopbackRecording to keep all diagnostics consistent and env-gated.
- Around line 297-318: The `useLoopbackRecording` hook is still reading client
observables directly, so update it to use bindings-backed state instead. Replace
the `combineLatest` subscription in `useEffect` and the `waitForLoopbackStreams`
path to consume values from `useLocalParticipant`, `useCameraState`, and
`useMicrophoneState` instead of `call.state.localParticipant$`,
`call.camera.state.mediaStream$`, and `call.microphone.state.mediaStream$`. Keep
`getLoopbackStreamsFor` as the shared mapper, and if `waitForLoopbackStreams`
still needs async waiting, feed it hook-derived state or move the observable
bridge outside the RN hook state flow.

In
`@sample-apps/react-native/dogfood/src/screens/TestRecording/TestRecordingResultsScreen.tsx`:
- Around line 48-68: The button labels in TestRecordingResultsScreen are
hardcoded and should be localized; replace the visible strings for Share, Record
again, and Done with translation lookups from the app’s dictionary. Update the
relevant keys in the translation flow used by this screen (including the English
source in src/translations/en.json) and wire the screen to read those keys
instead of inline text so the Pressable labels remain localizable.

---

Nitpick comments:
In `@packages/react-native-sdk/ios/TracksRecorder/AudioPipeline.swift`:
- Around line 104-110: The audio callback path in
`AudioPipeline.handleAudioBuffer(pcmBuffer:)` is doing an unnecessary second
deep copy even though `RecorderAudioRenderTap` already provides a freshly
allocated and filled `AVAudioPCMBuffer`. Update the queue handoff to capture and
use that existing buffer directly instead of calling
`AudioPipeline.deepCopyPCMBuffer`, while preserving the lifetime guarantees
needed across the async boundary.

In
`@sample-apps/react-native/dogfood/src/screens/TestRecording/components/LoopbackPanel.tsx`:
- Around line 46-69: The active status indicator color in LoopbackPanel is
hardcoded, which is inconsistent with the rest of the component’s theme usage.
Update the dot styling in the badge/status rows to use an existing
appTheme.colors token for the “ready” green instead of the literal value, and
keep the Mic and Video indicator logic unchanged while applying the themed color
wherever the active state is rendered.

In
`@sample-apps/react-native/dogfood/src/screens/TestRecording/TestRecordingScreen.tsx`:
- Around line 42-43: The TestRecordingScreen debug global assignment is left
enabled in all builds, leaking the call instance via globalThis.call. Update the
logic around the globalThis.call assignment in TestRecordingScreen so it is
either removed entirely or wrapped in a __DEV__-only guard, and keep the
`@ts-expect-error` scoped only if the debug path remains necessary.

In `@sample-apps/react-native/dogfood/types.ts`:
- Around line 19-23: The route param shape is now exported as
TestRecordingStackParamList, but other files still duplicate the same definition
and can drift. Update the related React Native dogfood files to import and reuse
TestRecordingStackParamList from this module instead of redeclaring it, and keep
any screen/stack typings wired through that shared type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99ce0378-72bd-4976-b0ab-1af590c4fda2

📥 Commits

Reviewing files that changed from the base of the PR and between cbf1864 and d97d680.

⛔ Files ignored due to path filters (8)
  • packages/client/src/gen/google/protobuf/struct.ts is excluded by !**/gen/**
  • packages/client/src/gen/google/protobuf/timestamp.ts is excluded by !**/gen/**
  • packages/client/src/gen/video/sfu/event/events.ts is excluded by !**/gen/**
  • packages/client/src/gen/video/sfu/models/models.ts is excluded by !**/gen/**
  • packages/client/src/gen/video/sfu/signal_rpc/signal.client.ts is excluded by !**/gen/**
  • packages/client/src/gen/video/sfu/signal_rpc/signal.ts is excluded by !**/gen/**
  • sample-apps/react-native/dogfood/ios/Podfile.lock is excluded by !**/*.lock
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (37)
  • packages/client/src/Call.ts
  • packages/client/src/rtc/Publisher.ts
  • packages/client/src/rtc/Subscriber.ts
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt
  • packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt
  • packages/react-native-sdk/ios/StreamVideoReactNative-Bridging-Header.h
  • packages/react-native-sdk/ios/StreamVideoReactNative.m
  • packages/react-native-sdk/ios/TracksRecorder/AudioPipeline.swift
  • packages/react-native-sdk/ios/TracksRecorder/PipelineHost.swift
  • packages/react-native-sdk/ios/TracksRecorder/RecorderAudioRenderTap.swift
  • packages/react-native-sdk/ios/TracksRecorder/RecorderVideoSink.swift
  • packages/react-native-sdk/ios/TracksRecorder/TracksRecorderManager.swift
  • packages/react-native-sdk/ios/TracksRecorder/VideoPipeline.swift
  • packages/react-native-sdk/src/hooks/index.ts
  • packages/react-native-sdk/src/hooks/useLoopbackRecording.ts
  • packages/react-native-sdk/src/utils/internal/callingx/callingx.ts
  • sample-apps/react-native/dogfood/App.tsx
  • sample-apps/react-native/dogfood/package.json
  • sample-apps/react-native/dogfood/src/components/NavigationHeader.tsx
  • sample-apps/react-native/dogfood/src/contexts/AppContext.tsx
  • sample-apps/react-native/dogfood/src/navigators/TestRecording.tsx
  • sample-apps/react-native/dogfood/src/screens/ChooseAppModeScreen.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/TestRecordingResultsScreen.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/TestRecordingScreen.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/components/InlineCallStats.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/components/LoopbackPanel.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/components/PlaybackPanel.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/components/RecordingControls.tsx
  • sample-apps/react-native/dogfood/src/screens/TestRecording/components/index.ts
  • sample-apps/react-native/dogfood/src/translations/en.json
  • sample-apps/react-native/dogfood/types.ts

Comment thread packages/client/src/Call.ts Outdated
Comment thread packages/react-native-sdk/ios/TracksRecorder/RecorderAudioRenderTap.swift Outdated
Comment thread packages/react-native-sdk/src/hooks/useLoopbackRecording.ts
Comment thread packages/react-native-sdk/src/hooks/useLoopbackRecording.ts
Comment thread packages/react-native-sdk/src/hooks/useLoopbackRecording.ts Outdated
@greenfrvr greenfrvr changed the title feat: loopback subscription feat: pre-call testing Jun 24, 2026
@greenfrvr
greenfrvr merged commit 87cfaa4 into main Jun 24, 2026
21 of 22 checks passed
@greenfrvr
greenfrvr deleted the loopback-subscription branch June 24, 2026 15:21
@github-actions

Copy link
Copy Markdown

🎉 The changes from this pull request have been released.

Shipped with:

@coderabbitai coderabbitai Bot mentioned this pull request Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants