Skip to content

Comments

Add support for individual, raw, and composite call recordings#1610

Merged
aleksandar-apostolov merged 9 commits intodevelopfrom
feature/rahullohra/raw-recording
Feb 11, 2026
Merged

Add support for individual, raw, and composite call recordings#1610
aleksandar-apostolov merged 9 commits intodevelopfrom
feature/rahullohra/raw-recording

Conversation

@rahul-lohra
Copy link
Contributor

@rahul-lohra rahul-lohra commented Feb 6, 2026

Goal

Add support for individual, raw, and composite call recordings

Implementation

New APIs

Class Function/Member Type Description
CallState individualRecording StateFlow Emits true while individual recording is active for the call
CallState rawRecording StateFlow Emits true while raw (RTP dump) recording is active for the call
CallState compositeRecording StateFlow (the existing default 'recording'): Combines all participants into a single file using a layout
Class Function
Call startRecording(recordingType: RecordingType)
Call stopRecording(recordingType: RecordingType)

New Public Enum
RecordingType Model

sealed class RecordingType(val value: String) {
    object Composite : RecordingType("composite")
    object Individual : RecordingType("individual")
    object Raw : RecordingType("raw")
}

🎨 UI Changes

Before After
- image
Screen_Recording_20260206_184817_Stream.Video.Calls.Development.mp4

Testing

  1. Join a Call
  2. Open Settings options
  3. Select Start/Stop Recording to open submenu
  4. Select type of recording raw, individual or composite
  5. Observe recording in progress in UI and we get a CallRecordingStartedEvent
  6. Stop recording -> UI should remove Recording Text from App bar and you should receive a CallRecordingStoppedEvent

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced recording controls with support for multiple recording types: Raw, Individual, and Composite.
    • Improved recording UI in settings menu with type-specific start/stop options.
    • Added recording state tracking and warning dialog visibility based on active recording status.
  • Chores

    • Updated code generation exclusions for event classes.

Core changes

  1. io.getstream.video.android.compose.ui.components.call.CallAppBar - To correctly render Recording Label
  2. ProductvideoApi - Api Interface
  3. io.getstream.video.android.core.recording.RecordingType - Introduced RecordingType which can be used by Call Class or UI classes
  4. Call - Only params changes
  5. CallState -
    1. Updated CallRecordingStartedEvent & CallRecordingStoppedEvent logic
    2. Introduced individualRecording & rawRecording StateFlows to align with React implementation - Link
  6. StreamVideoClient - Only params changes

Demo app changes

  1. CallScreen - To match existing behavior of isRecording
  2. MenuDefinitions & SettingsMenu - To add menu option for raw, individual and composite recordings
  3. DynamicMenu - Made changes to correctly update sub-menus composeable as previously it was not updating unless I reopen the submenu

@rahul-lohra rahul-lohra self-assigned this Feb 6, 2026
@rahul-lohra rahul-lohra added the pr:new-feature Adds new functionality label Feb 6, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled.

🎉 Great job! This PR is ready for review.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 11.96 MB 12.00 MB 0.05 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.28 MB 6.27 MB -0.02 MB 🚀

@rahul-lohra rahul-lohra force-pushed the feature/rahullohra/raw-recording branch 3 times, most recently from c97b424 to 4542064 Compare February 6, 2026 13:19
@rahul-lohra rahul-lohra force-pushed the feature/rahullohra/raw-recording branch from 4542064 to a1fd973 Compare February 6, 2026 13:37
@rahul-lohra rahul-lohra marked this pull request as ready for review February 6, 2026 13:38
@rahul-lohra rahul-lohra requested a review from a team as a code owner February 6, 2026 13:38
@rahul-lohra rahul-lohra changed the title [AND-1028] Add support for individual, raw, and composite call recordings Add support for individual, raw, and composite call recordings Feb 6, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

Walkthrough

This PR adds support for multiple call recording types (individual, raw, composite) in the Android Video SDK demo app. It introduces state tracking for each recording type, UI controls for selecting recording types in the menu system, refactors the dynamic menu navigation, and updates code generation configuration to skip specific event classes.

Changes

