Improve Android onboarding, member UI and PTT permissions
This commit is contained in:
1 parent
577bfb4e6f
commit
ce5f5bb7d0
17 files changed
+842
-555
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+170
-43
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (btInputAvailable) {
|
||||
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (btInputAvailable) {
|
||||
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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}")
|
||||
}
|
||||
} else scope.launch {
|
||||
provider.setRadioOverlay(!overlayEnabled)
|
||||
RadioOverlayService.sync(context)
|
||||
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled=${provider.getRadioOverlay()}")
|
||||
}
|
||||
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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "Продолжить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
-115
@@ -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) {
|
||||
LaunchedEffect(Unit) {
|
||||
val text = clipboard.getText()?.text?.trim().orEmpty()
|
||||
if (text.startsWith("utun://")) {
|
||||
LogManager.addLog("INFO", "INVITE", "Invite link pasted from clipboard")
|
||||
link = text
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
isError = false
|
||||
|
||||
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"
|
||||
.format(d.channelId, d.nodeId, d.addrs.size))
|
||||
onConnect(d)
|
||||
} else {
|
||||
connecting = false
|
||||
status = "Invalid invite link"
|
||||
isError = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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())
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(if (connecting) "..." else "Connect")
|
||||
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)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (autoLink.isNullOrBlank()) {
|
||||
TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
+98
-92
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-27
@@ -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) }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user