Skip to content

Move audio switching logic to execute on another thread#1648

Merged
aleksandar-apostolov merged 5 commits intodevelopfrom
improvement/rahullohra/make-audio-switch-off-main-thread-2
Apr 17, 2026
Merged

Move audio switching logic to execute on another thread#1648
aleksandar-apostolov merged 5 commits intodevelopfrom
improvement/rahullohra/make-audio-switch-off-main-thread-2

Conversation

@rahul-lohra
Copy link
Copy Markdown
Contributor

@rahul-lohra rahul-lohra commented Apr 16, 2026

Goal

  1. Move audio switching logic to execute on another thread
  2. Remove duplicate IPC calls

We were making 4 IPC calls when toggling the speaker

→ SpeakerManager.setSpeakerPhone(true)
  → MicrophoneManager.select(Speakerphone)
    → AudioSwitchHandler.selectDevice(Speakerphone)
        → audioSwitch.selectDevice()           ← triggers enumerateDevices()
            → since state=ACTIVATED: activate() ← IPC #1: clearCommunicationDevice()
                                                  IPC #2: setCommunicationDevice()
        → audioSwitch.activate()  [explicit]   ← IPC #3: clearCommunicationDevice()
                                                  IPC #4: setCommunicationDevice()

This was causing 50-60 framedrops

Implementation

We have changed any internal implementation

  1. We switch the audio switch work from main thread to a new thread audio-switch-thread
  2. Removed the duplicate IPC calls

We replaced HandlerThread with Couroutine backed by SingleThreadExecuter. Its lifecycle is cleaned up when StreamClient is cleaned up

We separated AudioSwitch core logic from concurrency. So we moved the core logic in AudioSwitchController and concurrency logic in AudioHandler

AudioExecutionContext - A new class to provide coroutine scope for AudioSwitch

🎨 UI Changes

None

Testing

Depends on PR: #1650, #1646

  1. Click on direct call
  2. select user to make audio/video call

@rahul-lohra rahul-lohra self-assigned this Apr 16, 2026
@rahul-lohra rahul-lohra added the pr:improvement Enhances an existing feature or code label Apr 16, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 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
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.02 MB 12.02 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.28 MB 6.28 MB 0.00 MB 🟢

@rahul-lohra rahul-lohra marked this pull request as ready for review April 16, 2026 13:47
@rahul-lohra rahul-lohra requested a review from a team as a code owner April 16, 2026 13:47
@rahul-lohra
Copy link
Copy Markdown
Contributor Author

@coderabbitai Analyse this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me gather the PR details and diff to conduct a thorough analysis.


🏁 Script executed:

#!/bin/bash
# Fetch PR details
gh pr view 1648 --repo GetStream/stream-video-android --json title,body,files,additions,deletions,changedFiles

echo "---FILES CHANGED---"
gh pr diff 1648 --repo GetStream/stream-video-android

Length of output: 15463


@rahul-lohra Here is a detailed analysis of PR #1648:


🔍 PR Analysis: Move Audio Switching Logic to Another Thread

✅ What's Good

Area Details
IPC deduplication The new isActivated guard in AudioSwitchController.selectDevice() correctly prevents the double clearCommunicationDevice() / setCommunicationDevice() that caused 50–60 frame drops
Thread separation All AudioSwitch ops now run on a named stream-audio-thread daemon thread, freeing the main thread from blocking IPC calls
Separation of concerns AudioSwitchController holds pure AudioSwitch logic; AudioSwitchHandler manages only concurrency dispatch — clean split
AtomicBoolean for setupCompleted Correct fix for the thread-safety gap in MicrophoneManager
Lifecycle cleanup audioExecutionContext.release() is called during StreamVideoClient.cleanup(), and dispatcher.close() is included

⚠️ Issues Found

1. Child scope is not a structural child of the parent — cancellation won't propagate

File: AudioExecutionContext.kt, createChildScope()

fun createChildScope(): CoroutineScope {
    return CoroutineScope(scope.coroutineContext + Job())  // ← new Job() has no parent
}