Cohort / File(s) Summary
Recording State Management
demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt, demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt
Added lifecycle-aware state tracking for composite, raw, and individual recording types; introduced derived isRecording state that combines all three; integrated recording type selection handler with start/stop recording logic via coroutine scope.
Recording UI Controls
demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt
Introduced recordingTypeMenu function with three recording type options (Raw, Individual, Composite); threaded selectedRecordingTypes and onSelectRecordingType parameters through defaultStreamMenu and debugSubmenu; replaced single recording action with submenu-based type selection.
Dynamic Menu Refactoring
demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt
Replaced single history list with separate historyTitles tracking and dynamicMenuRef reference; added isDynamic flag and findSubMenuItem helper for improved submenu navigation and specialized handling of dynamic submenus.
Code Generation Configuration
generate_openapi_v2.sh, scripts/open-api-code-gen.gradle.kts
Extended CLASSES_TO_SKIP list to include LocalCallAcceptedPostEvent and LocalCallRejectedPostEvent in both shell script and Gradle configuration.

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as Recording Menu UI
    participant Handler as onSelectRecordingType Handler
    participant Call as Call State/API
    participant State as Recording State
    
    User->>UI: Select Recording Type (Raw/Individual/Composite)
    activate UI
    UI->>Handler: onSelectRecordingType(RecordingType)
    deactivate UI
    
    activate Handler
    alt Recording Type is Disabled
        Handler->>Call: startRecording(type)
    else Recording Type is Enabled
        Handler->>Call: stopRecording(type)
    end
    deactivate Handler
    
    activate Call
    Call->>State: Update compositeRecording/rawRecording/individualRecording
    deactivate Call
    
    activate State
    State->>UI: derivedStateOf updates isRecording
    deactivate State
    
    activate UI
    UI->>UI: Refresh UI with new recording state
    deactivate UI
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

pr:improvement

Poem

🐰 Three ways to record, now at your behest,
Raw, individual, composite—the very best!
State flows through menus with elegant grace,
Each recording type has its rightful place. ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive Changes to generate_openapi_v2.sh and open-api-code-gen.gradle.kts add LocalCallAcceptedPostEvent and LocalCallRejectedPostEvent to skip lists, which appears unrelated to recording type feature requirements. Clarify whether the LocalCallAcceptedPostEvent and LocalCallRejectedPostEvent additions are intentional or inadvertently included in this PR.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding support for three recording types (individual, raw, composite).
Linked Issues check ✅ Passed Code changes fully implement AND-1028 requirements: new RecordingType enum, startRecording/stopRecording with type parameters, observable recording states (compositeRecording, rawRecording, individualRecording), and UI menu for type selection.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all major template sections including Goal, Implementation, UI Changes with screenshots/videos, Testing steps, and Core changes.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/rahullohra/raw-recording

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt (1)

134-151: ⚠️ Potential issue | 🔴 Critical

Bug: stale dynamicItems shown when navigating forward into a second DynamicSubMenuItem.

When the user navigates from one dynamic submenu into another (e.g., clicks a DynamicSubMenuItem inside already-loaded dynamic items), dynamicMenuRef and historyTitles are updated (lines 147-150), but loadedItems remains true from the previous load. On the next recomposition the if (!loadedItems) guard on line 138 is skipped, so the stale dynamicItems from the previous dynamic menu are rendered instead of triggering a fresh load.

Reset loadedItems (and clear dynamicItems) when navigating forward into a new dynamic submenu:

