Browse Source

Improve Android onboarding, member UI and PTT permissions

master
evgeny 2 days ago
parent
commit
ce5f5bb7d0
  1. BIN
      bg.png
  2. 12
      doc/pm.txt
  3. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  4. 154
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  5. 11
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
  6. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  7. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt
  8. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  9. 213
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  10. 95
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  11. 71
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt
  12. 171
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt
  13. 190
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  14. 165
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt
  15. 188
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt
  16. 37
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  17. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

BIN
bg.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

12
doc/pm.txt

@ -0,0 +1,12 @@
Работа лички
1. отдели логику лички от логики групповых чатов. не смешивай.
Работа лички. сообщения и медиа доставляется напрямую если узел доступен через bgp.
если недоступен - то pending сообщения сохраняются на суперузле и pending media сохраняется на каком-либо медиаузле, а суперузлу сообщается где хранится медиа.
при появлении пира суперузел организует доставку медиа до получателя. после успешной доставки медиа/сообщений они удаляются с промежуточных узлов.
время хранения медиафайлов на промежуточных узлах ограничено - 1 неделя по умолчанию (задачется в конфиге этих узлов).
для доставки медиа поднимаем линк (conn_mgr).
и посмотри реализацию медиа для групповых чатов.

8
tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt

@ -53,13 +53,7 @@ class ChatApplication : Application() {
TelecomCallManager.init()
CallController.init()
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
LogManager.addLog("INFO", "APP", "First launch — marking launched")
configProvider.markLaunched()
}
}
}
companion object {

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

@ -2,6 +2,11 @@ package com.utun.chat
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import com.utun.chat.data.MicrophonePermission
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@ -35,6 +40,7 @@ import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.theme.VibeChatTheme
@ -44,15 +50,69 @@ import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call, Dm }
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, VideoRecord, PhotoRecord, Call, Dm }
private var overLock = false
private var microphoneAction: (() -> Unit)? = null
private var microphoneReason = ""
private val overlayPermissionRequester: () -> Unit = {
try {
overlayPermissionLauncher.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioOverlay", "Cannot open overlay permission settings: $e")
RadioOverlayService.overlayPermissionResult()
}
}
private val overlayPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
RadioOverlayService.overlayPermissionResult()
}
private val microphoneRequester: (String, () -> Unit) -> Unit = { reason, action -> requestMicrophone(reason, action) }
private val microphoneLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val action = microphoneAction
microphoneAction = null
LogManager.addLog(if (granted) "INFO" else "WARN", "PERMISSION", "Microphone result=$granted reason=$microphoneReason")
if (granted) action?.invoke()
else {
Toast.makeText(this, "Для звонков и PTT разрешите доступ к микрофону", Toast.LENGTH_LONG).show()
if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) showMicrophoneSettings()
}
}
private fun requestMicrophone(reason: String, action: () -> Unit) {
if (MicrophonePermission.granted()) { action(); return }
if (microphoneAction != null) {
LogManager.addLog("WARN", "PERMISSION", "Microphone request already pending: $microphoneReason; ignored=$reason")
return
}
microphoneReason = reason
microphoneAction = action
LogManager.addLog("INFO", "PERMISSION", "Requesting RECORD_AUDIO: $reason")
try {
microphoneLauncher.launch(Manifest.permission.RECORD_AUDIO)
} catch (e: Exception) {
microphoneAction = null
LogManager.addLog("ERROR", "PERMISSION", "Cannot request microphone permission: $e")
Toast.makeText(this, "Не удалось запросить доступ к микрофону", Toast.LENGTH_LONG).show()
}
}
private fun showMicrophoneSettings() {
android.app.AlertDialog.Builder(this)
.setMessage("Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT.")
.setPositiveButton("Настройки") { _, _ ->
try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "PERMISSION", "Cannot open app settings: $e")
}
}
.setNegativeButton("Отмена", null).show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermission()
requestFgsMicPermission()
/* Входящий звонок: showWhenLocked/turnScreenOn ДО setContent, иначе полноэкранный
intent мигает и UI не отрисовывается поверх keyguard (холодный старт). */
@ -67,14 +127,27 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
/* Start uTun headless service (must be from foreground Activity on API 34+).
Only if it was running when the app last exited (remembered via DataStore). */
if (ChatApplication.instance.configProvider.getServiceEnabled()) {
ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java))
}
setContent {
VibeChatTheme {
val provider = ChatApplication.instance.configProvider
var firstLaunch by remember { mutableStateOf(provider.isFirstLaunch()) }
LaunchedEffect(firstLaunch) {
if (firstLaunch) {
LogManager.addLog("INFO", "SETUP", "Showing first launch wizard")
} else if (provider.getServiceEnabled()) {
ContextCompat.startForegroundService(this@MainActivity, Intent(this@MainActivity, HeadlessService::class.java))
}
}
if (firstLaunch) {
FirstLaunchScreen(
initialName = provider.getString("node.name") ?: provider.getDeviceName(),
onComplete = { name ->
provider.completeFirstLaunch(name)
firstLaunch = false
}
)
return@VibeChatTheme
}
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory())
/* Pause GUI polling (health-check, recording/playback timers) when backgrounded,
@ -85,9 +158,7 @@ class MainActivity : ComponentActivity() {
}
var screen by remember { mutableStateOf(Screen.ChannelList) }
var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
@ -204,7 +275,7 @@ class MainActivity : ComponentActivity() {
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onJoinChannel = { showJoinDialog = true },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
@ -265,15 +336,6 @@ class MainActivity : ComponentActivity() {
onClose = { vm.dismissCall(); screen = screenBeforeCall ?: Screen.ChannelList },
onMinimize = { screen = screenBeforeCall ?: Screen.ChannelList }
)
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onBack = { screen = Screen.ChannelList }
)
}
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
@ -319,22 +381,8 @@ class MainActivity : ComponentActivity() {
}
}
/* Show join dialog after QR scan returns */
LaunchedEffect(qrResult) {
qrResult?.let { link ->
joinAutoLink = link
showJoinDialog = true
qrResult = null
}
}
if (showJoinDialog) {
JoinChannelDialog(
autoLink = joinAutoLink,
onScanQr = {
screen = Screen.QrScan
showJoinDialog = false
},
onConnect = { data: InviteData ->
val addrs = data.serializeAddrs()
NativeLib.joinChannel(data.channelId, data.nodeId, data.joinKey, data.pubkey, addrs, data.addrs.size)
@ -361,13 +409,39 @@ class MainActivity : ComponentActivity() {
}
override fun onStart() {
MicrophonePermission.requester = microphoneRequester
RadioOverlayService.overlayPermissionRequester = overlayPermissionRequester
super.onStart()
if (intent.getBooleanExtra(MicrophonePermission.EXTRA_REQUEST, false)) {
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
requestMicrophone("PTT") {
RadioOverlayService.sync(this)
Toast.makeText(this, "Микрофон разрешён. Нажмите PTT снова", Toast.LENGTH_LONG).show()
}
}
CallController.setActivityVisible(true)
}
override fun onResume() {
super.onResume()
RadioOverlayService.setActivityVisible(true)
}
override fun onPause() {
RadioOverlayService.setActivityVisible(false)
super.onPause()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.getBooleanExtra(MicrophonePermission.EXTRA_REQUEST, false)) {
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
requestMicrophone("PTT") {
RadioOverlayService.sync(this)
Toast.makeText(this, "Микрофон разрешён. Нажмите PTT снова", Toast.LENGTH_LONG).show()
}
}
if (incomingCallId(intent) != 0L) {
LogManager.addLog("INFO", "MAIN", "launched for incoming call 0x%016x".format(incomingCallId(intent)))
showOverLockScreen()
@ -411,16 +485,10 @@ 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 (MicrophonePermission.requester === microphoneRequester) MicrophonePermission.requester = null
if (RadioOverlayService.overlayPermissionRequester === overlayPermissionRequester) RadioOverlayService.overlayPermissionRequester = null
CallController.setActivityVisible(false)
}
@ -438,7 +506,7 @@ class MainActivity : ComponentActivity() {
when (event.action) {
KeyEvent.ACTION_DOWN -> {
if (event.repeatCount == 0) {
if (pttHoldCount == 0) RadioAudioEngine.instance().talkBegin()
if (pttHoldCount == 0 && !RadioAudioEngine.instance().talkBegin()) return true
pttHoldCount++
LogManager.addLog("DEBUG", "PTT", "key=${event.keyCode} down hold=$pttHoldCount")
}

11
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt

@ -94,6 +94,10 @@ object CallController : TelecomCallManager.Listener {
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
return
}
if (!MicrophonePermission.granted()) {
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) }
return
}
var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING)
@ -108,6 +112,13 @@ object CallController : TelecomCallManager.Listener {
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
return
}
if (!MicrophonePermission.granted()) {
MicrophonePermission.request("Answer call id=${st.callId}") {
if (_state.value?.callId == st.callId && _state.value?.phase == CallPhase.INCOMING) accept()
else LogManager.addLog("WARN", "Call", "Permission granted after incoming call ended id=${st.callId}")
}
return
}
accepting = true
requestService(st.callId)
}

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

@ -235,8 +235,14 @@ class ConfigProvider(private val context: Context) {
!(context.dataStore.data.first()[firstLaunchDone] ?: false)
}
suspend fun markLaunched() {
context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true }
suspend fun completeFirstLaunch(name: String) {
val nick = name.trim()
require(nick.isNotEmpty() && nick.none { it.isISOControl() }) { "Invalid device name" }
context.dataStore.edit { prefs ->
prefs[nodeName] = nick
prefs[firstLaunchDone] = true
}
LogManager.addLog("INFO", "SETUP", "First launch completed: device name=$nick")
}
/* ── Connection mode ── */

38
tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt

@ -0,0 +1,38 @@
package com.utun.chat.data
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Looper
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
/* Запросы только из main thread; диалогом разрешений владеет видимая Activity. */
object MicrophonePermission {
const val EXTRA_REQUEST = "request_microphone_permission"
var requester: ((String, () -> Unit) -> Unit)? = null
fun granted(): Boolean = ContextCompat.checkSelfPermission(ChatApplication.instance, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
fun request(reason: String, onGranted: () -> Unit = {}) {
check(Looper.myLooper() == Looper.getMainLooper())
if (granted()) { onGranted(); return }
LogManager.addLog("INFO", "PERMISSION", "Microphone required: $reason")
val handler = requester
if (handler != null) {
handler(reason, onGranted)
} else {
// PTT из оверлея: открыть видимый экран; после выдачи нужна новая команда передачи.
try {
ChatApplication.instance.startActivity(Intent(ChatApplication.instance, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(EXTRA_REQUEST, true)
})
} catch (e: Exception) {
LogManager.addLog("ERROR", "PERMISSION", "Cannot open microphone permission screen: $e")
}
}
}
}

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

@ -406,6 +406,10 @@ class RadioAudioEngine {
}
private fun startCapture(vad: Boolean): Boolean {
if (!MicrophonePermission.granted()) {
LogManager.addLog("WARN", "RadioAudio", "Capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad")
return false
}
val startNs = SystemClock.elapsedRealtimeNanos()
val (rec, channels) = createAudioRecord()
if (rec == null) return false
@ -508,10 +512,18 @@ class RadioAudioEngine {
restoreAudioMode()
}
fun talkBegin() {
if (!active) return
fun talkBegin(): Boolean {
if (!active) return false
if (!MicrophonePermission.granted()) {
MicrophonePermission.request("PTT") {
RadioOverlayService.sync(ChatApplication.instance)
android.widget.Toast.makeText(ChatApplication.instance, "Микрофон разрешён. Нажмите PTT снова",
android.widget.Toast.LENGTH_LONG).show()
}
return false
}
mainHandler.removeCallbacks(releaseTalk)
if (requestedTalk) return
if (requestedTalk) return true
talkRequestNs = SystemClock.elapsedRealtimeNanos()
requestedTalk = true
_talkingFlow.value = true
@ -523,6 +535,7 @@ class RadioAudioEngine {
updateCapture()
}
}
return true
}
/** Отпускание: 250мс передачи, повторное касание отменяет завершение. */
@ -533,6 +546,12 @@ class RadioAudioEngine {
LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms")
}
/** Отмена без хвоста передачи при выключении/потере разрешений. */
fun cancelTalk() {
mainHandler.removeCallbacks(releaseTalk)
finishTalk()
}
private fun finishTalk() {
if (!requestedTalk) return
requestedTalk = false

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

@ -1,12 +1,10 @@
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.Point
@ -24,7 +22,6 @@ import android.view.WindowManager
import android.widget.TextView
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout
import com.utun.chat.ChatApplication
import kotlin.math.abs
@ -32,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -72,45 +71,133 @@ class RadioOverlayService : Service() {
@Volatile private var running = false
@Volatile private var starting = false
@Volatile private var pendingStop = false
private var restartAfterStop = false
fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
fun sync(ctx: Context) {
val provider = ChatApplication.instance.configProvider
if (RadioAudioEngine.instance().isActive() &&
(provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0)) start(ctx)
else stop(ctx)
private var activityVisible = false
private var waitingOverlayPermission = false
var overlayPermissionRequester: (() -> Unit)? = null
private val _status = MutableStateFlow<String?>(null)
val status: StateFlow<String?> = _status
private fun setStatus(message: String?) {
if (_status.value == message) return
_status.value = message
LogManager.addLog("INFO", "RadioOverlay", "state=${message ?: "disabled"} shown=${_isShown.value}")
}
fun start(ctx: Context) {
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}")
pendingStop = false
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")
suspend fun setEnabled(enabled: Boolean) {
ChatApplication.instance.configProvider.setRadioOverlay(enabled)
LogManager.addLog("INFO", "RadioOverlay", "User requested overlay enabled=$enabled")
if (enabled) requestPermissions() else {
waitingOverlayPermission = false
sync(ChatApplication.instance)
}
}
fun setActivityVisible(visible: Boolean) {
activityVisible = visible
if (visible) sync(ChatApplication.instance)
}
// Запросы разрешений выполняются только после явной команды пользователя.
fun requestPermissions() {
val ctx = ChatApplication.instance
val provider = ctx.configProvider
if (provider.getRadioOverlay() && !canDrawOverlays(ctx)) {
setStatus("Нужно разрешение поверх приложений")
if (waitingOverlayPermission) return
val request = overlayPermissionRequester
if (request == null) {
LogManager.addLog("WARN", "RadioOverlay", "Overlay permission request deferred: Activity unavailable")
return
}
waitingOverlayPermission = true
LogManager.addLog("INFO", "RadioOverlay", "Requesting overlay permission")
request()
return
}
if (starting) return
starting = !running
val i = Intent(ctx, RadioOverlayService::class.java)
if ((provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0) && !MicrophonePermission.granted()) {
setStatus("Нужен доступ к микрофону")
MicrophonePermission.request("Floating PTT / VAD") { sync(ctx) }
return
}
sync(ctx)
}
fun overlayPermissionResult() {
val wasWaiting = waitingOverlayPermission
waitingOverlayPermission = false
val ctx = ChatApplication.instance
val granted = canDrawOverlays(ctx)
LogManager.addLog(if (granted) "INFO" else "WARN", "RadioOverlay", "Overlay permission result=$granted pending=$wasWaiting")
val provider = ctx.configProvider
if (wasWaiting && granted && provider.getRadioOverlay()) requestPermissions()
else sync(ctx)
}
fun sync(ctx: Context) {
val provider = ChatApplication.instance.configProvider
val radio = RadioAudioEngine.instance()
val enabled = provider.getRadioOverlay()
val vad = provider.getInt("radio.vad_enabled") != 0
val overlayAllowed = canDrawOverlays(ctx)
val micAllowed = MicrophonePermission.granted()
when {
!enabled -> setStatus(null)
!overlayAllowed -> setStatus("Нужно разрешение поверх приложений")
!micAllowed -> setStatus("Нужен доступ к микрофону")
!radio.isActive() -> setStatus("Кнопка появится при включении рации")
}
if (!radio.isActive() || (!vad && (!enabled || !overlayAllowed)) || !micAllowed) {
if (!micAllowed && running) {
LogManager.addLog("WARN", "RadioOverlay", "Microphone permission revoked; stopping capture and service")
radio.cancelTalk()
radio.reconfigure()
}
stop(ctx)
return
}
if (running && pendingStop) {
restartAfterStop = true
return
}
if (running) {
currentService?.refresh()
return
}
if (starting) { pendingStop = false; return }
if (!activityVisible) {
if (enabled && overlayAllowed) setStatus("Для запуска кнопки откройте приложение")
LogManager.addLog("DEBUG", "RadioOverlay", "Service start deferred until Activity resume")
return
}
if (enabled && overlayAllowed) setStatus("Запуск плавающей кнопки…")
pendingStop = false
starting = true
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
else ctx.startService(i)
ctx.startForegroundService(Intent(ctx, RadioOverlayService::class.java))
LogManager.addLog("INFO", "RadioOverlay", "Microphone service requested from visible Activity overlay=$enabled vad=$vad")
} catch (e: Exception) {
starting = false
LogManager.addLog("ERROR", "RadioOverlay", "service start failed: ${e.message}")
if (enabled) setStatus("Не удалось запустить плавающую кнопку")
LogManager.addLog("ERROR", "RadioOverlay", "Service start failed: $e")
}
}
fun stop(ctx: Context) {
restartAfterStop = false
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.stop isShown=${_isShown.value} t=${System.currentTimeMillis()}")
if (starting) {
pendingStop = true
return
}
ctx.stopService(Intent(ctx, RadioOverlayService::class.java))
if (running && !pendingStop) {
pendingStop = true
ctx.stopService(Intent(ctx, RadioOverlayService::class.java))
}
}
}
@ -128,11 +215,19 @@ class RadioOverlayService : Service() {
super.onCreate()
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.onCreate thread=${Thread.currentThread().name} t=${System.currentTimeMillis()}")
createChannel()
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())
try {
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())
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioOverlay", "Foreground microphone start failed: $e")
starting = false
setStatus("Не удалось запустить сервис микрофона")
stopSelf()
return
}
running = true
@ -142,29 +237,55 @@ class RadioOverlayService : Service() {
RadioAudioEngine.instance().talkingFlow.collect { updateButtonColor() }
}
starting = false
LogManager.addLog("INFO", "RadioOverlay", "Foreground microphone service ready")
RadioAudioEngine.instance().reconfigure()
serviceScope.launch {
while (isActive) {
delay(1000)
if (!MicrophonePermission.granted() ||
(overlayView != null && !canDrawOverlays(this@RadioOverlayService))) sync(this@RadioOverlayService)
}
}
if (pendingStop) {
pendingStop = false
stopSelf()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (pendingStop || !RadioAudioEngine.instance().isActive()) {
refresh()
return START_NOT_STICKY
}
private fun refresh() {
if (!running || pendingStop || !RadioAudioEngine.instance().isActive() || !MicrophonePermission.granted()) {
removeOverlay()
stopSelf()
return START_NOT_STICKY
return
}
val provider = ChatApplication.instance.configProvider
val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this)
if (wantOverlay && overlayView == null) addOverlay()
if (!wantOverlay && overlayView != null) {
endTalk()
windowManager?.removeView(overlayView)
overlayView = null
_isShown.value = false
if (wantOverlay && overlayView == null) {
try { addOverlay() } catch (e: Exception) {
setStatus("Не удалось показать плавающую кнопку")
LogManager.addLog("ERROR", "RadioOverlay", "Overlay creation failed: $e")
}
}
if (!wantOverlay) removeOverlay()
if (overlayView != null) setStatus("Плавающая кнопка активна")
if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf()
LogManager.addLog("INFO", "RadioOverlay", "service active overlay=$wantOverlay vad=${provider.getInt("radio.vad_enabled")}")
return START_NOT_STICKY
}
private fun removeOverlay() {
val view = overlayView ?: return
talking = false
RadioAudioEngine.instance().cancelTalk()
try { windowManager?.removeView(view) } catch (e: Exception) {
LogManager.addLog("WARN", "RadioOverlay", "Overlay removal failed: $e")
}
overlayView = null
params = null
_isShown.value = false
LogManager.addLog("INFO", "RadioOverlay", "Overlay removed; transmission cancelled")
}
private fun createChannel() {
@ -261,7 +382,7 @@ class RadioOverlayService : Service() {
return true
}
MotionEvent.ACTION_UP -> {
val tapped = !dragging && event.eventTime - event.downTime <= RadioAudioEngine.RELEASE_HOLD_MS
val tapped = talking && !dragging && event.eventTime - event.downTime <= RadioAudioEngine.RELEASE_HOLD_MS
if (!wasLatched && tapped && secondTap) {
latched = true
lastTapUpTime = 0L
@ -323,8 +444,8 @@ class RadioOverlayService : Service() {
private fun beginTalk() {
if (talking) return
if (!RadioAudioEngine.instance().talkBegin()) return
talking = true
RadioAudioEngine.instance().talkBegin()
updateButtonColor()
}
@ -345,14 +466,20 @@ class RadioOverlayService : Service() {
override fun onDestroy() {
serviceScope.cancel()
endTalk()
overlayView?.let { windowManager?.removeView(it) }
overlayView = null
removeOverlay()
starting = false
running = false
pendingStop = false
if (currentService === this) currentService = null
_isShown.value = false
if (_status.value == "Плавающая кнопка активна" || _status.value == "Запуск плавающей кнопки…") {
setStatus(if (ChatApplication.instance.configProvider.getRadioOverlay()) "Плавающая кнопка остановлена" else null)
}
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy()
if (restartAfterStop) {
restartAfterStop = false
sync(ChatApplication.instance)
}
}
}

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

@ -1,8 +1,6 @@
package com.utun.chat.ui.screens
import android.content.ContentResolver
import android.content.Intent
import android.provider.Settings
import android.net.Uri
import android.os.SystemClock
import android.provider.OpenableColumns
@ -319,7 +317,7 @@ fun ChatScreen(
private fun RadioPttButton(
modifier: Modifier = Modifier,
isTalking: Boolean = false,
onTalkBegin: () -> Unit,
onTalkBegin: () -> Boolean,
onTalkEnd: () -> Unit
) {
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS)
@ -341,11 +339,12 @@ private fun RadioPttButton(
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
val wasLatched = latched
var started = false
if (wasLatched) {
latched = false
lastTapUp = 0L
onTalkEnd()
} else onTalkBegin()
} else started = onTalkBegin()
var released = false
try {
@ -356,7 +355,7 @@ private fun RadioPttButton(
if (!c.pressed) { released = true; break }
}
val upTime = SystemClock.uptimeMillis()
val tapped = released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS
val tapped = started && released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS
if (!wasLatched && tapped && isSecondTap) {
latched = true
lastTapUp = 0L
@ -390,67 +389,57 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
val btInputAvailable by viewModel.btInputAvailable.collectAsState()
val radioRoute by viewModel.radioRoute.collectAsState()
val context = LocalContext.current
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
val overlayPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (RadioOverlayService.canDrawOverlays(context)) {
scope.launch {
provider.setRadioOverlay(true)
RadioOverlayService.sync(context)
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled after permission grant")
}
} else LogManager.addLog("WARN", "RadioUI", "floating PTT permission not granted")
}
val overlayShown by RadioOverlayService.isShown.collectAsState()
val overlayStatus by RadioOverlayService.status.collectAsState()
Surface(color = Color(0xFF0F1B22)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
radioAuto, centerIcon = currentDeviceIcon) {
viewModel.setRadioAuto()
}
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
}
if (wiredInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
Row(
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
radioAuto, centerIcon = currentDeviceIcon) {
viewModel.setRadioAuto()
}
}
if (btInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
}
}
}
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) {
if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) {
try {
overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioUI", "open overlay permission failed: ${e.message}")
if (wiredInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
}
}
if (btInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
}
}
} else scope.launch {
provider.setRadioOverlay(!overlayEnabled)
RadioOverlayService.sync(context)
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled=${provider.getRadioOverlay()}")
}
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
RadioControlButton(null, "Плавающая PTT-кнопка", overlayShown, floating = true) {
scope.launch {
if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions()
else RadioOverlayService.setEnabled(!overlayEnabled)
}
}
}
if (overlayEnabled && !overlayShown) overlayStatus?.let { message ->
Text(message, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp).clickable { RadioOverlayService.requestPermissions() })
}
}
}

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

@ -0,0 +1,71 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.utun.chat.data.LogManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit) {
var name by rememberSaveable { mutableStateOf(initialName) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val validName = name.trim().isNotEmpty() && name.none { it.isISOControl() }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().safeDrawingPadding().imePadding()
.verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Добро пожаловать", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Text("Задайте имя устройства (ник), которое увидят другие участники.")
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it; error = null },
label = { Text("Имя устройства (ник)") },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth()
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(24.dp))
Button(
enabled = validName && !saving,
modifier = Modifier.fillMaxWidth(),
onClick = {
saving = true
scope.launch {
try {
onComplete(name.trim())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETUP", "Failed to save device name: $e")
error = "Не удалось сохранить имя. Попробуйте ещё раз."
} finally {
saving = false
}
}
}
) {
Text(if (saving) "Сохранение…" else "Продолжить")
}
}
}
}

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

@ -1,142 +1,85 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.InviteData
import com.utun.chat.data.InviteLink
import com.utun.chat.data.LogManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinChannelDialog(
autoLink: String? = null,
onScanQr: () -> Unit,
onConnect: (InviteData) -> Unit,
onDismiss: () -> Unit
) {
var link by remember { mutableStateOf(autoLink ?: "") }
var decoded by remember { mutableStateOf<InviteData?>(null) }
var status by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
val clipboard = LocalClipboardManager.current
var link by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var connecting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val connect by rememberUpdatedState(onConnect)
if (!autoLink.isNullOrBlank()) {
LaunchedEffect(autoLink) {
connecting = true
isError = false
status = "Decoding..."
delay(300)
val d = InviteLink.decode(autoLink.trim())
if (d != null) {
decoded = d
status = "Connecting to peer..."
delay(800)
LaunchedEffect(Unit) {
val text = clipboard.getText()?.text?.trim().orEmpty()
if (text.startsWith("utun://")) {
LogManager.addLog("INFO", "INVITE", "Invite link pasted from clipboard")
link = text
}
}
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d"
.format(d.channelId, d.nodeId, d.addrs.size))
onConnect(d)
} else {
connecting = false
status = "Invalid invite link"
isError = true
}
LaunchedEffect(link) {
error = null
val text = link.trim()
if (text.isEmpty() || connecting) return@LaunchedEffect
val invite = InviteLink.decode(text)
if (invite == null) {
LogManager.addLog("WARN", "INVITE", "Invalid invite link in join dialog")
error = "Invalid invite link"
} else {
connecting = true
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d"
.format(invite.channelId, invite.nodeId, invite.addrs.size))
connect(invite)
}
}
AlertDialog(
onDismissRequest = { if (!connecting) onDismiss() },
onDismissRequest = onDismiss,
title = { Text("Join Channel") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (autoLink.isNullOrBlank()) {
OutlinedTextField(
value = link,
onValueChange = { newVal ->
link = newVal
status = ""
isError = false
scope.launch {
delay(400)
if (link == newVal) {
if (newVal.isBlank()) {
decoded = null
} else {
decoded = InviteLink.decode(newVal.trim())
}
}
}
},
label = { Text("Invite link") },
placeholder = { Text("utun://...") },
trailingIcon = {
IconButton(onClick = onScanQr) {
Icon(Icons.Default.QrCodeScanner, "Scan QR")
}
},
singleLine = true,
enabled = !connecting,
modifier = Modifier.fillMaxWidth()
)
}
decoded?.let { d ->
val addrStr = d.addrs.joinToString(", ") { InviteLink.addrToString(it) }
Column(Modifier.fillMaxWidth()) {
Text("Node: 0x%016x".format(d.nodeId), fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Channel: ${d.channelId}", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (addrStr.isNotBlank())
Text(addrStr, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (status.isNotBlank()) {
Text(
status,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontSize = 14.sp, fontWeight = FontWeight.Medium
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!connecting) {
QrScannerPreview(
modifier = Modifier.fillMaxWidth().height(180.dp),
onResult = { link = it }
)
}
OutlinedTextField(
value = link,
onValueChange = { link = it },
label = { Text("Invite link") },
placeholder = { Text("utun://...") },
trailingIcon = {
IconButton(onClick = { link = clipboard.getText()?.text.orEmpty() }, enabled = !connecting) {
Icon(Icons.Filled.ContentPaste, "Paste invite link")
}
},
singleLine = true,
enabled = !connecting,
isError = error != null,
modifier = Modifier.fillMaxWidth()
)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (connecting) Text("Connecting to peer...", color = MaterialTheme.colorScheme.primary)
}
},
confirmButton = {
Button(
onClick = {
connecting = true
isError = false
status = "Connecting to peer..."
val d = InviteLink.decode(link.trim())
if (d != null) {
onConnect(d)
} else {
connecting = false
status = "Invalid invite link"
isError = true
}
},
enabled = !connecting && decoded != null && (autoLink.isNullOrBlank())
) {
Text(if (connecting) "..." else "Connect")
}
},
dismissButton = {
if (autoLink.isNullOrBlank()) {
TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") }
}
}
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}

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

@ -14,6 +14,11 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import android.util.Log
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star
@ -83,13 +88,8 @@ fun MemberListScreen(
member = m,
isSelected = selectedMember?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) },
onEdit = {
editMember = m
showEditDialog = true
}
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) }
)
}
}
@ -100,12 +100,24 @@ fun MemberListScreen(
exit = slideOutVertically(targetOffsetY = { it })
) {
selectedMember?.let { detail ->
val member = members.find { it.nodeId == detail.nodeId }
MemberDetailCard(
detail = detail,
memberLinks = memberLinks,
localSocketStates = localSockets,
isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf },
onDismiss = { }
isSelf = member?.isSelf == true,
canEdit = member != null,
onConnect = {
Log.i("utun-gui", "Member detail connect: channel=${channel.id} node=${detail.nodeId}")
viewModel.connectMemberNode(channel.id, detail.nodeId)
},
onEdit = {
member?.let {
Log.i("utun-gui", "Member detail edit: channel=${channel.id} node=${it.nodeId}")
editMember = it
showEditDialog = true
}
}
)
}
}
@ -136,10 +148,8 @@ private fun MemberListItem(
member: ChatMember,
isSelected: Boolean = false,
onClick: () -> Unit,
onConnect: () -> Unit,
onCall: () -> Unit,
onMessage: () -> Unit,
onEdit: () -> Unit
onMessage: () -> Unit
) {
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
@ -156,7 +166,7 @@ private fun MemberListItem(
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (member.isSelf) {
@ -185,6 +195,14 @@ private fun MemberListItem(
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (member.isStorage) {
Icon(Icons.Filled.Storage, "Storage", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (!member.isVerified) {
Icon(Icons.Filled.Close, "Not verified", tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(
text = member.name,
fontWeight = FontWeight.Medium,
@ -196,40 +214,24 @@ private fun MemberListItem(
modifier = Modifier.weight(1f)
)
}
val parts = mutableListOf<String>()
if (member.isAdmin) parts.add("Admin")
if (member.isSuper) parts.add("Super")
if (member.isModer) parts.add("Moder")
if (member.isStorage) parts.add("Storage")
if (member.isVerified) parts.add("Verified")
if (member.isDeleted) parts.add("Deleted")
if (member.isSelf) parts.add("self")
else if (member.connecting) parts.add("connecting…")
else if (member.directUp) parts.add("direct↑")
else if (member.indirectUp) parts.add("indirect↑")
else if (member.online) parts.add("relay")
if (member.rtt > 0) parts.add("${member.rtt}ms")
if (parts.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = parts.joinToString(" "),
fontSize = 11.sp,
color = if (member.isSelf || member.online)
Color(0xFF81C784) else Color(0xFFAEBCC8)
)
}
val connectionLabel = when {
member.isSelf -> "self"
member.directUp -> "D" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
member.bgpPresence -> "B" + if (member.bgpRtt > 0) ":${member.bgpRtt / 10}ms" else ""
member.indirectUp -> "indirect↑" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
member.connecting -> "connecting…"
else -> "offline"
}
Text(
text = connectionLabel, fontSize = 11.sp,
color = if (member.isSelf || member.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
)
}
if (!member.isSelf) {
TextButton(onClick = onConnect) { Text("Connect") }
TextButton(
onClick = onCall,
enabled = member.online
) { Text("Call") }
TextButton(onClick = onMessage) { Text("Message") }
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, "Call") }
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, "Message") }
}
TextButton(onClick = onEdit) { Text("Edit") }
}
}
}
@ -248,7 +250,9 @@ private fun MemberDetailCard(
memberLinks: MemberLinks,
localSocketStates: List<LocalSocketState>,
isSelf: Boolean = false,
onDismiss: () -> Unit
canEdit: Boolean,
onConnect: () -> Unit,
onEdit: () -> Unit
) {
Card(
modifier = Modifier
@ -264,19 +268,49 @@ private fun MemberDetailCard(
) {
Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) {
Row(verticalAlignment = Alignment.CenterVertically) {
val dotColor = if (detail.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(dotColor)
)
Spacer(Modifier.width(8.dp))
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp)
if (isSelf) {
Spacer(Modifier.width(4.dp))
Text("✓", fontSize = 12.sp, color = Color(0xFF81C784))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val dotColor = if (detail.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(dotColor)
)
Spacer(Modifier.width(8.dp))
Text(
detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 18.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)
)
if (isSelf) {
Spacer(Modifier.width(4.dp))
Text("✓", fontSize = 12.sp, color = Color(0xFF81C784))
}
}
if (!isSelf) {
Spacer(Modifier.height(2.dp))
if (detail.directPresence) {
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
if (detail.indirectPresence) {
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
if (detail.bgpPresence && detail.bgpNodes > 0) {
Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text("Connect") }
TextButton(onClick = onEdit, enabled = canEdit) { Text("Edit") }
}
Spacer(Modifier.height(4.dp))
@ -328,13 +362,15 @@ private fun MemberDetailCard(
if (detail.addresses.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
detail.addresses.forEach { a ->
detail.addresses.forEachIndexed { idx, a ->
Text(
text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}",
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
if (idx < detail.addresses.lastIndex) Spacer(Modifier.height(8.dp))
}
}
@ -355,60 +391,30 @@ private fun MemberDetailCard(
Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(4.dp))
Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp)
Spacer(Modifier.width(4.dp))
Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
}
val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName)
if (cleanSn.isNotEmpty()) {
Row {
Spacer(Modifier.width(14.dp))
Text(cleanSn, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(cleanSn, fontSize = 12.sp, lineHeight = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (idx < memberLinks.links.lastIndex) Spacer(Modifier.height(4.dp))
if (idx < memberLinks.links.lastIndex) Spacer(Modifier.height(8.dp))
}
}
Spacer(Modifier.height(6.dp))
Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
if (detail.directPresence) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape)
.background(if (detail.directUp) Color(0xFF81C784) else Color(0xFFFFB4AB)))
Spacer(Modifier.width(6.dp))
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
if (detail.indirectPresence) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape)
.background(if (detail.indirectUp) Color(0xFF81C784) else Color(0xFFFFB4AB)))
Spacer(Modifier.width(6.dp))
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
if (detail.bgpPresence && detail.bgpNodes > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF81C784)))
Spacer(Modifier.width(6.dp))
Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}

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

