Browse Source

Improve Android onboarding, member UI and PTT permissions

master
evgeny 2 weeks 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() TelecomCallManager.init()
CallController.init() CallController.init()
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
LogManager.addLog("INFO", "APP", "First launch — marking launched")
configProvider.markLaunched()
}
}
} }
companion object { 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.Manifest
import android.content.Intent 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.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle 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.LogManager
import com.utun.chat.data.Message import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.data.RadioAudioEngine import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.headless.HeadlessService import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.theme.VibeChatTheme import com.utun.chat.ui.theme.VibeChatTheme
@ -44,15 +50,69 @@ import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { 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 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermission() requestNotificationPermission()
requestFgsMicPermission()
/* Входящий звонок: showWhenLocked/turnScreenOn ДО setContent, иначе полноэкранный /* Входящий звонок: showWhenLocked/turnScreenOn ДО setContent, иначе полноэкранный
intent мигает и UI не отрисовывается поверх keyguard (холодный старт). */ intent мигает и UI не отрисовывается поверх keyguard (холодный старт). */
@ -67,14 +127,27 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) 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 { setContent {
VibeChatTheme { 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()) val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory())
/* Pause GUI polling (health-check, recording/playback timers) when backgrounded, /* 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 screen by remember { mutableStateOf(Screen.ChannelList) }
var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) } var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) } var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) } var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage 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 }, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm }, onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") }, onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, onJoinChannel = { showJoinDialog = true },
onInviteClick = showInvite, onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) }, onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
@ -265,15 +336,6 @@ class MainActivity : ComponentActivity() {
onClose = { vm.dismissCall(); screen = screenBeforeCall ?: Screen.ChannelList }, onClose = { vm.dismissCall(); screen = screenBeforeCall ?: Screen.ChannelList },
onMinimize = { 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 -> { Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value val channel = vm.currentChannel.collectAsState().value
if (channel != null) { 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) { if (showJoinDialog) {
JoinChannelDialog( JoinChannelDialog(
autoLink = joinAutoLink,
onScanQr = {
screen = Screen.QrScan
showJoinDialog = false
},
onConnect = { data: InviteData -> onConnect = { data: InviteData ->
val addrs = data.serializeAddrs() val addrs = data.serializeAddrs()
NativeLib.joinChannel(data.channelId, data.nodeId, data.joinKey, data.pubkey, addrs, data.addrs.size) NativeLib.joinChannel(data.channelId, data.nodeId, data.joinKey, data.pubkey, addrs, data.addrs.size)
@ -361,13 +409,39 @@ class MainActivity : ComponentActivity() {
} }
override fun onStart() { override fun onStart() {
MicrophonePermission.requester = microphoneRequester
RadioOverlayService.overlayPermissionRequester = overlayPermissionRequester
super.onStart() 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) CallController.setActivityVisible(true)
} }
override fun onResume() {
super.onResume()
RadioOverlayService.setActivityVisible(true)
}
override fun onPause() {
RadioOverlayService.setActivityVisible(false)
super.onPause()
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(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) { if (incomingCallId(intent) != 0L) {
LogManager.addLog("INFO", "MAIN", "launched for incoming call 0x%016x".format(incomingCallId(intent))) LogManager.addLog("INFO", "MAIN", "launched for incoming call 0x%016x".format(incomingCallId(intent)))
showOverLockScreen() 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() { override fun onStop() {
super.onStop() super.onStop()
if (MicrophonePermission.requester === microphoneRequester) MicrophonePermission.requester = null
if (RadioOverlayService.overlayPermissionRequester === overlayPermissionRequester) RadioOverlayService.overlayPermissionRequester = null
CallController.setActivityVisible(false) CallController.setActivityVisible(false)
} }
@ -438,7 +506,7 @@ class MainActivity : ComponentActivity() {
when (event.action) { when (event.action) {
KeyEvent.ACTION_DOWN -> { KeyEvent.ACTION_DOWN -> {
if (event.repeatCount == 0) { if (event.repeatCount == 0) {
if (pttHoldCount == 0) RadioAudioEngine.instance().talkBegin() if (pttHoldCount == 0 && !RadioAudioEngine.instance().talkBegin()) return true
pttHoldCount++ pttHoldCount++
LogManager.addLog("DEBUG", "PTT", "key=${event.keyCode} down hold=$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()}") LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
return return
} }
if (!MicrophonePermission.granted()) {
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) }
return
}
var id = java.security.SecureRandom().nextLong() var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1 if (id == 0L) id = 1
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING) _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") LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
return 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 accepting = true
requestService(st.callId) 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) !(context.dataStore.data.first()[firstLaunchDone] ?: false)
} }
suspend fun markLaunched() { suspend fun completeFirstLaunch(name: String) {
context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true } 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 ── */ /* ── 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 { 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 startNs = SystemClock.elapsedRealtimeNanos()
val (rec, channels) = createAudioRecord() val (rec, channels) = createAudioRecord()
if (rec == null) return false if (rec == null) return false
@ -508,10 +512,18 @@ class RadioAudioEngine {
restoreAudioMode() restoreAudioMode()
} }
fun talkBegin() { fun talkBegin(): Boolean {
if (!active) return 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) mainHandler.removeCallbacks(releaseTalk)
if (requestedTalk) return if (requestedTalk) return true
talkRequestNs = SystemClock.elapsedRealtimeNanos() talkRequestNs = SystemClock.elapsedRealtimeNanos()
requestedTalk = true requestedTalk = true
_talkingFlow.value = true _talkingFlow.value = true
@ -523,6 +535,7 @@ class RadioAudioEngine {
updateCapture() updateCapture()
} }
} }
return true
} }
/** Отпускание: 250мс передачи, повторное касание отменяет завершение. */ /** Отпускание: 250мс передачи, повторное касание отменяет завершение. */
@ -533,6 +546,12 @@ class RadioAudioEngine {
LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms") LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms")
} }
/** Отмена без хвоста передачи при выключении/потере разрешений. */
fun cancelTalk() {
mainHandler.removeCallbacks(releaseTalk)
finishTalk()
}
private fun finishTalk() { private fun finishTalk() {
if (!requestedTalk) return if (!requestedTalk) return
requestedTalk = false 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 package com.utun.chat.data
import android.Manifest
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.Point import android.graphics.Point
@ -24,7 +22,6 @@ import android.view.WindowManager
import android.widget.TextView import android.widget.TextView
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout import androidx.core.view.doOnLayout
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import kotlin.math.abs import kotlin.math.abs
@ -32,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -72,45 +71,133 @@ class RadioOverlayService : Service() {
@Volatile private var running = false @Volatile private var running = false
@Volatile private var starting = false @Volatile private var starting = false
@Volatile private var pendingStop = false @Volatile private var pendingStop = false
private var restartAfterStop = false
fun canDrawOverlays(ctx: Context): Boolean = fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
fun sync(ctx: Context) { private var activityVisible = false
val provider = ChatApplication.instance.configProvider private var waitingOverlayPermission = false
if (RadioAudioEngine.instance().isActive() && var overlayPermissionRequester: (() -> Unit)? = null
(provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0)) start(ctx) private val _status = MutableStateFlow<String?>(null)
else stop(ctx) 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) { suspend fun setEnabled(enabled: Boolean) {
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}") ChatApplication.instance.configProvider.setRadioOverlay(enabled)
pendingStop = false LogManager.addLog("INFO", "RadioOverlay", "User requested overlay enabled=$enabled")
if (Build.VERSION.SDK_INT >= 34 && if (enabled) requestPermissions() else {
ContextCompat.checkSelfPermission(ctx, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) != waitingOverlayPermission = false
PackageManager.PERMISSION_GRANTED) { sync(ChatApplication.instance)
LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted") }
}
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 return
} }
if (starting) return if ((provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0) && !MicrophonePermission.granted()) {
starting = !running setStatus("Нужен доступ к микрофону")
val i = Intent(ctx, RadioOverlayService::class.java) 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 { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) ctx.startForegroundService(Intent(ctx, RadioOverlayService::class.java))
else ctx.startService(i) LogManager.addLog("INFO", "RadioOverlay", "Microphone service requested from visible Activity overlay=$enabled vad=$vad")
} catch (e: Exception) { } catch (e: Exception) {
starting = false 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) { fun stop(ctx: Context) {
restartAfterStop = false
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.stop isShown=${_isShown.value} t=${System.currentTimeMillis()}") LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.stop isShown=${_isShown.value} t=${System.currentTimeMillis()}")
if (starting) { if (starting) {
pendingStop = true pendingStop = true
return 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() super.onCreate()
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.onCreate thread=${Thread.currentThread().name} t=${System.currentTimeMillis()}") LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.onCreate thread=${Thread.currentThread().name} t=${System.currentTimeMillis()}")
createChannel() createChannel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { try {
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(),
} else { ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE)
startForeground(NOTIF_ID, buildNotification()) } else {
startForeground(NOTIF_ID, buildNotification())
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioOverlay", "Foreground microphone start failed: $e")
starting = false
setStatus("Не удалось запустить сервис микрофона")
stopSelf()
return
} }
running = true running = true
@ -142,29 +237,55 @@ class RadioOverlayService : Service() {
RadioAudioEngine.instance().talkingFlow.collect { updateButtonColor() } RadioAudioEngine.instance().talkingFlow.collect { updateButtonColor() }
} }
starting = false 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) { if (pendingStop) {
pendingStop = false
stopSelf() stopSelf()
} }
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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() stopSelf()
return START_NOT_STICKY return
} }
val provider = ChatApplication.instance.configProvider val provider = ChatApplication.instance.configProvider
val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this) val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this)
if (wantOverlay && overlayView == null) addOverlay() if (wantOverlay && overlayView == null) {
if (!wantOverlay && overlayView != null) { try { addOverlay() } catch (e: Exception) {
endTalk() setStatus("Не удалось показать плавающую кнопку")
windowManager?.removeView(overlayView) LogManager.addLog("ERROR", "RadioOverlay", "Overlay creation failed: $e")
overlayView = null }
_isShown.value = false
} }
if (!wantOverlay) removeOverlay()
if (overlayView != null) setStatus("Плавающая кнопка активна")
if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf() 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() { private fun createChannel() {
@ -261,7 +382,7 @@ class RadioOverlayService : Service() {
return true return true
} }
MotionEvent.ACTION_UP -> { 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) { if (!wasLatched && tapped && secondTap) {
latched = true latched = true
lastTapUpTime = 0L lastTapUpTime = 0L
@ -323,8 +444,8 @@ class RadioOverlayService : Service() {
private fun beginTalk() { private fun beginTalk() {
if (talking) return if (talking) return
if (!RadioAudioEngine.instance().talkBegin()) return
talking = true talking = true
RadioAudioEngine.instance().talkBegin()
updateButtonColor() updateButtonColor()
} }
@ -345,14 +466,20 @@ class RadioOverlayService : Service() {
override fun onDestroy() { override fun onDestroy() {
serviceScope.cancel() serviceScope.cancel()
endTalk() removeOverlay()
overlayView?.let { windowManager?.removeView(it) }
overlayView = null
starting = false starting = false
running = false running = false
pendingStop = false
if (currentService === this) currentService = null if (currentService === this) currentService = null
_isShown.value = false _isShown.value = false
if (_status.value == "Плавающая кнопка активна" || _status.value == "Запуск плавающей кнопки…") {
setStatus(if (ChatApplication.instance.configProvider.getRadioOverlay()) "Плавающая кнопка остановлена" else null)
}
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy() 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 package com.utun.chat.ui.screens
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Intent
import android.provider.Settings
import android.net.Uri import android.net.Uri
import android.os.SystemClock import android.os.SystemClock
import android.provider.OpenableColumns import android.provider.OpenableColumns
@ -319,7 +317,7 @@ fun ChatScreen(
private fun RadioPttButton( private fun RadioPttButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTalking: Boolean = false, isTalking: Boolean = false,
onTalkBegin: () -> Unit, onTalkBegin: () -> Boolean,
onTalkEnd: () -> Unit onTalkEnd: () -> Unit
) { ) {
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS) 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 isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
val wasLatched = latched val wasLatched = latched
var started = false
if (wasLatched) { if (wasLatched) {
latched = false latched = false
lastTapUp = 0L lastTapUp = 0L
onTalkEnd() onTalkEnd()
} else onTalkBegin() } else started = onTalkBegin()
var released = false var released = false
try { try {
@ -356,7 +355,7 @@ private fun RadioPttButton(
if (!c.pressed) { released = true; break } if (!c.pressed) { released = true; break }
} }
val upTime = SystemClock.uptimeMillis() 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) { if (!wasLatched && tapped && isSecondTap) {
latched = true latched = true
lastTapUp = 0L lastTapUp = 0L
@ -390,67 +389,57 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState() val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
val btInputAvailable by viewModel.btInputAvailable.collectAsState() val btInputAvailable by viewModel.btInputAvailable.collectAsState()
val radioRoute by viewModel.radioRoute.collectAsState() val radioRoute by viewModel.radioRoute.collectAsState()
val context = LocalContext.current
val provider = ChatApplication.instance.configProvider val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
val overlayPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { val overlayShown by RadioOverlayService.isShown.collectAsState()
if (RadioOverlayService.canDrawOverlays(context)) { val overlayStatus by RadioOverlayService.status.collectAsState()
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")
}
Surface(color = Color(0xFF0F1B22)) { Surface(color = Color(0xFF0F1B22)) {
Row( Column {
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row( 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), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp Row(
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}", modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
radioAuto, centerIcon = currentDeviceIcon) { horizontalArrangement = Arrangement.spacedBy(8.dp),
viewModel.setRadioAuto() verticalAlignment = Alignment.CenterVertically
} ) {
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона", val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) { RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) radioAuto, centerIcon = currentDeviceIcon) {
} viewModel.setRadioAuto()
if (wiredInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
} }
} RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
if (btInputAvailable) { !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры", viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
} }
} if (wiredInputAvailable) {
} RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550)) !radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) { }
try { }
overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, if (btInputAvailable) {
Uri.parse("package:${context.packageName}"))) RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
} catch (e: Exception) { !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
LogManager.addLog("ERROR", "RadioUI", "open overlay permission failed: ${e.message}") 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 package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.* 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.Icons
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.InviteData import com.utun.chat.data.InviteData
import com.utun.chat.data.InviteLink import com.utun.chat.data.InviteLink
import com.utun.chat.data.LogManager import com.utun.chat.data.LogManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun JoinChannelDialog( fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
autoLink: String? = null, val clipboard = LocalClipboardManager.current
onScanQr: () -> Unit, var link by remember { mutableStateOf("") }
onConnect: (InviteData) -> Unit, var error by remember { mutableStateOf<String?>(null) }
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) }
var connecting by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val connect by rememberUpdatedState(onConnect)
if (!autoLink.isNullOrBlank()) { LaunchedEffect(Unit) {
LaunchedEffect(autoLink) { val text = clipboard.getText()?.text?.trim().orEmpty()
connecting = true if (text.startsWith("utun://")) {
isError = false LogManager.addLog("INFO", "INVITE", "Invite link pasted from clipboard")
link = text
status = "Decoding..." }
delay(300) }
val d = InviteLink.decode(autoLink.trim())
if (d != null) {
decoded = d
status = "Connecting to peer..."
delay(800)
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" LaunchedEffect(link) {
.format(d.channelId, d.nodeId, d.addrs.size)) error = null
onConnect(d) val text = link.trim()
} else { if (text.isEmpty() || connecting) return@LaunchedEffect
connecting = false val invite = InviteLink.decode(text)
status = "Invalid invite link" if (invite == null) {
isError = true 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( AlertDialog(
onDismissRequest = { if (!connecting) onDismiss() }, onDismissRequest = onDismiss,
title = { Text("Join Channel") }, title = { Text("Join Channel") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(
if (autoLink.isNullOrBlank()) { modifier = Modifier.verticalScroll(rememberScrollState()),
OutlinedTextField( verticalArrangement = Arrangement.spacedBy(12.dp)
value = link, ) {
onValueChange = { newVal -> if (!connecting) {
link = newVal QrScannerPreview(
status = "" modifier = Modifier.fillMaxWidth().height(180.dp),
isError = false onResult = { link = it }
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
) )
} }
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 = { confirmButton = {},
Button( dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
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") }
}
}
) )
} }

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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Bolt
import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
@ -83,13 +88,8 @@ fun MemberListScreen(
member = m, member = m,
isSelected = selectedMember?.nodeId == m.nodeId, isSelected = selectedMember?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, 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) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) }, onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) }
onEdit = {
editMember = m
showEditDialog = true
}
) )
} }
} }
@ -100,12 +100,24 @@ fun MemberListScreen(
exit = slideOutVertically(targetOffsetY = { it }) exit = slideOutVertically(targetOffsetY = { it })
) { ) {
selectedMember?.let { detail -> selectedMember?.let { detail ->
val member = members.find { it.nodeId == detail.nodeId }
MemberDetailCard( MemberDetailCard(
detail = detail, detail = detail,
memberLinks = memberLinks, memberLinks = memberLinks,
localSocketStates = localSockets, localSocketStates = localSockets,
isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf }, isSelf = member?.isSelf == true,
onDismiss = { } 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, member: ChatMember,
isSelected: Boolean = false, isSelected: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
onConnect: () -> Unit,
onCall: () -> Unit, onCall: () -> Unit,
onMessage: () -> Unit, onMessage: () -> Unit
onEdit: () -> Unit
) { ) {
val onlineColor = when { val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
@ -156,7 +166,7 @@ private fun MemberListItem(
tonalElevation = 0.dp tonalElevation = 0.dp
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (member.isSelf) { if (member.isSelf) {
@ -185,6 +195,14 @@ private fun MemberListItem(
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp)) Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.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(
text = member.name, text = member.name,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -196,40 +214,24 @@ private fun MemberListItem(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
val parts = mutableListOf<String>() val connectionLabel = when {
if (member.isAdmin) parts.add("Admin") member.isSelf -> "self"
if (member.isSuper) parts.add("Super") member.directUp -> "D" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
if (member.isModer) parts.add("Moder") member.bgpPresence -> "B" + if (member.bgpRtt > 0) ":${member.bgpRtt / 10}ms" else ""
if (member.isStorage) parts.add("Storage") member.indirectUp -> "indirect↑" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
if (member.isVerified) parts.add("Verified") member.connecting -> "connecting…"
if (member.isDeleted) parts.add("Deleted") else -> "offline"
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)
)
}
} }
Text(
text = connectionLabel, fontSize = 11.sp,
color = if (member.isSelf || member.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
)
} }
if (!member.isSelf) { if (!member.isSelf) {
TextButton(onClick = onConnect) { Text("Connect") } IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, "Call") }
TextButton( IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, "Message") }
onClick = onCall,
enabled = member.online
) { Text("Call") }
TextButton(onClick = onMessage) { Text("Message") }
} }
TextButton(onClick = onEdit) { Text("Edit") }
} }
} }
} }
@ -248,7 +250,9 @@ private fun MemberDetailCard(
memberLinks: MemberLinks, memberLinks: MemberLinks,
localSocketStates: List<LocalSocketState>, localSocketStates: List<LocalSocketState>,
isSelf: Boolean = false, isSelf: Boolean = false,
onDismiss: () -> Unit canEdit: Boolean,
onConnect: () -> Unit,
onEdit: () -> Unit
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
@ -264,19 +268,49 @@ private fun MemberDetailCard(
) { ) {
Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) { Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val dotColor = if (detail.online) Color(0xFF81C784) else Color(0xFFAEBCC8) Column(modifier = Modifier.weight(1f)) {
Box( Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier val dotColor = if (detail.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
.size(10.dp) Box(
.clip(CircleShape) modifier = Modifier
.background(dotColor) .size(10.dp)
) .clip(CircleShape)
Spacer(Modifier.width(8.dp)) .background(dotColor)
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp) )
if (isSelf) { Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(4.dp)) Text(
Text("✓", fontSize = 12.sp, color = Color(0xFF81C784)) 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)) Spacer(Modifier.height(4.dp))
@ -328,13 +362,15 @@ private fun MemberDetailCard(
if (detail.addresses.isNotEmpty()) { if (detail.addresses.isNotEmpty()) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium) Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
detail.addresses.forEach { a -> detail.addresses.forEachIndexed { idx, a ->
Text( Text(
text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}", text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface 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}", Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Start, 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)) Spacer(Modifier.width(4.dp))
Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp) Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}", Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Start, 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) val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName)
if (cleanSn.isNotEmpty()) { if (cleanSn.isNotEmpty()) {
Row { Row {
Spacer(Modifier.width(14.dp)) 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 { scope.launch {
provider.setValue("radio.vad_enabled", if (enabled) "1" else "0") provider.setValue("radio.vad_enabled", if (enabled) "1" else "0")
NativeLib.setChatSetting("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() RadioAudioEngine.instance().reconfigure()
} }
}) })
@ -533,38 +533,21 @@ private fun RadioSettingsTab() {
HorizontalDivider(Modifier.padding(vertical = 4.dp)) HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Оверлей PTT поверх приложений ── */ /* ── Оверлей PTT поверх приложений ── */
var overlayEnabled by remember { mutableStateOf(provider.getRadioOverlay()) } val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
var canOverlay by remember { mutableStateOf(RadioOverlayService.canDrawOverlays(ChatApplication.instance)) } val overlayShown by RadioOverlayService.isShown.collectAsState()
LifecycleResumeEffect(Unit) { val overlayStatus by RadioOverlayService.status.collectAsState()
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) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("PTT-кнопка поверх приложений") Text("PTT-кнопка поверх приложений")
if (!canOverlay) Text( if (overlayEnabled) Text(
"Требуется разрешение «поверх других приложений» — откройте настройки", overlayStatus ?: if (overlayShown) "Плавающая кнопка активна" else "Ожидание включения",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline, modifier = Modifier.clickable { RadioOverlayService.requestPermissions() }
modifier = Modifier.clickable { openOverlayPermission() }) )
} }
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = { Switch(checked = overlayEnabled, onCheckedChange = { enabled ->
overlayEnabled = it scope.launch { RadioOverlayService.setEnabled(enabled) }
scope.launch {
provider.setRadioOverlay(it)
RadioOverlayService.sync(ChatApplication.instance)
}
}) })
} }

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

@ -581,14 +581,11 @@ class ChatViewModel : ViewModel() {
_radioAuto.value = false _radioAuto.value = false
} }
_radioActive.value = next _radioActive.value = next
syncRadioOverlay() if (next) RadioOverlayService.requestPermissions() else 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")
} }
fun radioTalkBegin() { fun radioTalkBegin(): Boolean = _radioActive.value && radioAudio.talkBegin()
if (!_radioActive.value) return
radioAudio.talkBegin()
}
fun radioTalkEnd() { fun radioTalkEnd() {
if (!_radioActive.value) return if (!_radioActive.value) return

Loading…
Cancel
Save