Job() without a parent argument is not linked to the SupervisorJob in scope. When release() calls scope.cancel(), it cancels the internal SupervisorJob — but this does not propagate to the child scope. The child scope's Job keeps running (or remains non-cancelled), and any subsequent scope.launch {} on the closed dispatcher would throw RejectedExecutionException (silently, since it's in a coroutine).

Fix: Make the child Job a structural child:

fun createChildScope(): CoroutineScope {
    return CoroutineScope(scope.coroutineContext + Job(scope.coroutineContext[Job]))
}

Or preferably, just use scope directly and cancel child jobs individually, or use CoroutineScope(SupervisorJob(scope.coroutineContext[Job]) + dispatcher).


2. scope in AudioSwitchHandler is not @Volatile and has no synchronization

File: AudioHandler.kt

private var scope: CoroutineScope? = null  // ← not `@Volatile`

private fun ensureScope(): CoroutineScope? {
    ...
    if (scope == null) {
        scope = ctx.createChildScope()  // ← write from arbitrary thread
    }
    return scope
}

start(), stop(), and selectDevice() are all public and can be called from any thread. ensureScope() has a check-then-act race on scope. Two threads calling start() simultaneously could create two child scopes, with one being lost (leaked).

Fix: Add @Volatile to scope, or synchronize ensureScope():

`@Volatile` private var scope: CoroutineScope? = null

3. After audioExecutionContext.release(), ensureScope() silently returns a cancelled scope

File: AudioHandler.kt

After StreamVideoClient.cleanup()audioExecutionContext.release()scope.cancel() + dispatcher.close(), if AudioSwitchHandler.stop() is called late (or start() is re-invoked), ensureScope() will:

  • Find scope != null (the old reference is still held)
  • Return the cancelled child scope
  • scope.launch {} on a cancelled scope is a no-op — the coroutine never runs

This is a silent failure with no log output, making it very hard to debug lifecycle ordering issues.

Fix: Reset scope = null in stop(), and/or add a null-return log in ensureScope():

override fun stop() {
    val scope = ensureScope() ?: return
    scope.launch {
        controller.stop()
        this.cancel() // cancel the child scope itself
    }
    this.scope = null
}

4. Service-locator pattern in ensureScope() — silent null failure

File: AudioHandler.kt

private fun ensureScope(): CoroutineScope? {
    val ctx = (StreamVideo.instanceOrNull() as? StreamVideoClient)
        ?.getAudioContext() ?: return null  // ← silent null, no log
    ...
}

The ?: return null means that if the StreamVideo singleton hasn't been initialized yet (e.g., in tests or early lifecycle), all audio operations silently no-op with no diagnostic output. Combined with the TODO comments noting this class should be internal, the public API is leaking these fragile internals.

Fix — at minimum, add logging:

val ctx = (StreamVideo.instanceOrNull() as? StreamVideoClient)
    ?.getAudioContext() ?: run {
        logger.w { "[ensureScope] StreamVideoClient not available, audio op skipped" }
        return null
    }

5. Child scope uses Job() not SupervisorJob() — a single failure cancels all audio ops

File: AudioExecutionContext.kt

return CoroutineScope(scope.coroutineContext + Job())

If controller.start() throws an uncaught exception, it will cancel the child scope's Job, meaning all subsequent stop() / selectDevice() calls on that scope will silently no-op.

Fix: Use SupervisorJob for the child scope so individual coroutine failures are isolated:

return CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job]))

💡 Minor Observations

  • getAudioContext() vs. direct property: StreamVideoClient exposes both audioExecutionContext (internal property) and getAudioContext() (internal function). One is redundant — consider removing the function and accessing the property directly (or vice versa).
  • Remaining TODOs: Two TODO comments mention making AudioHandler internal and returning Result<> — these are pre-existing but worth tracking.
  • isAudioSwitchInitScheduled was removed; the new guard if (audioSwitch != null) return in AudioSwitchController.start() is a clean equivalent.

🧩 Architectural Summary