@ -1,165 +0,0 @@
package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QrScanScreen(
onResult: (String) -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var hasPermission by remember { mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(Unit) {
if (!hasPermission) launcher.launch(Manifest.permission.CAMERA)
}
/* Camera lifecycle — unbind when leaving this screen */
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val mainHandler = remember { Handler(Looper.getMainLooper()) }
DisposableEffect(Unit) {
onDispose {
Log.d("QrScan", "disposing camera")
cameraProviderRef.value?.unbindAll()
cameraProviderRef.value = null
cameraExecutor.shutdown()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Scan QR Code") },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") }
}
)
}
) { padding ->
if (!hasPermission) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Camera permission required")
Spacer(Modifier.height(8.dp))
Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) {
Text("Grant")
}
}
}
} else {
var scanned by remember { mutableStateOf(false) }
AndroidView(
modifier = Modifier.fillMaxSize().padding(padding),
factory = { ctx ->
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
cameraProviderRef.value = cameraProvider
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
val scanner = BarcodeScanning.getClient(options)
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
if (scanned) {
imageProxy.close()
return@setAnalyzer
}
val mediaImage = imageProxy.image
if (mediaImage != null) {
try {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
val text = barcode.rawValue
if (text != null && text.startsWith("utun://")) {
scanned = true
val link = text
Log.d("QrScan", "detected: $link")
mainHandler.post { onResult(link) }
break
}
}
}
.addOnCompleteListener { imageProxy.close() }
} catch (e: Exception) {
Log.e("QrScan", "scan error", e)
imageProxy.close()
}
} else {
imageProxy.close()
}
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
analysis
)
Log.d("QrScan", "camera bound ok")
} catch (e: Exception) {
Log.e("QrScan", "Camera bind failed", e)
}
}, ContextCompat.getMainExecutor(ctx))
previewView
}
)
}
}
}

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

@ -0,0 +1,188 @@
package com.utun.chat.ui.screens
import android.Manifest
import android.app.Activity
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.utun.chat.data.InviteLink
import com.utun.chat.data.LogManager
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val result by rememberUpdatedState(onResult)
var hasPermission by remember { mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
) }
var cameraError by remember { mutableStateOf<String?>(null) }
var permanentDenial by remember { mutableStateOf(false) }
val activity = remember(context) {
var current = context
while (current is ContextWrapper && current !is Activity) current = current.baseContext
current as? Activity
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (granted != hasPermission) {
LogManager.addLog("INFO", "QR", "Camera permission on resume=$granted")
hasPermission = granted
cameraError = null
}
if (granted) permanentDenial = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasPermission = granted
permanentDenial = !granted && activity?.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) == false
LogManager.addLog(if (granted) "INFO" else "WARN", "QR", "Camera permission=$granted permanentDenial=$permanentDenial")
}
LaunchedEffect(Unit) {
if (!hasPermission) {
LogManager.addLog("INFO", "QR", "Requesting CAMERA permission")
launcher.launch(Manifest.permission.CAMERA)
}
}
Box(modifier, contentAlignment = Alignment.Center) {
if (!hasPermission) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Camera permission required")
TextButton(onClick = {
if (permanentDenial) {
try {
context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${context.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "QR", "Cannot open camera permission settings: $e")
}
} else launcher.launch(Manifest.permission.CAMERA)
}) { Text(if (permanentDenial) "Settings" else "Grant") }
}
} else {
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } }
DisposableEffect(lifecycleOwner, previewView) {
val disposed = AtomicBoolean(false)
val scanned = AtomicBoolean(false)
val executor = Executors.newSingleThreadExecutor()
val mainExecutor = ContextCompat.getMainExecutor(context)
val scanner = BarcodeScanning.getClient(BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE).build())
val preview = Preview.Builder().build().also { it.surfaceProvider = previewView.surfaceProvider }
val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build()
var provider: ProcessCameraProvider? = null
analysis.setAnalyzer(executor) { imageProxy ->
val mediaImage = imageProxy.image
if (disposed.get() || scanned.get() || mediaImage == null) {
imageProxy.close()
} else {
try {
scanner.process(InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees))
.addOnSuccessListener(mainExecutor) { barcodes ->
if (!disposed.get()) {
val link = barcodes.mapNotNull { it.rawValue }
.firstOrNull { it.startsWith("utun://") && InviteLink.decode(it) != null }
if (link != null && scanned.compareAndSet(false, true)) {
LogManager.addLog("INFO", "QR", "Valid invite QR detected")
result(link)
}
}
}
.addOnFailureListener(mainExecutor) { e ->
if (!disposed.get()) {
LogManager.addLog("ERROR", "QR", "QR scan failed: $e")
cameraError = "QR scanning failed. Paste an invite link."
analysis.clearAnalyzer()
}
}
.addOnCompleteListener(mainExecutor) { imageProxy.close() }
} catch (e: Exception) {
LogManager.addLog("ERROR", "QR", "QR image processing failed: $e")
imageProxy.close()
mainExecutor.execute {
if (!disposed.get()) {
cameraError = "QR scanning failed. Paste an invite link."
analysis.clearAnalyzer()
}
}
}
}
}
val future = ProcessCameraProvider.getInstance(context)
future.addListener({
if (!disposed.get()) {
try {
provider = future.get()
provider!!.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
LogManager.addLog("INFO", "QR", "Join dialog camera started")
} catch (e: Exception) {
LogManager.addLog("ERROR", "QR", "Camera initialization failed: $e")
cameraError = "Camera unavailable. Paste an invite link."
}
}
}, mainExecutor)
onDispose {
disposed.set(true)
analysis.clearAnalyzer()
provider?.unbind(preview, analysis)
scanner.close()
executor.shutdown()
LogManager.addLog("INFO", "QR", "Join dialog camera released")
}
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
Canvas(Modifier.fillMaxSize()) {
val side = size.minDimension * 0.75f
val left = (size.width - side) / 2
val top = (size.height - side) / 2
val length = 20.dp.toPx()
val stroke = 3.dp.toPx()
for (xSign in listOf(-1, 1)) {
for (ySign in listOf(-1, 1)) {
val x = if (xSign == -1) left else left + side
val y = if (ySign == -1) top else top + side
drawLine(Color.Red, Offset(x, y), Offset(x - xSign * length, y), stroke)
drawLine(Color.Red, Offset(x, y), Offset(x, y - ySign * length), stroke)
}
}
}
cameraError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
}

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

