A call keeps the microphone when the screen locks

On Android 14 and later a foreground service has to declare the
microphone, and for video the camera, or the system silences them the
moment the app leaves the foreground. The only service here declared
remoteMessaging, for the poll loop. So a call whose phone locked, or
whose person switched apps, went quiet at the far end while the screen
on this one still said the call was up. The app targets 35 and runs on
nothing older than 33, so every phone it ships to behaves this way.

The service re-declares itself with the microphone type when call media
starts, camera too for video, and back to the plain declaration when the
call ends. It is done the moment the audio track exists rather than at
connect, because the microphone is open from there. If the system
refuses the extra types, the plain declaration keeps the poll loop alive
rather than crashing the service mid-call. A source-reading test pins
the manifest, the service and both call-side hooks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-15 08:10:24 -04:00
co-authored by Claude Fable 5.1
parent e25cd413be
commit 3932e43153
4 changed files with 115 additions and 6 deletions
+8 -1
View File
@@ -19,6 +19,13 @@
held poll is. dataSync would be throttled and is not what this does. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<!-- A call keeps the microphone (and for video the camera) while the
screen is off or another app is in front. On Android 14 and later a
foreground service has to declare those types or the system silences
the microphone the moment the app leaves the foreground, and the far
end hears the call go dead while the screen says it is up. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<!-- ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without
it the service still runs and every alert is silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -93,7 +100,7 @@
<service
android:name=".app.MessengerService"
android:exported="false"
android:foregroundServiceType="remoteMessaging" />
android:foregroundServiceType="remoteMessaging|microphone|camera" />
<!--
Decline, from the notification, without opening the app. The whole
point of answering from a lock screen is that the phone stays locked
@@ -363,6 +363,10 @@ class CallManager(
pc.addTrack(track, listOf("fnstream"))
audioSource = source
audioTrack = track
// Now, not at connect: the microphone is open from here, and Android 14
// silences it the moment the app leaves the foreground unless the
// service has said it is using it.
MessengerService.callMedia(context, video)
if (video) {
// Failure here downgrades to voice rather than killing the call, an
// emulator without a camera, or a denied permission mid-flow,
@@ -1059,6 +1063,7 @@ class CallManager(
connection = null
audioTrack?.let { runCatching { it.dispose() } }
audioTrack = null
MessengerService.callMediaEnded(context)
audioSource?.let { runCatching { it.dispose() } }
audioSource = null
// The camera, released in capture order.
@@ -41,6 +41,33 @@ class MessengerService : Service() {
/** Matches the relay's own hold, so a poll returns as it expires. */
private const val WAIT_SECONDS = 20
/** What a call is using right now, carried on the start intent. */
const val EXTRA_CALL_MEDIA = "io.flamenet.messenger.CALL_MEDIA"
const val MEDIA_NONE = 0
const val MEDIA_AUDIO = 1
const val MEDIA_VIDEO = 2
/**
* A call has media up. Re-declare the service with the microphone
* (and camera) types so Android 14 keeps them alive when the screen
* locks or the person switches apps. Safe to call repeatedly.
*/
fun callMedia(
context: Context,
video: Boolean,
) = reclassify(context, if (video) MEDIA_VIDEO else MEDIA_AUDIO)
/** The call is over; back to the plain message-polling declaration. */
fun callMediaEnded(context: Context) = reclassify(context, MEDIA_NONE)
private fun reclassify(
context: Context,
media: Int,
) {
val intent = Intent(context, MessengerService::class.java).putExtra(EXTRA_CALL_MEDIA, media)
runCatching { context.startForegroundService(intent) }
}
fun start(context: Context) {
val intent = Intent(context, MessengerService::class.java)
runCatching { context.startForegroundService(intent) }
@@ -69,6 +96,9 @@ class MessengerService : Service() {
): Int {
// startForeground within a few seconds of onStartCommand or Android
// kills the process with a ForegroundServiceDidNotStartInTimeException.
if (intent?.hasExtra(EXTRA_CALL_MEDIA) == true) {
callMedia = intent.getIntExtra(EXTRA_CALL_MEDIA, MEDIA_NONE)
}
startInForeground()
if (loop?.isActive == true) return START_STICKY
@@ -123,6 +153,9 @@ class MessengerService : Service() {
}
}
/** What the current call is using, so the declaration below can say so. */
private var callMedia = MEDIA_NONE
private fun startInForeground() {
val open =
PendingIntent.getActivity(
@@ -143,11 +176,20 @@ class MessengerService : Service() {
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
ONGOING_ID,
notice,
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING,
)
// The microphone and camera types are what let a call survive the
// screen locking on Android 14. They can only be declared while
// the permission is held and the app is eligible, which is true
// at the moment media starts; if the system refuses anyway, the
// plain declaration keeps the poll loop alive rather than
// crashing the service mid-call.
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
if (callMedia >= MEDIA_AUDIO) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
if (callMedia >= MEDIA_VIDEO) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
runCatching { startForeground(ONGOING_ID, notice, types) }
.onFailure {
android.util.Log.w("fnmsg-call", "could not declare call media types: $it")
startForeground(ONGOING_ID, notice, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
}
} else {
startForeground(ONGOING_ID, notice)
}
@@ -0,0 +1,55 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* On Android 14 and later a foreground service has to declare the microphone
* (and camera) types or the system silences them the moment the app leaves
* the foreground. The only service declared remoteMessaging, so a call went
* quiet at the far end when the phone locked while the screen still said the
* call was up. The service now re-declares itself when call media starts and
* again when it ends, and the manifest allows it to.
*/
class ACallKeepsTheMicrophoneWhenTheScreenLocksTest {
private fun source(path: String): String {
val f = File(path)
assertTrue(f.isFile, "cannot find $path, this test reads the sources")
return f
.readText()
.lines()
.filterNot { it.trimStart().startsWith("//") }
.joinToString("\n")
}
private val manifest = File("src/main/AndroidManifest.xml").readText()
private val service = source("src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt")
private val calls = source("src/main/kotlin/io/flamenet/messenger/app/CallManager.kt")
@Test
fun `the manifest allows the service to hold the microphone and camera`() {
assertTrue(manifest.contains("android.permission.FOREGROUND_SERVICE_MICROPHONE"))
assertTrue(manifest.contains("android.permission.FOREGROUND_SERVICE_CAMERA"))
assertTrue(
Regex("""foregroundServiceType="[^"]*microphone[^"]*camera[^"]*"""").containsMatchIn(manifest),
"the service does not declare the microphone and camera types",
)
}
@Test
fun `the service declares the microphone while a call has media`() {
assertTrue(service.contains("FOREGROUND_SERVICE_TYPE_MICROPHONE"))
assertTrue(service.contains("FOREGROUND_SERVICE_TYPE_CAMERA"))
assertTrue(service.contains("fun callMedia("))
assertTrue(service.contains("fun callMediaEnded("))
}
@Test
fun `a call tells the service when its media starts and ends`() {
val start = calls.indexOf("MessengerService.callMedia(context, video)")
val audio = calls.indexOf("audioTrack = track")
assertTrue(start > 0 && audio > 0 && start > audio, "callMedia must follow the audio track being created")
assertTrue(calls.contains("MessengerService.callMediaEnded(context)"), "the call never gives the microphone back")
}
}