From 11d3f722115bc90cd76e7245f6a3dbc3195e1ce1 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Jul 2026 09:23:31 -0700 Subject: [PATCH 1/2] RU-T50 Voice fix for ios --- scripts/extract-release-notes.sh | 3 ++ .../app/__tests__/livekit-store.test.ts | 43 ++++++------------- src/stores/app/livekit-store.ts | 39 +++++++---------- 3 files changed, 31 insertions(+), 54 deletions(-) diff --git a/scripts/extract-release-notes.sh b/scripts/extract-release-notes.sh index 50014e6f..6cd2e62a 100755 --- a/scripts/extract-release-notes.sh +++ b/scripts/extract-release-notes.sh @@ -46,6 +46,9 @@ extract_release_notes() { notes="$cleaned_body" fi + # Replace standalone "PR" with "Release" for release notes text + notes="$(printf '%s\n' "$notes" | sed -E 's/\bPR\b/Release/g')" + # Final trim notes="$(printf '%s\n' "$notes" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index 8c2c5c75..5574b82f 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -259,17 +259,8 @@ describe('LiveKit Store - Permission Management', () => { (Platform as any).OS = 'ios'; }); - it('should successfully request permissions on iOS', async () => { - // Mock initial permission check - not granted + it('should return true on iOS without requesting when permission already granted', async () => { mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - expires: 'never', - status: 'undetermined', - } as any); - - // Mock permission request - granted - mockRequestRecordingPermissionsAsync.mockResolvedValueOnce({ granted: true, canAskAgain: true, expires: 'never', @@ -277,18 +268,17 @@ describe('LiveKit Store - Permission Management', () => { } as any); const { requestPermissions } = useLiveKitStore.getState(); - await requestPermissions(); + const result = await requestPermissions(); + expect(result).toBe(true); expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Microphone permission granted successfully', - context: { platform: 'ios' }, - }); + expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); }); - it('should handle iOS permission denial', async () => { - // Mock initial permission check - not granted + it('should never call session-activating request on iOS, even when not granted', async () => { + // expo-audio's requestRecordingPermissionsAsync activates AVAudioSession + // and deadlocks against expo-av — the store must only perform the + // non-activating check and let WebRTC prompt natively on publish. mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ granted: false, canAskAgain: false, @@ -296,21 +286,14 @@ describe('LiveKit Store - Permission Management', () => { status: 'denied', } as any); - // Mock permission request - still denied - mockRequestRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: false, - expires: 'never', - status: 'denied', - } as any); - const { requestPermissions } = useLiveKitStore.getState(); - await requestPermissions(); + const result = await requestPermissions(); + expect(result).toBe(true); expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Microphone permission not granted', + expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Microphone permission not yet granted - WebRTC will prompt on publish', context: { platform: 'ios' }, }); }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index da7d1bad..9045a885 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -1,7 +1,7 @@ import { AudioSession } from '@livekit/react-native'; import { RTCAudioSession } from '@livekit/react-native-webrtc'; import notifee, { AndroidForegroundServiceType, AndroidImportance } from '@notifee/react-native'; -import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; +import { getRecordingPermissionsAsync } from 'expo-audio'; import { Audio, InterruptionModeIOS } from 'expo-av'; import * as Device from 'expo-device'; import { Room, RoomEvent } from 'livekit-client'; @@ -397,23 +397,20 @@ export const useLiveKitStore = create((set, get) => ({ }); return true; } else if (Platform.OS === 'ios') { + // NOTE: expo-audio's requestRecordingPermissionsAsync activates + // AVAudioSession in record mode, which deadlocks against expo-av's + // active session and freezes the app. Only perform the + // non-activating status check here; WebRTC triggers the native mic + // prompt itself when a track is published. const micPermission = await getRecordingPermissionsAsync(); if (!micPermission.granted) { - const result = await requestRecordingPermissionsAsync(); - if (!result.granted) { - logger.error({ - message: 'Microphone permission not granted', - context: { platform: Platform.OS }, - }); - return false; - } + logger.info({ + message: 'Microphone permission not yet granted - WebRTC will prompt on publish', + context: { platform: Platform.OS }, + }); } - logger.info({ - message: 'Microphone permission granted successfully', - context: { platform: Platform.OS }, - }); return true; } return true; // Web/other platforms don't need runtime permissions @@ -963,20 +960,14 @@ export const useLiveKitStore = create((set, get) => ({ } return true; } else if (Platform.OS === 'ios') { + // Non-activating check only — see requestPermissions for why + // requestRecordingPermissionsAsync must not be called on iOS. const mic = await getRecordingPermissionsAsync(); - if (mic.granted) return true; - - logger.info({ - message: 'Requesting microphone permission before opening voice UI', - context: { platform: Platform.OS }, - }); - const result = await requestRecordingPermissionsAsync(); - if (!result.granted) { - logger.warn({ - message: 'Microphone permission denied - voice UI will still open but joining will fail', + if (!mic.granted) { + logger.info({ + message: 'Microphone permission not yet granted - WebRTC will prompt on publish', context: { platform: Platform.OS }, }); - return false; } return true; } From 07903e53b69e73aa8453eea8cfe32743984224bc Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Jul 2026 09:50:48 -0700 Subject: [PATCH 2/2] RU-T50 PR#258 fixes --- src/stores/app/__tests__/livekit-store.test.ts | 7 ++++--- src/stores/app/livekit-store.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index 5574b82f..3e396059 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -118,6 +118,7 @@ jest.mock('expo-audio', () => ({ jest.mock('../../../lib/logging', () => ({ logger: { info: jest.fn(), + warn: jest.fn(), error: jest.fn(), debug: jest.fn(), }, @@ -289,11 +290,11 @@ describe('LiveKit Store - Permission Management', () => { const { requestPermissions } = useLiveKitStore.getState(); const result = await requestPermissions(); - expect(result).toBe(true); + expect(result).toBe(false); expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Microphone permission not yet granted - WebRTC will prompt on publish', + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Microphone permission permanently denied', context: { platform: 'ios' }, }); }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 9045a885..b4caa00f 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -405,6 +405,13 @@ export const useLiveKitStore = create((set, get) => ({ const micPermission = await getRecordingPermissionsAsync(); if (!micPermission.granted) { + if (!micPermission.canAskAgain) { + logger.warn({ + message: 'Microphone permission permanently denied', + context: { platform: Platform.OS }, + }); + return false; + } logger.info({ message: 'Microphone permission not yet granted - WebRTC will prompt on publish', context: { platform: Platform.OS }, @@ -964,6 +971,13 @@ export const useLiveKitStore = create((set, get) => ({ // requestRecordingPermissionsAsync must not be called on iOS. const mic = await getRecordingPermissionsAsync(); if (!mic.granted) { + if (!mic.canAskAgain) { + logger.warn({ + message: 'Microphone permission permanently denied', + context: { platform: Platform.OS }, + }); + return false; + } logger.info({ message: 'Microphone permission not yet granted - WebRTC will prompt on publish', context: { platform: Platform.OS },