DEV Community

Pushkar Aagnihotri
Pushkar Aagnihotri

Posted on

Everything That Can Interrupt Your Microphone on Android -And How to Handle It

Building AudioMemo taught me that continuous audio recording is 10% MediaRecorder and 90% defending against everything that tries to stop it.


Why Audio UX is Uniquely Hard on Mobile

Building a screen with a list and a button is predictable. Building an app that continuously records audio is not.

When I built AudioMemo — a native Android app that records audio in 30-second chunks, transcribes via OpenAI Whisper, and summarizes with GPT-4o-mini — I quickly realized that wiring up MediaRecorder was the easy part. The real challenge was everything that tries to stop that recording without warning.

Phone calls. Spotify stealing audio focus. The user toggling the microphone off from Quick Settings. Android 14 crashing your foreground service if you forgot one line in the manifest. A Bluetooth headset disconnecting mid-sentence.

Every one of these is a different problem requiring a different solution. And worse — multiple interruptions can overlap, which opens its own class of bugs.

This article documents every interruption AudioMemo handles, with the actual code behind each one.


The App Architecture (Quick Context)

Before diving in, here's the stack so the code samples make sense:

  • MediaRecorder for audio capture, split into 30-second .m4a chunks
  • Room for immediate local persistence of each chunk
  • WorkManager (WhisperUploadWorker) for background Whisper API transcription
  • Foreground Service (AudioRecordingService) to keep recording alive
  • AudioInterruptionManager — a dedicated class that owns all interruption logic
  • Kotlin Coroutines + StateFlow throughout

The chunking strategy is itself a first line of defense. If something kills your recording, you only lose the current 30-second chunk — not the entire session. But chunking alone is nowhere near enough.


The Central Problem: Overlapping Interruptions

Before getting into individual interruptions, the most important architectural decision in AudioMemo is dual-flag (actually triple-flag) pause logic.

Consider this scenario: a phone call arrives while audio focus has already been lost to Spotify. When the call ends, your app gets CALL_STATE_IDLE. A naive implementation would immediately resume recording — but audio focus is still gone. You'd be resuming into a broken state.

AudioMemo tracks three independent boolean flags inside AudioInterruptionManager:

class AudioInterruptionManager @Inject constructor(
    private val context: Context,
    private val audioManager: AudioManager,
    private val onPauseRequested: () -> Unit,
    private val onResumeRequested: () -> Unit
) {
    // Each interruption source owns its own flag
    private var pausedForCall = false
    private var pausedForFocus = false
    private var pausedForMicMute = false

    // Only resume when ALL flags are clear
    private fun checkAndResume() {
        if (!pausedForCall && !pausedForFocus && !pausedForMicMute) {
            onResumeRequested()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Every interrupt source sets its own flag independently. onResumeRequested() is only called when all three are clear. This prevents premature resumption when one condition clears but another is still active.


Interruption #1: AudioFocus — The Foundation

AudioFocus is Android's traffic control system for audio. Every app that plays or records should request focus first and release it when done. Without it, your app fights every other audio app on the device — and loses unpredictably.

private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
    when (focusChange) {
        AudioManager.AUDIOFOCUS_LOSS -> {
            // Permanent loss — e.g. YouTube, Spotify took over
            pausedForFocus = true
            onPauseRequested()
            showNotification("Paused – Audio focus lost")
        }
        AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
            // Temporary loss — notification sound, assistant
            pausedForFocus = true
            onPauseRequested()
        }
        AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
            // Music apps lower volume here.
            // For recording, we still pause — you cannot "duck" a microphone.
            pausedForFocus = true
            onPauseRequested()
        }
        AudioManager.AUDIOFOCUS_GAIN -> {
            // Focus returned
            pausedForFocus = false
            checkAndResume()
        }
    }
}