graph TD
    A[StreamVideoClient] -->|owns| B[AudioExecutionContext]
    B -->|creates| C[stream-audio-thread Executor]
    B -->|exposes| D[createChildScope]
    E[AudioSwitchHandler] -->|fetches via singleton| B
    E -->|holds| F[child CoroutineScope]
    E -->|dispatches to| F
    F -->|runs on| C
    F -->|delegates to| G[AudioSwitchController]
    G -->|wraps| H[AudioSwitch / Twilio]

    style F stroke:`#f00`,stroke-width:2px
    note1["⚠️ Not structurally linked to parent Job"]
Loading

The main risk area (red) is the CoroutineScope created via createChildScope() — its lifecycle isn't properly bound to AudioExecutionContext's lifetime. The rest of the architecture is a solid improvement over the Handler-based approach.


🧠 Learnings used
Learnt from: rahul-lohra
Repo: GetStream/stream-video-android PR: 1647
File: stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioSwitchHandler.kt:57-60
Timestamp: 2026-04-16T11:30:09.543Z
Learning: In `stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioSwitchHandler.kt`, the `handler.removeCallbacksAndMessages(null)` calls in `start()` and `stop()` are intentional existing logic moved from the old per-instance Handler design. The Handler is now shared via `StreamVideoClient.getOrCreateAudioHandlerThread()`, but the team accepts this because only one `AudioSwitchHandler` instance is expected to be active per `StreamVideoClient` at any time.

