Browse Source

chatgui-android: PTT по volume-кнопкам (вверх/вниз) + синхронизация оверлея

- volume up/down обе держат PTT (holdCount), настройка radio_volume_ptt
- syncRadioOverlay при включении/выключении рации
proxy
evgeny 2 weeks ago
parent
commit
4a785772be
  1. 33
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  3. 116
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  4. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

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

@ -403,14 +403,33 @@ class MainActivity : ComponentActivity() {
if (NativeLib.isReady()) NativeLib.setActive(false) if (NativeLib.isReady()) NativeLib.setActive(false)
} }
/** Volume-up (удержание) → PTT рации, при активной рации и включённой опции. */ /** Volume-кнопки (удержание) → PTT рации, при активной рации и включённой опции. */
private var pttHoldCount = 0
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP && val isVol = event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
ChatApplication.instance.configProvider.getRadioVolumeUpPtt() && val pttOn = ChatApplication.instance.configProvider.getRadioVolumePtt()
RadioAudioEngine.instance().isActive()) { && RadioAudioEngine.instance().isActive()
when (event.action) { if (!pttOn || !isVol) {
KeyEvent.ACTION_DOWN -> { RadioAudioEngine.instance().talkBegin(); return true } pttHoldCount = 0
KeyEvent.ACTION_UP -> { RadioAudioEngine.instance().talkEnd(); return true } return super.dispatchKeyEvent(event)
}
when (event.action) {
KeyEvent.ACTION_DOWN -> {
if (event.repeatCount == 0) {
if (pttHoldCount == 0) RadioAudioEngine.instance().talkBegin()
pttHoldCount++
LogManager.addLog("DEBUG", "PTT", "key=${event.keyCode} down hold=$pttHoldCount")
}
return true
}
KeyEvent.ACTION_UP -> {
if (pttHoldCount > 0) {
pttHoldCount--
if (pttHoldCount == 0) RadioAudioEngine.instance().talkEnd()
}
LogManager.addLog("DEBUG", "PTT", "key=${event.keyCode} up hold=$pttHoldCount")
return true
} }
} }
return super.dispatchKeyEvent(event) return super.dispatchKeyEvent(event)