fun requestAudioFocus(): Boolean {
    val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
        .setAudioAttributes(
            AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
                .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                .build()
        )
        .setAcceptsDelayedFocusGain(true)
        .setOnAudioFocusChangeListener(audioFocusChangeListener)
        .build()
        .also { focusRequest = it }

    return audioManager.requestAudioFocus(focusRequest!!) ==
           AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
Enter fullscreen mode Exit fullscreen mode

Key detail: Use USAGE_VOICE_COMMUNICATION, not USAGE_MEDIA. This tells the system your app is recording speech, which changes how other apps respond to your focus request.


Interruption #2: Phone Calls — The Mic Killer

An incoming call physically seizes the microphone at the hardware level. If MediaRecorder is still running when the call connects, you'll either crash or silently record nothing.

AudioMemo listens for call state changes and stops recording before the mic is seized, using the modern API on Android 12+ and the legacy listener below that.

Modern API (Android 12+ / API 31+)

private val callStateCallback = object : TelephonyCallback(),
                                         TelephonyCallback.CallStateListener {
    override fun onCallStateChanged(state: Int) {
        when (state) {
            TelephonyManager.CALL_STATE_RINGING,
            TelephonyManager.CALL_STATE_OFFHOOK -> {
                pausedForCall = true
                onPauseRequested()
                showNotification("Paused – Phone call")
            }
            TelephonyManager.CALL_STATE_IDLE -> {
                pausedForCall = false
                checkAndResume() // Only resumes if other flags are also clear
            }
        }
    }
}

fun registerCallListener() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        telephonyManager.registerTelephonyCallback(
            context.mainExecutor,
            callStateCallback
        )
    } else {
        @Suppress("DEPRECATION")
        telephonyManager.listen(
            legacyPhoneStateListener,
            PhoneStateListener.LISTEN_CALL_STATE
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Required in your manifest:

<uses-permission android:name="android.permission.READ_PHONE_STATE" />
Enter fullscreen mode Exit fullscreen mode

Interruption #3: Android 14 Foreground Service Type

This is the most common crash I see in audio recording apps targeting Android 14. Without this, your app crashes at runtime the moment it tries to access the microphone from a foreground service — not a graceful error, a hard crash.

Three things must all be present:

1. Manifest service declaration:

<service
    android:name=".service.AudioRecordingService"
    android:foregroundServiceType="microphone"
    android:exported="false" />
Enter fullscreen mode Exit fullscreen mode

2. The permission:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
Enter fullscreen mode Exit fullscreen mode

3. The startForeground call with the type flag:

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    ServiceCompat.startForeground(
        this,
        NOTIFICATION_ID,
        buildRecordingNotification(),
        ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE // required on API 34+
    )
    startRecording()
    return START_STICKY
}
Enter fullscreen mode Exit fullscreen mode

Miss any one of the three — and you crash on every Android 14 device.


Interruption #4: Mic Hardware Toggle (Android 12+)

Android 12 added a hardware-level microphone toggle in Quick Settings. The user can disable the microphone system-wide while your app is recording. The brutal part: you get silence, not an exception. MediaRecorder keeps running happily, producing empty audio files.

AudioMemo handles this in two ways. First, check initial state when recording starts:

fun checkInitialMicState() {
    if (audioManager.isMicrophoneMute) {
        pausedForMicMute = true
        onPauseRequested()
        showNotification("Paused – Microphone muted")
    }
}
Enter fullscreen mode Exit fullscreen mode

Second, register for the mute change broadcast (API 27+):

private val micMuteReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == AudioManager.ACTION_MICROPHONE_MUTE_CHANGED) {
            if (audioManager.isMicrophoneMute) {
                pausedForMicMute = true
                onPauseRequested()
                showNotification("Paused – Microphone muted")
            } else {
                pausedForMicMute = false
                checkAndResume()
            }
        }
    }
}

fun registerMicMuteListener() {
    context.registerReceiver(
        micMuteReceiver,
        IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
    )
}
Enter fullscreen mode Exit fullscreen mode

Interruption #5: One-Time Permission Revocation

Android 11 introduced auto-reset permissions for apps unused for months. Android 12 added one-time permissions — the user can grant microphone access "only this time." If they do, your next session throws a SecurityException.

The fix is twofold. In the UI, re-check the permission every time the recording screen is composed — not just on first launch:

@Composable
fun TranscriptScreen(viewModel: TranscriptViewModel = hiltViewModel()) {
    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) viewModel.startRecording()
        else viewModel.onPermissionDenied()
    }

    // Checked every composition — catches one-time permission revocation
    LaunchedEffect(Unit) {
        if (ContextCompat.checkSelfPermission(
                context, Manifest.permission.RECORD_AUDIO
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the service, catch SecurityException separately — not just generic Exception:

private fun startRecording() {
    try {
        mediaRecorder?.apply {
            setAudioSource(MediaRecorder.AudioSource.MIC)
            setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
            setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
            setOutputFile(currentChunkFile.absolutePath)
            prepare()
            start()
        }
    } catch (e: SecurityException) {
        // Permission revoked mid-session — notify UI to re-request
        // NOT a generic error — needs a specific re-permission flow
        broadcastPermissionRevoked()
        stopSelf()
    } catch (e: IOException) {
        broadcastRecordingError("Failed to prepare recorder: ${e.message}")
    }
}
Enter fullscreen mode Exit fullscreen mode

Interruption #6: Bluetooth SCO — The Async Trap

If the user is wearing a Bluetooth headset, recording should route to the headset mic. But Bluetooth SCO (Synchronous Connection Oriented) connects asynchronously. If you start recording before SCO is connected, you silently fall back to the device microphone — or record nothing.

AudioMemo checks for a connected SCO input device at startup and only begins recording inside the connected callback:

fun initializeBluetooth() {
    val hasBtInput = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
        .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }

    if (hasBtInput) {
        // Register first, then connect — callback fires when ready
        context.registerReceiver(
            scoStateReceiver,
            IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
        )
        audioManager.startBluetoothSco()
        // Do NOT start recording here — wait for SCO_AUDIO_STATE_CONNECTED
    }
}

private val scoStateReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        when (intent.getIntExtra(
            AudioManager.EXTRA_SCO_AUDIO_STATE,
            AudioManager.SCO_AUDIO_STATE_ERROR
        )) {
            AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
                audioManager.isBluetoothScoOn = true
                showNotification("Microphone switched to Bluetooth headset")
                // Now safe to start recording
                startRecording()
            }
            AudioManager.SCO_AUDIO_STATE_DISCONNECTED,
            AudioManager.SCO_AUDIO_STATE_ERROR -> {
                audioManager.isBluetoothScoOn = false
                audioManager.stopBluetoothSco()
                showNotification("Microphone switched to device microphone")
            }
        }
    }
}

fun stopBluetooth() {
    audioManager.isBluetoothScoOn = false
    audioManager.stopBluetoothSco()
    try { context.unregisterReceiver(scoStateReceiver) }
    catch (e: IllegalArgumentException) { /* already unregistered */ }
}
Enter fullscreen mode Exit fullscreen mode

Required permission:

<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <!-- API ≤ 30 -->
Enter fullscreen mode Exit fullscreen mode

Interruption #7: Process Death

If the OS kills your process mid-recording (memory pressure, aggressive battery optimization), the current chunk is lost. But already-uploaded chunks are safe in Room. The risk is in-flight chunks — recorded but not yet uploaded.

AudioMemo solves this with a ChunkFinalizationWorker that is enqueued with a 15-second delay the moment recording starts:

fun enqueueFinalizationSafeguard() {
    val finalizationWork = OneTimeWorkRequestBuilder<ChunkFinalizationWorker>()
        .setInitialDelay(15, TimeUnit.SECONDS)
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()
        )
        .build()

    WorkManager.getInstance(context).enqueueUniqueWork(
        FINALIZATION_WORK_NAME,
        ExistingWorkPolicy.REPLACE,
        finalizationWork
    )
}
Enter fullscreen mode Exit fullscreen mode

If the app stops cleanly (user taps Stop), the worker is cancelled. If the process dies, WorkManager fires after 15 seconds, marks all in-flight chunks as FAILED, and re-queues TranscriptRetryWorker to retry the uploads:

class ChunkFinalizationWorker(
    context: Context,
    params: WorkerParameters,
    private val chunkRepository: ChunkRepository
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        // Find all chunks that were recording when the process died
        val inFlightChunks = chunkRepository.getChunksByStatus(ChunkStatus.RECORDING)

        inFlightChunks.forEach { chunk ->
            chunkRepository.updateChunkStatus(chunk.id, ChunkStatus.FAILED)
        }

        // Re-enqueue retry worker for all failed chunks
        if (inFlightChunks.isNotEmpty()) {
            WorkManager.getInstance(applicationContext)
                .enqueue(OneTimeWorkRequestBuilder<TranscriptRetryWorker>().build())
        }

        return Result.success()
    }
}
Enter fullscreen mode Exit fullscreen mode

Interruption #8: Network Failure During Transcription

When the Whisper API call fails mid-upload (dropped connection, timeout, server error), AudioMemo relies on WorkManager's built-in retry with exponential backoff:

class WhisperUploadWorker(
    context: Context,
    params: WorkerParameters,
    private val whisperRepository: WhisperRepository,
    private val chunkRepository: ChunkRepository
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val chunkId = inputData.getLong(KEY_CHUNK_ID, -1L)
        val chunk = chunkRepository.getChunkById(chunkId) ?: return Result.failure()

        return try {
            val transcription = whisperRepository.transcribeAudio(File(chunk.filePath))
            chunkRepository.updateChunkTranscription(chunkId, transcription)
            Result.success()
        } catch (e: IOException) {
            // Network error — retry with backoff
            if (runAttemptCount < MAX_RETRIES) Result.retry()
            else {
                chunkRepository.updateChunkStatus(chunkId, ChunkStatus.FAILED)
                Result.failure()
            }
        } catch (e: Exception) {
            chunkRepository.updateChunkStatus(chunkId, ChunkStatus.FAILED)
            Result.failure()
        }
    }

    companion object {
        const val KEY_CHUNK_ID = "chunk_id"
        const val MAX_RETRIES = 3
    }
}
Enter fullscreen mode Exit fullscreen mode

Enqueue with backoff policy when a chunk is ready:

fun enqueueTranscriptionWork(chunkId: Long) {
    val uploadWork = OneTimeWorkRequestBuilder<WhisperUploadWorker>()
        .setInputData(workDataOf(WhisperUploadWorker.KEY_CHUNK_ID to chunkId))
        .setBackoffCriteria(
            BackoffPolicy.EXPONENTIAL,
            WorkRequest.MIN_BACKOFF_MILLIS,
            TimeUnit.MILLISECONDS
        )
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()
        )
        .build()

    WorkManager.getInstance(context).enqueue(uploadWork)
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together — The Service Startup

Here's how AudioRecordingService orchestrates all of the above on start:

@AndroidEntryPoint
class AudioRecordingService : Service() {

    @Inject lateinit var audioInterruptionManager: AudioInterruptionManager
    @Inject lateinit var audioRecorderManager: AudioRecorderManager
    @Inject lateinit var chunkWorkManager: ChunkWorkManager

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 1. Start foreground with microphone type (Android 14 requirement)
        ServiceCompat.startForeground(
            this, NOTIFICATION_ID,
            buildRecordingNotification(),
            ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
        )

        // 2. Request AudioFocus — bail if denied
        if (!audioInterruptionManager.requestAudioFocus()) {
            stopSelf()
            return START_NOT_STICKY
        }

        // 3. Register all interruption listeners
        audioInterruptionManager.registerCallListener()
        audioInterruptionManager.registerMicMuteListener()
        audioInterruptionManager.checkInitialMicState()

        // 4. Init Bluetooth (starts recording inside SCO callback if headset found)
        //    Otherwise starts recording directly
        audioInterruptionManager.initializeBluetooth {
            audioRecorderManager.startRecording()
        }

        // 5. Enqueue process-death safeguard
        chunkWorkManager.enqueueFinalizationSafeguard()

        return START_STICKY
    }

    override fun onDestroy() {
        audioInterruptionManager.abandonAudioFocus()
        audioInterruptionManager.unregisterAll()
        audioInterruptionManager.stopBluetooth()
        audioRecorderManager.stopRecording()
        chunkWorkManager.cancelFinalizationSafeguard()
        super.onDestroy()
    }
}
Enter fullscreen mode Exit fullscreen mode

The Complete Interruption Checklist

Interruption Handled By Behaviour
AudioFocus loss (Spotify, YouTube) AudioInterruptionManager Pauses on loss, auto-resumes on gain
Phone call seizes mic TelephonyCallback / PhoneStateListener Pauses on ringing/offhook, resumes on idle
Android 14 FGS mic crash Manifest + ServiceInfo type flag Declares foregroundServiceType="microphone"
Mic hardware toggle (silent) ACTION_MICROPHONE_MUTE_CHANGED Detects toggle, pauses/resumes accordingly
One-time permission revocation UI LaunchedEffect + SecurityException catch Re-requests permission, doesn't show generic error
Bluetooth SCO disconnect ACTION_SCO_AUDIO_STATE_UPDATED Stops SCO, falls back to device mic
Process death ChunkFinalizationWorker Marks in-flight chunks failed, retries upload
Network failure mid-transcription WhisperUploadWorker backoff retry Retries up to 3× with exponential backoff
Overlapping interruptions Triple-flag logic in AudioInterruptionManager Only resumes when all flags are clear

Key Takeaways

Building reliable audio recording on Android isn't about the happy path — your MediaRecorder.start() and stop() are the easy part. The real engineering is the defensive layer around them.

The three most impactful things to get right, in order:

  1. AudioFocus — without it, your app is a bad citizen and will behave unpredictably across devices
  2. Android 14 foreground service type — one missing line crashes every Android 14 device, no graceful fallback
  3. The triple-flag pause logic — this is what separates a recording app from a reliable recording app; most tutorials don't cover it at all

The rest — Bluetooth SCO, mic hardware toggle, process death safeguards — are what separate a good audio app from a great one.

If you're building anything that touches the microphone continuously, treat these not as edge cases but as first-class requirements.


AudioMemo is open source — you can find the full implementation including AudioInterruptionManager, ChunkFinalizationWorker, and the complete WorkManager pipeline on GitHub. If this was useful, feel free to connect on LinkedIn or drop a comment below.

Top comments (0)