Learnt from: CR
Repo: GetStream/stream-video-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-19T09:15:37.269Z
Learning: Applies to **/*.{kt,kts} : Pause/resume capture on lifecycle changes; ensure background audio routing is intentional

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

Walkthrough

The changes migrate audio handling from Android main-thread Handler/Looper callbacks to coroutine-based execution. A new AudioExecutionContext manages a dedicated executor dispatcher and child scopes. AudioSwitchController encapsulates AudioSwitch lifecycle management, while MicrophoneManager adopts AtomicBoolean for thread-safe setup state tracking.

Changes

Cohort / File(s) Summary
Atomic Synchronization
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt
Changed setupCompleted flag from non-atomic Boolean to AtomicBoolean with corresponding .get() and .set() calls for thread-safe state management.
Audio Execution Context
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioExecutionContext.kt, stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt
Introduced new AudioExecutionContext class managing a single-thread executor dispatcher and coroutine scope hierarchy. StreamVideoClient instantiates and owns this context, exposing it via getAudioContext() and releasing it during cleanup.
Audio Handler Refactoring
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt, stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioSwitchController.kt
Replaced Handler/Looper-based execution with coroutine-based flow via new AudioSwitchController. Handler now acquires child scopes from client's audio context and launches async tasks. Controller encapsulates AudioSwitch lifecycle and device selection with lazy activation on first selection. Adjusted visibility of focus listener components and selectDevice method.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hops of joy through coroutine flows,
Thread-safe atoms in a row,
AudioSwitch finds its way,
From Handler's old to Scope's new day!
Execution context, clean and bright,
Audio magic—oh, what a sight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly captures the primary change: moving audio switching logic to execute on a separate thread, which aligns with the main objective to improve performance.
Description check ✅ Passed The pull request description covers the goal, implementation approach, and basic testing information despite leaving testing section mostly empty.

✏️ 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 improvement/rahullohra/make-audio-switch-off-main-thread-2

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
Copy Markdown

@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: 1

🧹 Nitpick comments (4)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt (3)

29-29: TODO comments flagged by static analysis.

These TODO comments indicate planned refactoring work. Consider creating tracking issues to ensure they're addressed:

  • Line 29: Make AudioHandler internal with Result<> return types
  • Line 43: Merge AudioSwitchHandler into MicrophoneManager

Would you like me to help draft GitHub issues to track these technical debt items?

Also applies to: 43-43

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt`
at line 29, The TODOs indicate technical debt: make the AudioHandler class
internal and change its public APIs to return Result<> instead of throwing/using
nullable returns, so update the AudioHandler declaration to internal and
refactor its public methods (e.g., start/stop/prepare functions in AudioHandler)
to return Result<T> wrapping success/error; additionally merge the
AudioSwitchHandler functionality into MicrophoneManager by moving/combining
methods and state from AudioSwitchHandler into MicrophoneManager and removing
the separate AudioSwitchHandler type, ensuring callers now use MicrophoneManager
for device switching and preserving existing behavior and error propagation via
Result<>.

69-82: Fire-and-forget coroutine launches may complicate cleanup sequencing.

The start(), stop(), and selectDevice() methods use scope.launch without awaiting completion. For stop() specifically, callers like MicrophoneManager.cleanup() cannot guarantee the audio switch is fully stopped before proceeding.

Given the single-threaded dispatcher and the PR's goal of eliminating frame drops, this trade-off is reasonable. However, if deterministic cleanup becomes necessary in the future, consider returning a Job or providing a suspending variant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt`
around lines 69 - 82, The methods start(), stop(), and selectDevice() currently
fire-and-forget with scope.launch which prevents callers (e.g.,
MicrophoneManager.cleanup()) from deterministically waiting for
controller.start()/controller.stop()/controller.selectDevice() to finish; change
these APIs to either return the Job from scope.launch (e.g., return scope.launch
{ controller.stop() }) or provide suspending variants (suspend fun stop() {
ensureScope()?.let { it.launch... } or directly call controller.stop() if
already suspending) so callers can await completion; keep ensureScope() and
controller.* calls intact and update call sites like MicrophoneManager.cleanup()
to await the returned Job or call the suspending variant.

58-67: Silent no-op when StreamVideo instance is unavailable.

If StreamVideo.instanceOrNull() returns null or isn't a StreamVideoClient, ensureScope() returns null and start()/stop()/selectDevice() silently do nothing. This could make debugging difficult if audio issues arise due to incorrect SDK initialization order.

Consider adding a log warning when the instance is unavailable:

💡 Add logging for debugging
 private fun ensureScope(): CoroutineScope? {
     val ctx = (StreamVideo.instanceOrNull() as? StreamVideoClient)
-        ?.getAudioContext() ?: return null
+        ?.getAudioContext()
+    
+    if (ctx == null) {
+        StreamLog.w(TAG) { "[ensureScope] StreamVideoClient not available, audio operations will be skipped" }
+        return null
+    }

     if (scope == null) {
         scope = ctx.createChildScope()
     }

     return scope
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt`
around lines 58 - 67, ensureScope() currently returns null when
StreamVideo.instanceOrNull() is missing or not a StreamVideoClient, causing
start()/stop()/selectDevice() to silently no-op; update ensureScope (in class
AudioHandler) to emit a clear warning log (e.g., Log.w or your existing logger)
right before returning null that states StreamVideo is unavailable or not a
StreamVideoClient and that audio operations will be skipped, and also log when
creating the child scope (when ctx.createChildScope() runs) so initialization
failures are visible.
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioExecutionContext.kt (1)

43-46: Potential crash if release() is called before lazy properties are accessed.

If release() is called when neither scope nor dispatcher have been initialized (e.g., early cleanup path), scope.cancel() will force lazy initialization, then immediately cancel. This is wasteful but not harmful. However, calling dispatcher.close() on a just-initialized dispatcher that was never used is safe.

More importantly, after release() is called, subsequent calls to createChildScope() will throw IllegalStateException because the dispatcher is closed. Consider adding a guard or documenting this constraint.

💡 Optional: Add closed state tracking
+private var released = false
+
 fun createChildScope(): CoroutineScope {
+    check(!released) { "AudioExecutionContext has been released" }
     return CoroutineScope(scope.coroutineContext + Job(scope.coroutineContext[Job]))
 }

 fun release() {
+    if (released) return
+    released = true
     scope.cancel()
     dispatcher.close()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioExecutionContext.kt`
around lines 43 - 46, Add a closed/released flag and guard lazy initialization:
introduce a private var released = false, set released = true at the start of
release(), and only cancel/close scope and dispatcher if they are already
initialized (avoid forcing lazy init — convert the lazy delegates to checkable
forms or use nullable/backing properties so you can test initialization before
accessing). Also update createChildScope() to check released and throw a clear
IllegalStateException (or return a no-op/closed scope) when released is true to
avoid post-release usage. Ensure references include release(), scope,
dispatcher, and createChildScope().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioExecutionContext.kt`:
- Around line 39-41: createChildScope() currently builds a standalone Job() so
children aren't cancelled when release() cancels the parent scope; change it to
create a Job that is a child of the existing scope's job (e.g., construct the
child Job with the parent Job from scope.coroutineContext or use
scope.coroutineContext.plus(Job(parentJob))) so the CoroutineScope returned by
createChildScope() inherits the parent's lifecycle and will be cancelled when
scope is cancelled; update createChildScope() to obtain the parent Job from
scope.coroutineContext and pass it as the parent to the new Job.

---

Nitpick comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioExecutionContext.kt`:
- Around line 43-46: Add a closed/released flag and guard lazy initialization:
introduce a private var released = false, set released = true at the start of
release(), and only cancel/close scope and dispatcher if they are already
initialized (avoid forcing lazy init — convert the lazy delegates to checkable
forms or use nullable/backing properties so you can test initialization before
accessing). Also update createChildScope() to check released and throw a clear
IllegalStateException (or return a no-op/closed scope) when released is true to
avoid post-release usage. Ensure references include release(), scope,
dispatcher, and createChildScope().

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt`:
- Line 29: The TODOs indicate technical debt: make the AudioHandler class
internal and change its public APIs to return Result<> instead of throwing/using
nullable returns, so update the AudioHandler declaration to internal and
refactor its public methods (e.g., start/stop/prepare functions in AudioHandler)
to return Result<T> wrapping success/error; additionally merge the
AudioSwitchHandler functionality into MicrophoneManager by moving/combining
methods and state from AudioSwitchHandler into MicrophoneManager and removing
the separate AudioSwitchHandler type, ensuring callers now use MicrophoneManager
for device switching and preserving existing behavior and error propagation via
Result<>.
- Around line 69-82: The methods start(), stop(), and selectDevice() currently
fire-and-forget with scope.launch which prevents callers (e.g.,
MicrophoneManager.cleanup()) from deterministically waiting for
controller.start()/controller.stop()/controller.selectDevice() to finish; change
these APIs to either return the Job from scope.launch (e.g., return scope.launch
{ controller.stop() }) or provide suspending variants (suspend fun stop() {
ensureScope()?.let { it.launch... } or directly call controller.stop() if
already suspending) so callers can await completion; keep ensureScope() and
controller.* calls intact and update call sites like MicrophoneManager.cleanup()
to await the returned Job or call the suspending variant.
- Around line 58-67: ensureScope() currently returns null when
StreamVideo.instanceOrNull() is missing or not a StreamVideoClient, causing
start()/stop()/selectDevice() to silently no-op; update ensureScope (in class
AudioHandler) to emit a clear warning log (e.g., Log.w or your existing logger)
right before returning null that states StreamVideo is unavailable or not a
StreamVideoClient and that audio operations will be skipped, and also log when
creating the child scope (when ctx.createChildScope() runs) so initialization
failures are visible.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c246058c-767a-4343-aa77-fb5f5a4c49c8

📥 Commits

Reviewing files that changed from the base of the PR and between 14e119a and 90d398e.

📒 Files selected for processing (5)
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioExecutionContext.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioSwitchController.kt

@rahul-lohra rahul-lohra changed the title Move audio switching logic to execute on another thread [AND-1149] Move audio switching logic to execute on another thread Apr 16, 2026
@rahul-lohra rahul-lohra changed the title [AND-1149] Move audio switching logic to execute on another thread Move audio switching logic to execute on another thread Apr 16, 2026
@sonarqubecloud
Copy link
Copy Markdown

@aleksandar-apostolov aleksandar-apostolov merged commit 7e360ce into develop Apr 17, 2026
12 checks passed
@aleksandar-apostolov aleksandar-apostolov deleted the improvement/rahullohra/make-audio-switch-off-main-thread-2 branch April 17, 2026 14:45
@stream-public-bot stream-public-bot added the released Included in a release label Apr 23, 2026
@stream-public-bot
Copy link
Copy Markdown
Collaborator

🚀 Available in v1.22.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:improvement Enhances an existing feature or code released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants