Skip to content

[Fix] Recover microphone after an interruption ends while backgrounded#1174

Open
ipavlidakis wants to merge 3 commits into
developfrom
iliaspavlidakis/ios-1773-gh-issueaudio-does-not-recover-after-avaudiosession
Open

[Fix] Recover microphone after an interruption ends while backgrounded#1174
ipavlidakis wants to merge 3 commits into
developfrom
iliaspavlidakis/ios-1773-gh-issueaudio-does-not-recover-after-avaudiosession

Conversation

@ipavlidakis

@ipavlidakis ipavlidakis commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

🔗 Issue Links

Resolves https://linear.app/stream/issue/IOS-1773/gh-issueaudio-does-not-recover-after-avaudiosession-interruption-in

🎯 Goal

In Audio Rooms (and any call), the microphone could go permanently silent
after the app spent time in the background. The participant appears unmuted and
the SDK reports isRecording: true, but no audio is captured — and only
leaving and rejoining restores it.

Confirmed from a customer log: an AVAudioSession interruption ends while the
app is still backgrounded
(~2s before it returns to the foreground).
InterruptionsEffect runs its recording restart at that moment, but WebRTC can
only (re)start the input audio unit while the app is active, so the restart is a
no-op (IsInputEnabled: 0 / IsInputRunning: 0). When the app later becomes
active, WebRTC's own handleApplicationDidBecomeActive finds nothing to resume,
and nothing replays the restart — leaving the mic recording silence.

📝 Summary

  • Replays the recording restart on the next foreground transition when an
    interruption ended while the app was not active, so the microphone recovers
    without leaving the call.
  • Keeps the behaviour inside the existing InterruptionsEffect (no new type).
  • Adds unit coverage for the foreground-recovery paths plus a concurrency
    stress test.

🛠 Implementation

  • InterruptionsEffect now also observes applicationStateAdapter.statePublisher.
    When didEndInterruption is handled while applicationStateAdapter.state is
    not .foreground, it sets a pendingForegroundRecovery flag.
  • On the next .foreground transition, if the store is still isActive and
    isRecording, it dispatches the recording restart
    (setRecording(false)setRecording(true) → re-apply mute) — now against
    the reactivated session.
  • Both triggers (the RTCAudioSessionPublisher events and the app-lifecycle
    publisher) are funneled through a single serial OperationQueue
    (processingQueue), so pendingForegroundRecovery is always read/written
    from one serial context — no extra locking needed.

🎨 Showcase

N/A — no UI changes.

Before After
Mic stays silent after backgrounding until leave/rejoin Mic recovers automatically on returning to the foreground

🧪 Manual Testing Notes

  1. Join an Audio Room as a speaker (or any call while publishing audio).
  2. Background the app and play audio in another app (music/podcast/video),
    then stop it — or simply leave the app backgrounded for several minutes.
  3. Return to the foreground and confirm:
    • Other participants can hear you again without toggling mute.
    • The speaking indicator lights up.
    • WebRTC reports IsInputRunning: 1 with no activeSessionIdentifier
      change (i.e. no implicit leave/rejoin).
  4. Regression check: background ⇄ foreground repeatedly during a call and
    confirm the mic keeps working and is not stopped by the transition.

Automated: RTCAudioStore_InterruptionsEffectTests covers the recover /
no-recover paths and a concurrent interruption + app-state storm
(test_concurrentInterruptionAndForegroundStorm_doesNotCrashAndStaysResponsive).

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change follows zero ⚠️ policy (required)
  • This change should receive manual QA
  • Changelog is updated with client-facing changes
  • New code is covered by unit tests
  • Comparison screenshots added for visual changes
  • Affected documentation updated (tutorial, CMS)

Summary by CodeRabbit

  • Bug Fixes
    • Fixed microphone recovery after an audio-session interruption ends while the app is backgrounded; the mic now resumes when you return to the foreground.
    • Improved interruption handling so the recording restart is deferred until the next valid foreground state.
    • Enhanced reliability during rapid foreground/background transitions to prevent incorrect recovery behavior.

@ipavlidakis ipavlidakis self-assigned this Jun 24, 2026
@ipavlidakis ipavlidakis requested a review from a team as a code owner June 24, 2026 15:09
@ipavlidakis ipavlidakis added the bug Something isn't working label Jun 24, 2026
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1bc7be9a-3935-4057-9d62-b4e1794100b2

📥 Commits

Reviewing files that changed from the base of the PR and between cf71384 and 9a795c7.

📒 Files selected for processing (1)
  • CHANGELOG.md
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

RTCAudioStore.InterruptionsEffect now defers interruption recovery while the app is backgrounded and replays it when the app returns to the foreground. Tests were expanded for foreground recovery, negative cases, and concurrency. CHANGELOG.md and the demo scheme were also updated.

Changes

Foreground Microphone Recovery

Layer / File(s) Summary
InterruptionsEffect recovery flow
Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore+InterruptionsEffect.swift
Adds Combine import, applicationStateAdapter injection, a serial processingQueue, and pendingForegroundRecovery state. Interruption handling now defers restart while backgrounded, and foreground transitions trigger recoverPendingForegroundInterruption() to validate state and dispatch the restart sequence.
Tests, changelog, and scheme setting
StreamVideoTests/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore_InterruptionsEffectTests.swift, CHANGELOG.md, StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme
Adds shared app-state test wiring, foreground recovery coverage, non-recovery cases, and a concurrency stress test. Also updates the changelog entry and adds queueDebuggingEnabled = "No" to the demo launch action.

Sequence Diagram

sequenceDiagram
  participant AVFoundation
  participant InterruptionsEffect
  participant processingQueue
  participant applicationStateAdapter
  participant dispatcher

  AVFoundation->>InterruptionsEffect: interruption ended while backgrounded
  InterruptionsEffect->>processingQueue: deliver handle(_:)
  processingQueue->>InterruptionsEffect: set pendingForegroundRecovery = true

  applicationStateAdapter->>InterruptionsEffect: statePublisher emits .foreground
  InterruptionsEffect->>processingQueue: recoverPendingForegroundInterruption()
  processingQueue->>InterruptionsEffect: validate stateProvider
  InterruptionsEffect->>dispatcher: dispatch setRecording(false)
  InterruptionsEffect->>dispatcher: dispatch setRecording(true)
  InterruptionsEffect->>dispatcher: dispatch setMicrophoneMuted(true)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • martinmitrevski

Poem

🐰 The mic went quiet, then came back,
With foreground hops along the track.
A tiny flag said “wait, don’t race,”
Then audio bounced to its rightful place.
Hop-hop hooray, the voice is free!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% 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 Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: microphone recovery after an interruption ends while the app is backgrounded.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch iliaspavlidakis/ios-1773-gh-issueaudio-does-not-recover-after-avaudiosession

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 (1)
Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore+InterruptionsEffect.swift (1)

80-103: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Only arm foreground recovery when a recording restart was actually deferred.

pendingForegroundRecovery is currently set for any backgrounded interruption end, including shouldResumeSession == false and cases where no restart was attempted. On the next .foreground, recoverPendingForegroundInterruption() can still dispatch .setRecording(false/true) because it only checks isActive/isRecording, so a non-resumable interruption can unexpectedly restart capture.

Track a needsForegroundRecovery flag inside the same branch that appends the restart actions, and only set pendingForegroundRecovery when that restart was both needed and blocked by background state.

Also applies to: 118-133

🤖 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
`@Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore`+InterruptionsEffect.swift
around lines 80 - 103, `pendingForegroundRecovery` is being armed too broadly in
`RTCAudioStore+InterruptionsEffect`, which can cause
`recoverPendingForegroundInterruption()` to restart recording after
interruptions that were not meant to resume. Update the interruption-end
handling in `handleInterruptionEnded` so a local `needsForegroundRecovery` flag
is set only when the `.setRecording(false)`/`.setRecording(true)` restart
actions are actually appended, then assign `pendingForegroundRecovery` only if
that restart was needed and the app is still backgrounded. Keep the microphone
mute restore behavior unchanged, and make sure the same gating is applied in the
related recovery path referenced by `recoverPendingForegroundInterruption`.
🧹 Nitpick comments (1)
StreamVideoTests/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore_InterruptionsEffectTests.swift (1)

215-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a backgrounded no-resume/no-restart negative test.

These new tests cover foreground transitions well, but they still miss the case where interruption end happens in the background with shouldResumeSession == false (or without a restart-capable state). That is the edge where pendingForegroundRecovery can be armed incorrectly, so a dedicated negative test would pin the intended contract.

🤖 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
`@StreamVideoTests/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore_InterruptionsEffectTests.swift`
around lines 215 - 245, Add a negative test for the interruption-end background
path where no resume/restart should occur, since the current tests only cover
foreground transitions. Extend RTCAudioStore_InterruptionsEffectTests with a
case around audioSessionDidEndInterruption that sets appState to background and
uses shouldResumeSession false (or a non-restart-capable state from makeState),
then verify dispatch stays empty and pendingForegroundRecovery is not armed. Use
the existing publisher, subject.dispatcher, and stateProvider setup patterns to
locate and mirror the surrounding tests.
🤖 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 `@CHANGELOG.md`:
- Around line 7-8: The changelog has a heading level jump: after the top-level
“Upcoming” heading, the “Fixed” section uses a level 3 heading instead of the
next level down. Update the heading in CHANGELOG.md so the section under
“Upcoming” uses the correct next heading level (or add an intermediate level 2
parent) to keep the hierarchy consistent and avoid MD001; use the “Upcoming” and
“Fixed” headings to locate the spot.