Proposed fix
                     if (dynamicItems.isNotEmpty()) {
                         menuItems(dynamicItems) {
                             if (it is DynamicSubMenuItem) {
                                 dynamicMenuRef.value = it
                             }
+                            loadedItems = false
+                            dynamicItems.clear()
                             historyTitles.add(it.title)
                         }
demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt (1)

827-847: ⚠️ Potential issue | 🟡 Minor

Remove the unused "End recording" dialog—it's dead code and its functionality is already handled by SettingsMenu with proper per-type controls.

showEndRecordingDialog is never set to true anywhere in the codebase (only initialized and reset to false), making this dialog unreachable. Additionally, if triggered, call.stopRecording() would only stop the Composite recording since it defaults to RecordingType.Composite. The SettingsMenu already provides the correct solution: it tracks individual, raw, and composite recording states separately and calls call.stopRecording(recordingType) with the specific type when stopping any recording.

🤖 Fix all issues with AI agents
In
`@demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt`:
- Around line 71-75: The single-slot dynamicMenuRef cannot track nested dynamic
submenus and gets overwritten; replace it with a small keyed stack or map (e.g.,
a mutableStateMapOf<String, DynamicSubMenuItem> or a MutableList stack) keyed by
submenu title so each DynamicSubMenuItem is preserved per title; update lookup
logic that uses dynamicMenuRef (references in dynamicMenuRef,
DynamicSubMenuItem, currentTitle, isDynamic, and findSubMenuItem) to read/write
from the map/stack (push on enter, pop on back, and lookup by currentTitle) so
nested dynamic menus resolve correctly and prior dynamic state isn’t lost.
- Around line 113-118: The bug is that loadedItems is only reset on back
navigation (inside the IconButton click handler) but not on forward navigation,
causing stale items; update the logic so loadedItems is reset whenever the
submenu changes—either by calling dynamicMenuRef.value?.onNewSubmenu(...) (or
the existing onNewSubmenu callback) to perform loadedItems = false for all
transitions, or derive loadedItems from currentTitle so it is recomputed on
every title change; locate references to loadedItems, the IconButton back
handler, the forward-navigation where historyTitles is appended, and the
onNewSubmenu callback (or currentTitle) and move the reset there to centralize
behavior.
🧹 Nitpick comments (4)
demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt (1)

174-182: findSubMenuItem relies on unique titles across the entire menu tree.

If two SubMenuItems at different nesting levels share the same title, this depth-first search returns the first match, which may not be the intended submenu. This is fine for the current demo app menu structure, but worth noting as a fragility if menus grow or titles are localized identically.

demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt (1)

283-332: Use listOf instead of arrayListOf — the returned list doesn't need to be mutable.

arrayListOf allocates a mutable ArrayList that is never mutated. The return type is List<MenuItem>, so listOf is more idiomatic and communicates immutability.

Also, the repeated selectedRecordingTypes.contains(…) calls per item can be simplified with local booleans.

♻️ Suggested simplification
 fun recordingTypeMenu(onSelectRecording: (RecordingType) -> Unit, selectedRecordingTypes: Set<RecordingType>): List<MenuItem> {
-    return arrayListOf(
-        ActionMenuItem(
-            title = if (selectedRecordingTypes.contains(
-                    RecordingType.Raw,
-                )
-            ) {
-                "Stop raw recording"
-            } else {
-                "Start raw recording"
-            },
-            icon = if (selectedRecordingTypes.contains(
-                    RecordingType.Raw,
-                )
-            ) {
-                Icons.Default.RawOn
-            } else {
-                Icons.Default.RawOff
-            },
-            highlight = selectedRecordingTypes.contains(RecordingType.Raw),
-            action = { onSelectRecording(RecordingType.Raw) },
-        ),
-        ActionMenuItem(
-            title = if (selectedRecordingTypes.contains(
-                    RecordingType.Individual,
-                )
-            ) {
-                "Stop individual recording"
-            } else {
-                "Start individual recording"
-            },
-            icon = Icons.Default.Person,
-            highlight = selectedRecordingTypes.contains(RecordingType.Individual),
-            action = { onSelectRecording(RecordingType.Individual) },
-        ),
-        ActionMenuItem(
-            title = if (selectedRecordingTypes.contains(
-                    RecordingType.Composite,
-                )
-            ) {
-                "Stop composite recording"
-            } else {
-                "Start composite recording"
-            },
-            icon = Icons.Default.CropFree,
-            highlight = selectedRecordingTypes.contains(RecordingType.Composite),
-            action = { onSelectRecording(RecordingType.Composite) },
-        ),
-    )
+    val isRaw = RecordingType.Raw in selectedRecordingTypes
+    val isIndividual = RecordingType.Individual in selectedRecordingTypes
+    val isComposite = RecordingType.Composite in selectedRecordingTypes
+
+    return listOf(
+        ActionMenuItem(
+            title = if (isRaw) "Stop raw recording" else "Start raw recording",
+            icon = if (isRaw) Icons.Default.RawOn else Icons.Default.RawOff,
+            highlight = isRaw,
+            action = { onSelectRecording(RecordingType.Raw) },
+        ),
+        ActionMenuItem(
+            title = if (isIndividual) "Stop individual recording" else "Start individual recording",
+            icon = Icons.Default.Person,
+            highlight = isIndividual,
+            action = { onSelectRecording(RecordingType.Individual) },
+        ),
+        ActionMenuItem(
+            title = if (isComposite) "Stop composite recording" else "Start composite recording",
+            icon = Icons.Default.CropFree,
+            highlight = isComposite,
+            action = { onSelectRecording(RecordingType.Composite) },
+        ),
+    )
 }
demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt (1)

265-277: The when is redundant — all branches perform the same action.

Every sealed subclass of RecordingType is collapsed into a single branch, so the when adds no value. A simple if/else on the set membership is sufficient. If the intent is to get a compile error on future RecordingType additions, the when should be exhaustive (e.g., drop the combined branch and handle each case separately) — but since they all do the same thing, that won't help either.

Also, consider surfacing the Result from startRecording/stopRecording to show a toast on failure, consistent with how other debug actions in this file show Toast messages.

♻️ Simplified handler
     val onSelectRecordingType = { recordingType: RecordingType ->
         scope.launch {
-            when (recordingType) {
-                RecordingType.Raw, RecordingType.Individual, RecordingType.Composite ->
-                    if (enabledRecordingTypes.contains(recordingType)) {
-                        call.stopRecording(recordingType)
-                    } else {
-                        call.startRecording(recordingType)
-                    }
+            if (recordingType in enabledRecordingTypes) {
+                call.stopRecording(recordingType)
+            } else {
+                call.startRecording(recordingType)
             }
         }
         Unit
     }
demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt (1)

796-804: Simplify isRecording — building a Set just to check emptiness is unnecessary overhead.

Since you only need a boolean, a simple disjunction avoids the Set allocation on every derivation pass.

♻️ Simplified derivation
         val isRecording by remember {
             derivedStateOf {
-                buildSet {
-                    if (compositeRecording) add(RecordingType.Composite)
-                    if (individualRecording) add(RecordingType.Individual)
-                    if (rawRecording) add(RecordingType.Raw)
-                }.isNotEmpty()
+                compositeRecording || individualRecording || rawRecording
             }
         }

Note: this buildSet pattern is also duplicated in SettingsMenu.kt (lines 256-264). Consider extracting a shared helper if both need the same Set<RecordingType> representation, or simplify both to plain boolean logic where only a boolean is needed.

@aleksandar-apostolov
Copy link
Contributor

aleksandar-apostolov commented Feb 10, 2026

The change to startRecording(recordingType) with a default parameter breaks binary compatibility — Java apps compiled against the old SDK will crash with NoSuchMethodError. Keep the original no-arg methods and have them delegate to the new ones:

suspend fun startRecording(): Result<Any> = startRecording(RecordingType.Composite)
suspend fun stopRecording(): Result<Any> = stopRecording(RecordingType.Composite)

suspend fun startRecording(recordingType: RecordingType): Result<Any> { ... }
suspend fun stopRecording(recordingType: RecordingType): Result<Any> { ... }

Copy link
Contributor

@aleksandar-apostolov aleksandar-apostolov left a comment

Choose a reason for hiding this comment

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

Recording indicator is a privacy concern — users must know when ANY recording is active. _recording.value should be set to true for all recording types, not just Composite. The specific flows (individualRecording, rawRecording) track which type, but recording must be true whenever any recording is happening.

CallState.kt line ~940:

is CallRecordingStartedEvent -> {
    _recording.value = true  // Always true for privacy - any recording active
    when (event.recordingType) {
        Individual -> _individualRecording.value = true
        Raw -> _rawRecording.value = true
        else -> {}
    }
}

@aleksandar-apostolov
Copy link
Contributor

aleksandar-apostolov commented Feb 10, 2026

To maintain backward compatibility and ensure privacy (users must always know when ANY recording is active), the existing recording StateFlow should remain as "is any recording active" indicator. Instead, add a new compositeRecording StateFlow for composite-specific tracking:

// Existing - keep as "any recording active" for backward compatibility & privacy
val recording: StateFlow<Boolean>  // true when ANY recording type is active

// New additions
val compositeRecording: StateFlow<Boolean>
val individualRecording: StateFlow<Boolean>
val rawRecording: StateFlow<Boolean>

This way apps using recording won't break, and the recording indicator always shows when users are being recorded.

rahul-lohra and others added 7 commits February 11, 2026 14:43
Previously, stopping any recording type would set recording=false even
if other recording types were still active. Now recording remains true
as long as any recording type (composite, individual, or raw) is active.
Copy link
Contributor

@aleksandar-apostolov aleksandar-apostolov left a comment

Choose a reason for hiding this comment

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

LGTM.

The 'test recording types are independent' test was leaving
individualRecording and rawRecording as true, which polluted
subsequent tests that share the same call object.
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
69.0% Coverage on New Code (required ≥ 80%)
11.3% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@aleksandar-apostolov aleksandar-apostolov merged commit 46d5363 into develop Feb 11, 2026
11 of 12 checks passed
@aleksandar-apostolov aleksandar-apostolov deleted the feature/rahullohra/raw-recording branch February 11, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:new-feature Adds new functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants