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:
co-authored by
Claude Fable 5.1
parent
e25cd413be
commit
3932e43153
@@ -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)
|
||||
}
|
||||
|
||||
+55
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user