In
`@Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore`+InterruptionsEffect.swift:
- Around line 49-55: The interruption handling in
RTCAudioStore+InterruptionsEffect is reading applicationStateAdapter.state from
processingQueue, which can race with StreamAppStateAdapter’s `@Published` state
updates. Update the statePublisher pipeline to cache the latest ApplicationState
on processingQueue, and have handle(_:) use that cached value instead of
querying the adapter directly; keep the recovery path in
recoverPendingForegroundInterruption() driven only by the queue-local cached
state.

---

Outside diff comments:
In
`@Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore`+InterruptionsEffect.swift:
- Around line 80-103: `pendingForegroundRecovery` is being armed too broadly in
`RTCAudioStore+InterruptionsEffect`, which can cause
`recoverPendingForegroundInterruption()` to restart recording after
interruptions that were not meant to resume. Update the interruption-end
handling in `handleInterruptionEnded` so a local `needsForegroundRecovery` flag
is set only when the `.setRecording(false)`/`.setRecording(true)` restart
actions are actually appended, then assign `pendingForegroundRecovery` only if
that restart was needed and the app is still backgrounded. Keep the microphone
mute restore behavior unchanged, and make sure the same gating is applied in the
related recovery path referenced by `recoverPendingForegroundInterruption`.

---

Nitpick comments:
In
`@StreamVideoTests/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore_InterruptionsEffectTests.swift`:
- Around line 215-245: Add a negative test for the interruption-end background
path where no resume/restart should occur, since the current tests only cover
foreground transitions. Extend RTCAudioStore_InterruptionsEffectTests with a
case around audioSessionDidEndInterruption that sets appState to background and
uses shouldResumeSession false (or a non-restart-capable state from makeState),
then verify dispatch stays empty and pendingForegroundRecovery is not armed. Use
the existing publisher, subject.dispatcher, and stateProvider setup patterns to
locate and mirror the surrounding tests.
🪄 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: 1430c6cc-fe30-4b90-b937-3d11a33a5cd7

📥 Commits

Reviewing files that changed from the base of the PR and between bc4735e and 3c5471b.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore+InterruptionsEffect.swift
  • StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme
  • StreamVideoTests/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore_InterruptionsEffectTests.swift

Comment thread CHANGELOG.md
Comment on lines +49 to +55
applicationStateAdapter
.statePublisher
.removeDuplicates()
.filter { $0 == .foreground }
.receive(on: processingQueue)
.sink { [weak self] _ in self?.recoverPendingForegroundInterruption() }
.store(in: disposableBag)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid reading applicationStateAdapter.state off the processing queue.

The new background check reads applicationStateAdapter.state on processingQueue, but StreamAppStateAdapter stores state in a plain @Published property. That introduces a cross-thread read/write race between lifecycle notifications and this queue, which can mis-arm or skip recovery.

Cache the latest ApplicationState from statePublisher on processingQueue and use that cached value inside handle(_:) instead of reading the adapter directly.

Also applies to: 101-103

🤖 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
`@Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Effects/RTCAudioStore`+InterruptionsEffect.swift
around lines 49 - 55, The interruption handling in
RTCAudioStore+InterruptionsEffect is reading applicationStateAdapter.state from
processingQueue, which can race with StreamAppStateAdapter’s `@Published` state
updates. Update the statePublisher pipeline to cache the latest ApplicationState
on processingQueue, and have handle(_:) use that cached value instead of
querying the adapter directly; keep the recovery path in
recoverPendingForegroundInterruption() driven only by the queue-local cached
state.

@sonarqubecloud

Copy link
Copy Markdown

@martinmitrevski martinmitrevski 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.

lgtm

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

SDK Size

title develop branch diff status
StreamVideo 10.54 MB 10.54 MB 0 KB 🟢
StreamVideoSwiftUI 2.47 MB 2.47 MB 0 KB 🟢
StreamVideoUIKit 2.6 MB 2.6 MB 0 KB 🟢
StreamWebRTC 11.87 MB 11.87 MB 0 KB 🟢

@github-actions

Copy link
Copy Markdown

Public Interface

🚀 No changes affecting the public interface.

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

StreamVideo XCSize

Object Diff (bytes)
RTCAudioStore+InterruptionsEffect.o +4225
StreamAppStateAdapter.o -208
ApplicationLifecycleVideoMuteAdapter.o -156
BatteryStore+ObserverEffect.o -152
WebRTCStateAdapter.o -134
RTCAudioStore+RouteChangeEffect.o +96
Foundation.tbd -76

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants