Browse Source

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-категорий
proxy
evgeny 2 weeks ago
parent
commit
6b92964794
  1. 7
      src/radio/radio.c
  2. 3
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  3. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  4. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  5. 18
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  6. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

7
src/radio/radio.c

@ -292,6 +292,13 @@ static void radio_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn,
return;
}
/* начало передачи (первый кадр burst): видно и на транзитных узлах (коло), не только у подписчиков */
if (seq == 0) {
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: recv burst src=%016llx stream=%u fin=%u len=%d sub=%d",
RADIO_ID, (unsigned long long)src, stream, (flags & RADIO_FLAG_FIN) ? 1 : 0,
opus_len, group->radio_active ? 1 : 0);
}
/* доставка локально, если мы подписаны */
if (group->radio_active && ctx->group) {
struct radio_instance* ri = radio_inst(group->instance);

3
tools/chatgui-android/app/src/main/AndroidManifest.xml

@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
@ -51,7 +52,7 @@
<service
android:name=".data.RadioOverlayService"
android:exported="false"
android:foregroundServiceType="specialUse">
android:foregroundServiceType="microphone|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="ptt_overlay_service" />

9
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -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)

25
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -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)) {

18
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt

@ -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)) {

2
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -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")

Loading…
Cancel
Save