@siteed/expo-audio-studio
Version:
Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web
279 lines (239 loc) • 10.2 kB
text/typescript
import {
ConfigPlugin,
withAndroidManifest,
withInfoPlist,
AndroidConfig,
} from '@expo/config-plugins'
import { ExpoConfig } from '@expo/config-types'
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone'
const NOTIFICATION_USAGE = 'Show recording notifications and controls'
const LOG_PREFIX = '[@siteed/expo-audio-studio]'
function debugLog(message: string, ...args: unknown[]): void {
if (process.env.EXPO_DEBUG) {
console.log(`${LOG_PREFIX} ${message}`, ...args)
}
}
interface AudioStreamPluginOptions {
enablePhoneStateHandling?: boolean // Controls READ_PHONE_STATE permission
enableNotifications?: boolean
enableBackgroundAudio?: boolean
iosBackgroundModes?: {
useVoIP?: boolean
useAudio?: boolean
useProcessing?: boolean
useLocation?: boolean
useExternalAccessory?: boolean
}
iosConfig?: {
allowBackgroundAudioControls?: boolean
backgroundProcessingTitle?: string
microphoneUsageDescription?: string
notificationUsageDescription?: string
}
}
const withRecordingPermission: ConfigPlugin<AudioStreamPluginOptions> = (
config: ExpoConfig,
props: AudioStreamPluginOptions | void
) => {
const options: AudioStreamPluginOptions = {
enablePhoneStateHandling: true, // Default to true for backward compatibility
enableNotifications: true,
enableBackgroundAudio: true,
iosBackgroundModes: {
useVoIP: false,
useAudio: false,
useProcessing: false,
useLocation: false,
useExternalAccessory: false,
},
iosConfig: {
microphoneUsageDescription: MICROPHONE_USAGE,
notificationUsageDescription: NOTIFICATION_USAGE,
},
...(props || {}),
}
const {
enablePhoneStateHandling,
enableNotifications,
enableBackgroundAudio,
} = options
debugLog('📱 Configuring Recording Permissions Plugin...', options)
// iOS Configuration
config = withInfoPlist(config as any, (config) => {
// Always set the microphone usage description from options first
config.modResults['NSMicrophoneUsageDescription'] =
options.iosConfig?.microphoneUsageDescription ||
config.modResults['NSMicrophoneUsageDescription'] ||
MICROPHONE_USAGE
if (enableNotifications) {
config.modResults['NSUserNotificationsUsageDescription'] =
options.iosConfig?.notificationUsageDescription ||
config.modResults['NSUserNotificationsUsageDescription'] ||
NOTIFICATION_USAGE
config.modResults['NSUserNotificationAlertStyle'] = 'alert'
}
const existingBackgroundModes =
config.modResults.UIBackgroundModes || []
// Only add background modes if explicitly enabled and set to true
if (
options.iosBackgroundModes?.useAudio === true &&
enableBackgroundAudio === true &&
!existingBackgroundModes.includes('audio')
) {
// Don't automatically add 'audio' background mode as it's only for playback
// existingBackgroundModes.push('audio')
// Instead, ensure processing mode is used for background recording
if (options.iosBackgroundModes?.useProcessing !== true) {
console.warn(
`${LOG_PREFIX} Warning: Background audio recording requires 'processing' background mode. Please enable 'useProcessing' in iosBackgroundModes.`
)
}
}
if (
options.iosBackgroundModes?.useVoIP === true &&
enablePhoneStateHandling === true
) {
if (!existingBackgroundModes.includes('voip')) {
existingBackgroundModes.push('voip')
}
const existingCapabilities = (config.modResults
.UIRequiredDeviceCapabilities || []) as string[]
if (!existingCapabilities.includes('telephony')) {
existingCapabilities.push('telephony')
}
config.modResults.UIRequiredDeviceCapabilities =
existingCapabilities
}
// Add additional background modes only if explicitly set to true
if (options.iosBackgroundModes?.useProcessing === true) {
if (!existingBackgroundModes.includes('processing')) {
existingBackgroundModes.push('processing')
}
// Add processing info if enabled
// Note: We keep the 'audiostream' namespace for native modules to maintain compatibility
config.modResults.BGTaskSchedulerPermittedIdentifiers = [
'com.siteed.audiostream.processing',
]
}
if (options.iosBackgroundModes?.useLocation === true) {
if (!existingBackgroundModes.includes('location')) {
existingBackgroundModes.push('location')
}
}
if (options.iosBackgroundModes?.useExternalAccessory === true) {
if (!existingBackgroundModes.includes('external-accessory')) {
existingBackgroundModes.push('external-accessory')
}
}
// Configure background processing info if enabled
if (options.iosConfig?.backgroundProcessingTitle) {
config.modResults.BGProcessingTaskTitle =
options.iosConfig.backgroundProcessingTitle
}
// Configure audio session behavior
if (options.iosConfig?.allowBackgroundAudioControls) {
config.modResults.UIBackgroundModes = [
...existingBackgroundModes,
'remote-notification',
]
config.modResults.MPNowPlayingInfoPropertyPlaybackRate = true
}
config.modResults.UIBackgroundModes = existingBackgroundModes
return config
})
// Android Configuration
config = withAndroidManifest(config as any, (config) => {
const basePermissions = [
'android.permission.RECORD_AUDIO',
'android.permission.WAKE_LOCK',
]
const optionalPermissions = [
enableNotifications && 'android.permission.POST_NOTIFICATIONS',
enablePhoneStateHandling && 'android.permission.READ_PHONE_STATE', // Only add if enabled
enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE',
enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE_MICROPHONE',
].filter(Boolean) as string[]
const permissionsToAdd = [...basePermissions, ...optionalPermissions]
debugLog(
'📋 Existing Android permissions:',
config.modResults.manifest['uses-permission']?.map(
(p) => p.$?.['android:name']
) || []
)
debugLog('➕ Adding Android permissions:', permissionsToAdd)
const { addPermission } = AndroidConfig.Permissions
// Add each permission only if it doesn't exist
permissionsToAdd.forEach((permission) => {
const existingPermission = config.modResults.manifest[
'uses-permission'
]?.find((p) => p.$?.['android:name'] === permission)
if (!existingPermission) {
addPermission(config.modResults, permission)
}
})
// Get the main application node
const mainApplication = config.modResults.manifest.application?.[0]
if (mainApplication) {
debugLog('📱 Configuring Android application components...')
// Add RecordingActionReceiver
if (!mainApplication.receiver) {
mainApplication.receiver = []
}
const receiverConfig = {
$: {
'android:name': '.RecordingActionReceiver',
'android:exported': 'false' as const,
},
'intent-filter': [
{
action: [
{ $: { 'android:name': 'PAUSE_RECORDING' } },
{ $: { 'android:name': 'RESUME_RECORDING' } },
{ $: { 'android:name': 'STOP_RECORDING' } },
],
},
],
}
const receiverIndex = mainApplication.receiver.findIndex(
(receiver: any) =>
receiver.$?.['android:name'] === '.RecordingActionReceiver'
)
if (receiverIndex >= 0) {
mainApplication.receiver[receiverIndex] = receiverConfig
} else {
mainApplication.receiver.push(receiverConfig)
}
debugLog('✅ RecordingActionReceiver configured')
// Add AudioRecordingService
if (!mainApplication.service) {
mainApplication.service = []
}
const serviceConfig = {
$: {
'android:name': '.AudioRecordingService',
'android:enabled': 'true' as const,
'android:exported': 'false' as const,
'android:foregroundServiceType': 'microphone',
},
}
const serviceIndex = mainApplication.service.findIndex(
(service: any) =>
service.$?.['android:name'] === '.AudioRecordingService'
)
if (serviceIndex >= 0) {
mainApplication.service[serviceIndex] = serviceConfig
} else {
mainApplication.service.push(serviceConfig)
}
debugLog('✅ AudioRecordingService configured')
} else {
console.error(
`${LOG_PREFIX} ❌ Main application node not found in Android Manifest`
)
}
return config
})
debugLog('✨ Recording Permissions Plugin configuration completed')
return config as any
}
export default withRecordingPermission