12
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -53,7 +53,7 @@ class ConfigProvider(private val context: Context) {
private val soundEnabledKey = booleanPreferencesKey("sound_enabled") private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route") private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val radioVolumeUpPttKey = booleanPreferencesKey("radio_volume_up_ptt") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay") private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private fun keysFilePath(): String { private fun keysFilePath(): String {
@ -261,14 +261,14 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[callAudioRouteKey] = r.name } context.dataStore.edit { prefs -> prefs[callAudioRouteKey] = r.name }
} }
/* ── Рация: volume-up PTT и оверлей ── */ /* ── Рация: volume-кнопки PTT и оверлей ── */
fun getRadioVolumeUpPtt(): Boolean = runBlocking { fun getRadioVolumePtt(): Boolean = runBlocking {
context.dataStore.data.first()[radioVolumeUpPttKey] ?: false context.dataStore.data.first()[radioVolumePttKey] ?: false
} }
suspend fun setRadioVolumeUpPtt(enabled: Boolean) { suspend fun setRadioVolumePtt(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[radioVolumeUpPttKey] = enabled } context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled }
} }
fun getRadioOverlay(): Boolean = runBlocking { fun getRadioOverlay(): Boolean = runBlocking {

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

@ -14,12 +14,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.RadioOverlayService import com.utun.chat.data.RadioOverlayService
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.LogManager import com.utun.chat.data.LogManager
import com.utun.chat.data.ServerEntry import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState import com.utun.chat.data.LocalSocketState
@ -56,7 +58,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
var dirty by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) }
var tabIndex by remember { mutableStateOf(0) } var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("Settings", "Audio", "Storage") val tabs = listOf("Settings", "Audio", "Storage", "Radio")
val handleBack = { val handleBack = {
if (dirty && NativeLib.isReady()) { if (dirty && NativeLib.isReady()) {
@ -88,6 +90,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit) 0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit)
1 -> AudioSettingsTab(vm) 1 -> AudioSettingsTab(vm)
2 -> StorageSettingsTab() 2 -> StorageSettingsTab()
3 -> RadioSettingsTab()
} }
} }
} }
@ -359,49 +362,6 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
HorizontalDivider(Modifier.padding(vertical = 8.dp)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* ── Рация (PTT) ── */
Text("Рация (PTT)", style = MaterialTheme.typography.titleMedium)
var volumeUpPtt by remember { mutableStateOf(provider.getRadioVolumeUpPtt()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Volume-up (удержание) — передача", modifier = Modifier.weight(1f))
Switch(checked = volumeUpPtt, onCheckedChange = {
volumeUpPtt = it
scope.launch { provider.setRadioVolumeUpPtt(it) }
})
}
var overlayEnabled by remember { mutableStateOf(provider.getRadioOverlay()) }
val canOverlay = RadioOverlayService.canDrawOverlays(ChatApplication.instance)
Row(
modifier = Modifier.fillMaxWidth().clickable(enabled = !canOverlay) {
if (!canOverlay) {
try {
ChatApplication.instance.startActivity(
Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${ChatApplication.instance.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open overlay permission failed: ${e.message}")
}
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("PTT-кнопка поверх приложений")
if (!canOverlay) Text("Требуется разрешение «поверх других приложений» — нажмите",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
overlayEnabled = it
scope.launch { provider.setRadioOverlay(it) }
if (it) RadioOverlayService.start(ChatApplication.instance)
else RadioOverlayService.stop(ChatApplication.instance)
})
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* Debug */ /* Debug */
Text("Debug", style = MaterialTheme.typography.titleMedium) Text("Debug", style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug) DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
@ -502,6 +462,68 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
dismissButton = { TextButton(onClick = { showClearDb = false }) { Text("Cancel") } }) dismissButton = { TextButton(onClick = { showClearDb = false }) { Text("Cancel") } })
} }
/* ── Radio Settings Tab ── */
@Composable
private fun RadioSettingsTab() {
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
Column(
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
/* ── Volume-кнопки PTT ── */
var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Кнопки громкости (удержание) — передача", modifier = Modifier.weight(1f))
Switch(checked = volumePtt, onCheckedChange = {
volumePtt = it
scope.launch { provider.setRadioVolumePtt(it) }
})
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Оверлей PTT поверх приложений ── */
var overlayEnabled by remember { mutableStateOf(provider.getRadioOverlay()) }
var canOverlay by remember { mutableStateOf(RadioOverlayService.canDrawOverlays(ChatApplication.instance)) }
LifecycleResumeEffect(Unit) {
canOverlay = RadioOverlayService.canDrawOverlays(ChatApplication.instance)
onPauseOrDispose { }
}
val openOverlayPermission = {
try {
val i = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${ChatApplication.instance.packageName}"))
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
ChatApplication.instance.startActivity(i)
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open overlay permission failed: ${e.message}")
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("PTT-кнопка поверх приложений")
if (!canOverlay) Text(
"Требуется разрешение «поверх других приложений» — откройте настройки",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier = Modifier.clickable { openOverlayPermission() })
}
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
overlayEnabled = it
scope.launch { provider.setRadioOverlay(it) }
if (it && RadioAudioEngine.instance().isActive())
RadioOverlayService.start(ChatApplication.instance)
else
RadioOverlayService.stop(ChatApplication.instance)
})
}
}
}
/* ── Audio Settings Tab ── */ /* ── Audio Settings Tab ── */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -582,8 +604,10 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
) { ) {
val ctx = ChatApplication.instance val ctx = ChatApplication.instance
try { try {
ctx.startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, val i = Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:${ctx.packageName}"))) Uri.parse("package:${ctx.packageName}"))
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
ctx.startActivity(i)
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open full-screen-intent settings failed: ${e.message}") LogManager.addLog("ERROR", "SETTINGS", "open full-screen-intent settings failed: ${e.message}")
} }

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

@ -740,6 +740,7 @@ class ChatViewModel : ViewModel() {
_radioTalking.value = null _radioTalking.value = null
radioAudio.stop() radioAudio.stop()
} }
syncRadioOverlay()
LogManager.addLog("INFO", "VM", "toggleRadio -> ${if (next) "on" else "off"} group=$groupId") LogManager.addLog("INFO", "VM", "toggleRadio -> ${if (next) "on" else "off"} group=$groupId")
} }
@ -763,6 +764,14 @@ class ChatViewModel : ViewModel() {
radioAudio.stop() radioAudio.stop()
} }
private fun syncRadioOverlay() {
val ctx = ChatApplication.instance
if (_radioActive.value && ctx.configProvider.getRadioOverlay())
RadioOverlayService.start(ctx)
else
RadioOverlayService.stop(ctx)
}
fun radioName(nodeId: Long): String = fun radioName(nodeId: Long): String =
members.value.firstOrNull { it.nodeId == nodeId }?.name members.value.firstOrNull { it.nodeId == nodeId }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId) ?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId)

Loading…
Cancel
Save