Fix Android speaker output + keyboard-aware modal

Android Audio:
- Use inCommunication mode with forceHandleAudioRouting
- Explicit selectAudioOutput('speaker') after session start
- Keeps echo cancellation while forcing speaker output

Profile Modal:
- Add KeyboardAvoidingView for deployment ID input
- Prevents modal buttons from being hidden by keyboard

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-25 11:47:53 -08:00
parent 85896f442f
commit 8240e51bc5
2 changed files with 124 additions and 106 deletions

View File

@ -8,6 +8,8 @@ import {
Alert, Alert,
TextInput, TextInput,
Modal, Modal,
KeyboardAvoidingView,
Platform,
} from 'react-native'; } from 'react-native';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -223,7 +225,10 @@ export default function ProfileScreen() {
animationType="fade" animationType="fade"
onRequestClose={() => setShowDeploymentModal(false)} onRequestClose={() => setShowDeploymentModal(false)}
> >
<View style={styles.modalOverlay}> <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}> <View style={styles.modalContent}>
<Text style={styles.modalTitle}>Deployment ID</Text> <Text style={styles.modalTitle}>Deployment ID</Text>
<Text style={styles.modalDescription}> <Text style={styles.modalDescription}>
@ -264,7 +269,7 @@ export default function ProfileScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View> </KeyboardAvoidingView>
</Modal> </Modal>
</SafeAreaView> </SafeAreaView>
); );

View File

