radio/android: PTT FGS microphone permission + детект потери микрофона

- FOREGROUND_SERVICE_MICROPHONE запрос + foregroundServiceType microphone|specialUse (Android 14+)
- RadioOverlayService.start: не стартовать без mic-разрешения, ServiceCompat.startForeground с TYPE_MICROPHONE
- RadioAudioEngine: micDead-флаг и лог при обрыве captureLoop/read
- radio.c: лог первого кадра burst на транзитных узлах
- SettingsScreen: категория radio в списке debug-категорий
This commit is contained in:
evgeny committed 2026-09-19 23:13:04 +03:00
1 parent 688785fc6e
commit 6b92964794
6 files changed
+58 -6

No files matched your search

@@ -49,6 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
requestNotificationPermission()
requestFgsMicPermission()
handleIncomingCallIntent(intent)
/* Edge-to-edge with dark status/navigation bar icons (light app theme) */
@@ -398,6 +399,14 @@ class MainActivity : ComponentActivity() {
}
}
private fun requestFgsMicPermission() {
if (Build.VERSION.SDK_INT >= 34 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) !=
PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.FOREGROUND_SERVICE_MICROPHONE), 2)
}
}
override fun onStop() {
super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false)
@@ -45,6 +45,7 @@ class RadioAudioEngine {
@Volatile private var active = false
@Volatile private var groupId = 0L
@Volatile private var talking = false
@Volatile private var micDead = false
private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow
@@ -115,6 +116,7 @@ class RadioAudioEngine {
}
active = true
micDead = false
audioRecord?.startRecording()
audioTrack?.play()
@@ -153,6 +155,9 @@ class RadioAudioEngine {
/** PTT зажата — начинаем передачу. */
fun talkBegin() {
if (!active || talking) return
if (micDead) {
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)")
}
talking = true
_talkingFlow.value = true
NativeLib.radioTalkBegin(groupId)
@@ -168,12 +173,26 @@ class RadioAudioEngine {
private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES)
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
while (active) {
val rec = audioRecord ?: break
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
if (active) {
micDead = true
LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped")
}
break
}
var read = 0
while (read < FRAME_SAMPLES) {
if (!active) return
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return
if (n <= 0) return
val n = rec.read(buf, read, FRAME_SAMPLES - read)
if (n <= 0) {
if (active) {
micDead = true
LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost")
}
return
}
read += n
}
if (talking && !NativeLib.radioAudioFeed(groupId, buf)) {
@@ -1,10 +1,13 @@
package com.utun.chat.data
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Build
@@ -18,6 +21,8 @@ import android.view.ViewConfiguration
import android.view.WindowManager
import android.widget.TextView
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -44,6 +49,12 @@ class RadioOverlayService : Service() {
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
fun start(ctx: Context) {
if (Build.VERSION.SDK_INT >= 34 &&
ContextCompat.checkSelfPermission(ctx, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) !=
PackageManager.PERMISSION_GRANTED) {
LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted")
return
}
val i = Intent(ctx, RadioOverlayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(i)
@@ -66,7 +77,12 @@ class RadioOverlayService : Service() {
override fun onCreate() {
super.onCreate()
createChannel()
startForeground(NOTIF_ID, buildNotification())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE)
} else {
startForeground(NOTIF_ID, buildNotification())
}
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
if (!canDrawOverlays(this)) {
@@ -46,7 +46,7 @@ private val CATEGORIES = listOf(
"config", "tun", "routing", "timers", "bgp", "chat",
"socket", "control", "dump", "traffic", "debug", "general",
"nat", "keepalive", "media", "etcp_route", "etcp_dump",
"chat_sync", "member_sync", "proxy"
"chat_sync", "member_sync", "proxy", "radio"
)
private val PROTOCOLS = listOf("tcp", "udp")