@ -493,7 +493,7 @@ private fun RadioSettingsTab() {
scope.launch {
provider.setValue("radio.vad_enabled", if (enabled) "1" else "0")
NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0")
RadioOverlayService.sync(ChatApplication.instance)
if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ChatApplication.instance)
RadioAudioEngine.instance().reconfigure()
}
})
@ -533,38 +533,21 @@ private fun RadioSettingsTab() {
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}")
}
}
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
val overlayShown by RadioOverlayService.isShown.collectAsState()
val overlayStatus by RadioOverlayService.status.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("PTT-кнопка поверх приложений")
if (!canOverlay) Text(
"Требуется разрешение «поверх других приложений» — откройте настройки",
if (overlayEnabled) Text(
overlayStatus ?: if (overlayShown) "Плавающая кнопка активна" else "Ожидание включения",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier = Modifier.clickable { openOverlayPermission() })
modifier = Modifier.clickable { RadioOverlayService.requestPermissions() }
)
}
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
overlayEnabled = it
scope.launch {
provider.setRadioOverlay(it)
RadioOverlayService.sync(ChatApplication.instance)
}
Switch(checked = overlayEnabled, onCheckedChange = { enabled ->
scope.launch { RadioOverlayService.setEnabled(enabled) }
})
}

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

@ -581,14 +581,11 @@ class ChatViewModel : ViewModel() {
_radioAuto.value = false
}
_radioActive.value = next
syncRadioOverlay()
if (next) RadioOverlayService.requestPermissions() else syncRadioOverlay()
LogManager.addLog("INFO", "VM", "toggleRadio -> ${if (next) "on" else "off"} group=$groupId")
}
fun radioTalkBegin() {
if (!_radioActive.value) return
radioAudio.talkBegin()
}
fun radioTalkBegin(): Boolean = _radioActive.value && radioAudio.talkBegin()
fun radioTalkEnd() {
if (!_radioActive.value) return

Loading…
Cancel
Save