@ -50,102 +50,93 @@ export async function configureAudioForVoiceCall(): Promise<void> {
} }
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
// iOS-specific configuration with fallback strategies // iOS-specific configuration - FORCE SPEAKER OUTPUT
// Try multiple configurations in order of preference // Using videoChat mode + defaultSpeakerOutput option for guaranteed speaker
console.log('[AudioSession] Configuring iOS for SPEAKER output...');
const configs = [ try {
// Strategy 1: videoChat mode (speaker by default, no problematic options) // Primary config: videoChat mode with defaultSpeakerOutput
{ await AudioSession.setAppleAudioConfiguration({
name: 'videoChat', audioCategory: 'playAndRecord',
config: { audioCategoryOptions: [
'allowBluetooth',
'mixWithOthers',
'defaultToSpeaker', // KEY: Forces speaker as default output
],
audioMode: 'videoChat', // videoChat mode uses speaker by default
});
console.log('[AudioSession] iOS videoChat + defaultToSpeaker configured!');
} catch (err) {
console.warn('[AudioSession] Primary iOS config failed, trying fallback:', err);
// Fallback: just videoChat without defaultToSpeaker option
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord', audioCategory: 'playAndRecord',
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'],
audioMode: 'videoChat', audioMode: 'videoChat',
}, });
},
// Strategy 2: voiceChat mode (more compatible, but earpiece by default)
{
name: 'voiceChat',
config: {
audioCategory: 'playAndRecord',
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'],
audioMode: 'voiceChat',
},
},
// Strategy 3: Minimal config (most compatible)
{
name: 'minimal',
config: {
audioCategory: 'playAndRecord',
audioCategoryOptions: [],
audioMode: 'default',
},
},
];
let configSuccess = false;
let lastError: any = null;
for (const { name, config } of configs) {
try {
console.log(`[AudioSession] Trying ${name} configuration...`);
await AudioSession.setAppleAudioConfiguration(config);
console.log(`[AudioSession] ${name} configuration succeeded!`);
configSuccess = true;
break;
} catch (err) {
console.warn(`[AudioSession] ${name} config failed:`, err);
lastError = err;
// Continue to next strategy
}
} }
if (!configSuccess) { console.log('[AudioSession] Starting iOS audio session...');
console.error('[AudioSession] All iOS configurations failed!');
throw lastError || new Error('All audio configurations failed');
}
console.log('[AudioSession] Starting audio session...');
await AudioSession.startAudioSession(); await AudioSession.startAudioSession();
// Try to set speaker output (non-critical, don't throw on failure) // Additionally set default output to speaker (belt and suspenders)
try { try {
console.log('[AudioSession] Setting default output to speaker...'); console.log('[AudioSession] Setting iOS default output to speaker...');
await AudioSession.configureAudio({ await AudioSession.configureAudio({
ios: { ios: {
defaultOutput: 'speaker', defaultOutput: 'speaker',
}, },
}); });
console.log('[AudioSession] iOS speaker output set!');
} catch (outputErr) { } catch (outputErr) {
console.warn('[AudioSession] Could not set speaker output:', outputErr); console.warn('[AudioSession] Could not set speaker output:', outputErr);
// Continue anyway - audio will work, just maybe on earpiece
} }
} else if (Platform.OS === 'android') { } else if (Platform.OS === 'android') {
// Android-specific configuration // Android-specific configuration - FORCE SPEAKER OUTPUT
// IMPORTANT: Using 'music' stream type to force output to speaker // SOLUTION: Use 'inCommunication' for echo cancellation + forceHandleAudioRouting + explicit speaker selection
// 'voiceCall' stream type defaults to earpiece on many Android devices console.log('[AudioSession] Configuring Android audio for SPEAKER with echo cancellation...');
console.log('[AudioSession] Configuring Android audio for SPEAKER...');
await AudioSession.configureAudio({ await AudioSession.configureAudio({
android: { android: {
// Use MEDIA mode to ensure speaker output // Force speaker as preferred output
preferredOutputList: ['speaker'],
// CRITICAL: This flag forces audio routing even in communication mode
forceHandleAudioRouting: true,
audioTypeOptions: { audioTypeOptions: {
manageAudioFocus: true, manageAudioFocus: true,
audioMode: 'normal', // Use 'inCommunication' for echo cancellation (important for voice calls!)
audioMode: 'inCommunication',
audioFocusMode: 'gain', audioFocusMode: 'gain',
// Use 'music' stream - goes to speaker by default // Voice call stream type for proper routing
audioStreamType: 'music', audioStreamType: 'voiceCall',
audioAttributesUsageType: 'media', audioAttributesUsageType: 'voiceCommunication',
audioAttributesContentType: 'music', audioAttributesContentType: 'speech',
}, },
// Force speaker as output
preferredOutputList: ['speaker'],
// Allow us to control audio routing
forceHandleAudioRouting: true,
}, },
}); });
console.log('[AudioSession] Starting Android audio session...'); console.log('[AudioSession] Starting Android audio session...');
await AudioSession.startAudioSession(); await AudioSession.startAudioSession();
// CRITICAL: Explicitly select speaker AFTER session starts
// This overrides the default earpiece routing of inCommunication mode
try {
console.log('[AudioSession] Explicitly selecting speaker output...');
await AudioSession.selectAudioOutput('speaker');
console.log('[AudioSession] Speaker output explicitly selected!');
} catch (speakerErr) {
console.warn('[AudioSession] selectAudioOutput failed, trying showAudioRoutePicker:', speakerErr);
// Fallback: try to show audio route picker or use alternative method
try {
if (AudioSession.showAudioRoutePicker) {
await AudioSession.showAudioRoutePicker();
}
} catch (pickerErr) {
console.warn('[AudioSession] showAudioRoutePicker also failed:', pickerErr);
}
}
console.log('[AudioSession] Android speaker mode with echo cancellation configured!');
} }
console.log('[AudioSession] Configuration complete!'); console.log('[AudioSession] Configuration complete!');
@ -191,7 +182,7 @@ export async function reconfigureAudioForPlayback(): Promise<void> {
return; return;
} }
console.log(`[AudioSession] Reconfiguring for playback on ${Platform.OS}...`); console.log(`[AudioSession] Reconfiguring for playback (SPEAKER) on ${Platform.OS}...`);
try { try {
const AudioSession = await getAudioSession(); const AudioSession = await getAudioSession();
@ -200,33 +191,50 @@ export async function reconfigureAudioForPlayback(): Promise<void> {
} }
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
// Reconfigure with same safe settings - this "refreshes" the audio routing // Reconfigure iOS - force speaker output
await AudioSession.setAppleAudioConfiguration({ await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord', audioCategory: 'playAndRecord',
audioCategoryOptions: [ audioCategoryOptions: [
'allowBluetooth', 'allowBluetooth',
'mixWithOthers', 'mixWithOthers',
'defaultToSpeaker', // Force speaker
], ],
// Use 'videoChat' - defaults to speaker audioMode: 'videoChat', // videoChat = speaker by default
audioMode: 'videoChat',
}); });
// Also set default output to speaker
await AudioSession.configureAudio({
ios: {
defaultOutput: 'speaker',
},
});
console.log('[AudioSession] iOS reconfigured for speaker playback');
} else if (Platform.OS === 'android') { } else if (Platform.OS === 'android') {
// Reconfigure Android audio to ensure speaker output // Reconfigure Android - force speaker while keeping echo cancellation
// Using 'music' stream type to force speaker
await AudioSession.configureAudio({ await AudioSession.configureAudio({
android: { android: {
audioTypeOptions: {
manageAudioFocus: true,
audioMode: 'normal',
audioFocusMode: 'gain',
audioStreamType: 'music',
audioAttributesUsageType: 'media',
audioAttributesContentType: 'music',
},
preferredOutputList: ['speaker'], preferredOutputList: ['speaker'],
forceHandleAudioRouting: true, forceHandleAudioRouting: true,
audioTypeOptions: {
manageAudioFocus: true,
audioMode: 'inCommunication', // Keep for echo cancellation
audioFocusMode: 'gain',
audioStreamType: 'voiceCall',
audioAttributesUsageType: 'voiceCommunication',
audioAttributesContentType: 'speech',
},
}, },
}); });
// Explicitly select speaker output
try {
await AudioSession.selectAudioOutput('speaker');
console.log('[AudioSession] Android speaker explicitly selected');
} catch (err) {
console.warn('[AudioSession] selectAudioOutput failed in reconfigure:', err);
}
console.log('[AudioSession] Android reconfigured for speaker playback');
} }
console.log('[AudioSession] Reconfigured successfully'); console.log('[AudioSession] Reconfigured successfully');
@ -252,11 +260,12 @@ export async function setAudioOutput(useSpeaker: boolean): Promise<void> {
} }
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
// iOS: Update configuration based on desired output // iOS: Use videoChat mode + defaultToSpeaker for speaker, voiceChat for earpiece
// Use 'videoChat' mode for speaker, 'voiceChat' for earpiece
await AudioSession.setAppleAudioConfiguration({ await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord', audioCategory: 'playAndRecord',
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], audioCategoryOptions: useSpeaker
? ['allowBluetooth', 'mixWithOthers', 'defaultToSpeaker']
: ['allowBluetooth', 'mixWithOthers'],
audioMode: useSpeaker ? 'videoChat' : 'voiceChat', audioMode: useSpeaker ? 'videoChat' : 'voiceChat',
}); });
@ -267,25 +276,29 @@ export async function setAudioOutput(useSpeaker: boolean): Promise<void> {
}, },
}); });
} else if (Platform.OS === 'android') { } else if (Platform.OS === 'android') {
// Android: Switch stream type to control speaker/earpiece // Android: Keep inCommunication mode for echo cancellation, use explicit output selection
// - 'music' stream goes to speaker by default
// - 'voiceCall' stream goes to earpiece by default
await AudioSession.configureAudio({ await AudioSession.configureAudio({
android: { android: {
audioTypeOptions: {
manageAudioFocus: true,
audioMode: useSpeaker ? 'normal' : 'inCommunication',
audioFocusMode: 'gain',
// Key difference: music→speaker, voiceCall→earpiece
audioStreamType: useSpeaker ? 'music' : 'voiceCall',
audioAttributesUsageType: useSpeaker ? 'media' : 'voiceCommunication',
audioAttributesContentType: useSpeaker ? 'music' : 'speech',
},
// Also set preferred output list
preferredOutputList: useSpeaker ? ['speaker'] : ['earpiece'], preferredOutputList: useSpeaker ? ['speaker'] : ['earpiece'],
forceHandleAudioRouting: true, forceHandleAudioRouting: true,
audioTypeOptions: {
manageAudioFocus: true,
// Always use inCommunication for echo cancellation
audioMode: 'inCommunication',
audioFocusMode: 'gain',
audioStreamType: 'voiceCall',
audioAttributesUsageType: 'voiceCommunication',
audioAttributesContentType: 'speech',
},
}, },
}); });
// Explicitly select output device
try {
await AudioSession.selectAudioOutput(useSpeaker ? 'speaker' : 'earpiece');
} catch (err) {
console.warn('[AudioSession] selectAudioOutput failed:', err);
}
} }
console.log(`[AudioSession] Audio output set to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'}`); console.log(`[AudioSession] Audio output set to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'}`);