diff --git a/bg.png b/bg.png new file mode 100644 index 00000000..6fd80417 Binary files /dev/null and b/bg.png differ diff --git a/doc/pm.txt b/doc/pm.txt new file mode 100644 index 00000000..3052a56c --- /dev/null +++ b/doc/pm.txt @@ -0,0 +1,12 @@ +Работа лички + +1. отдели логику лички от логики групповых чатов. не смешивай. + +Работа лички. сообщения и медиа доставляется напрямую если узел доступен через bgp. +если недоступен - то pending сообщения сохраняются на суперузле и pending media сохраняется на каком-либо медиаузле, а суперузлу сообщается где хранится медиа. +при появлении пира суперузел организует доставку медиа до получателя. после успешной доставки медиа/сообщений они удаляются с промежуточных узлов. + +время хранения медиафайлов на промежуточных узлах ограничено - 1 неделя по умолчанию (задачется в конфиге этих узлов). +для доставки медиа поднимаем линк (conn_mgr). + +и посмотри реализацию медиа для групповых чатов. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 50600898..4e980e1a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -53,13 +53,7 @@ class ChatApplication : Application() { TelecomCallManager.init() CallController.init() - /* First launch config */ - if (configProvider.isFirstLaunch()) { - kotlinx.coroutines.runBlocking { - LogManager.addLog("INFO", "APP", "First launch — marking launched") - configProvider.markLaunched() - } - } + } companion object { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 3ec0811b..9800effe 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -2,6 +2,11 @@ package com.utun.chat import android.Manifest import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import com.utun.chat.data.MicrophonePermission import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -35,6 +40,7 @@ import com.utun.chat.data.InviteData import com.utun.chat.data.LogManager import com.utun.chat.data.Message import com.utun.chat.data.NativeLib +import com.utun.chat.data.RadioOverlayService import com.utun.chat.data.RadioAudioEngine import com.utun.chat.headless.HeadlessService import com.utun.chat.ui.theme.VibeChatTheme @@ -44,15 +50,69 @@ import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call, Dm } + enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, VideoRecord, PhotoRecord, Call, Dm } private var overLock = false + private var microphoneAction: (() -> Unit)? = null + private var microphoneReason = "" + private val overlayPermissionRequester: () -> Unit = { + try { + overlayPermissionLauncher.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))) + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioOverlay", "Cannot open overlay permission settings: $e") + RadioOverlayService.overlayPermissionResult() + } + } + private val overlayPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + RadioOverlayService.overlayPermissionResult() + } + private val microphoneRequester: (String, () -> Unit) -> Unit = { reason, action -> requestMicrophone(reason, action) } + private val microphoneLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val action = microphoneAction + microphoneAction = null + LogManager.addLog(if (granted) "INFO" else "WARN", "PERMISSION", "Microphone result=$granted reason=$microphoneReason") + if (granted) action?.invoke() + else { + Toast.makeText(this, "Для звонков и PTT разрешите доступ к микрофону", Toast.LENGTH_LONG).show() + if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) showMicrophoneSettings() + } + } + + private fun requestMicrophone(reason: String, action: () -> Unit) { + if (MicrophonePermission.granted()) { action(); return } + if (microphoneAction != null) { + LogManager.addLog("WARN", "PERMISSION", "Microphone request already pending: $microphoneReason; ignored=$reason") + return + } + microphoneReason = reason + microphoneAction = action + LogManager.addLog("INFO", "PERMISSION", "Requesting RECORD_AUDIO: $reason") + try { + microphoneLauncher.launch(Manifest.permission.RECORD_AUDIO) + } catch (e: Exception) { + microphoneAction = null + LogManager.addLog("ERROR", "PERMISSION", "Cannot request microphone permission: $e") + Toast.makeText(this, "Не удалось запросить доступ к микрофону", Toast.LENGTH_LONG).show() + } + } + + private fun showMicrophoneSettings() { + android.app.AlertDialog.Builder(this) + .setMessage("Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT.") + .setPositiveButton("Настройки") { _, _ -> + try { + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + } catch (e: Exception) { + LogManager.addLog("ERROR", "PERMISSION", "Cannot open app settings: $e") + } + } + .setNegativeButton("Отмена", null).show() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestNotificationPermission() - requestFgsMicPermission() /* Входящий звонок: showWhenLocked/turnScreenOn ДО setContent, иначе полноэкранный intent мигает и UI не отрисовывается поверх keyguard (холодный старт). */ @@ -67,14 +127,27 @@ class MainActivity : ComponentActivity() { navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) ) - /* Start uTun headless service (must be from foreground Activity on API 34+). - Only if it was running when the app last exited (remembered via DataStore). */ - if (ChatApplication.instance.configProvider.getServiceEnabled()) { - ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java)) - } - setContent { VibeChatTheme { + val provider = ChatApplication.instance.configProvider + var firstLaunch by remember { mutableStateOf(provider.isFirstLaunch()) } + LaunchedEffect(firstLaunch) { + if (firstLaunch) { + LogManager.addLog("INFO", "SETUP", "Showing first launch wizard") + } else if (provider.getServiceEnabled()) { + ContextCompat.startForegroundService(this@MainActivity, Intent(this@MainActivity, HeadlessService::class.java)) + } + } + if (firstLaunch) { + FirstLaunchScreen( + initialName = provider.getString("node.name") ?: provider.getDeviceName(), + onComplete = { name -> + provider.completeFirstLaunch(name) + firstLaunch = false + } + ) + return@VibeChatTheme + } val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory()) /* Pause GUI polling (health-check, recording/playback timers) when backgrounded, @@ -85,9 +158,7 @@ class MainActivity : ComponentActivity() { } var screen by remember { mutableStateOf(Screen.ChannelList) } - var qrResult by remember { mutableStateOf(null) } var showJoinDialog by remember { mutableStateOf(false) } - var joinAutoLink by remember { mutableStateOf(null) } var inviteChannelId by remember { mutableStateOf(null) } var videoMessage by remember { mutableStateOf(null) } var photoMessage by remember { mutableStateOf(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") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index 71be5c98..4cf86be6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt @@ -94,6 +94,10 @@ object CallController : TelecomCallManager.Listener { LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}") return } + if (!MicrophonePermission.granted()) { + MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) } + return + } var id = java.security.SecureRandom().nextLong() if (id == 0L) id = 1 _state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING) @@ -108,6 +112,13 @@ object CallController : TelecomCallManager.Listener { LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting") return } + if (!MicrophonePermission.granted()) { + MicrophonePermission.request("Answer call id=${st.callId}") { + if (_state.value?.callId == st.callId && _state.value?.phase == CallPhase.INCOMING) accept() + else LogManager.addLog("WARN", "Call", "Permission granted after incoming call ended id=${st.callId}") + } + return + } accepting = true requestService(st.callId) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 4ca3bba4..9e50842c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -235,8 +235,14 @@ class ConfigProvider(private val context: Context) { !(context.dataStore.data.first()[firstLaunchDone] ?: false) } - suspend fun markLaunched() { - context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true } + suspend fun completeFirstLaunch(name: String) { + val nick = name.trim() + require(nick.isNotEmpty() && nick.none { it.isISOControl() }) { "Invalid device name" } + context.dataStore.edit { prefs -> + prefs[nodeName] = nick + prefs[firstLaunchDone] = true + } + LogManager.addLog("INFO", "SETUP", "First launch completed: device name=$nick") } /* ── Connection mode ── */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt new file mode 100644 index 00000000..9448fccf --- /dev/null +++ b/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") + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index d67851fb..c7adf47b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -406,6 +406,10 @@ class RadioAudioEngine { } private fun startCapture(vad: Boolean): Boolean { + if (!MicrophonePermission.granted()) { + LogManager.addLog("WARN", "RadioAudio", "Capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad") + return false + } val startNs = SystemClock.elapsedRealtimeNanos() val (rec, channels) = createAudioRecord() if (rec == null) return false @@ -508,10 +512,18 @@ class RadioAudioEngine { restoreAudioMode() } - fun talkBegin() { - if (!active) return + fun talkBegin(): Boolean { + if (!active) return false + if (!MicrophonePermission.granted()) { + MicrophonePermission.request("PTT") { + RadioOverlayService.sync(ChatApplication.instance) + android.widget.Toast.makeText(ChatApplication.instance, "Микрофон разрешён. Нажмите PTT снова", + android.widget.Toast.LENGTH_LONG).show() + } + return false + } mainHandler.removeCallbacks(releaseTalk) - if (requestedTalk) return + if (requestedTalk) return true talkRequestNs = SystemClock.elapsedRealtimeNanos() requestedTalk = true _talkingFlow.value = true @@ -523,6 +535,7 @@ class RadioAudioEngine { updateCapture() } } + return true } /** Отпускание: 250мс передачи, повторное касание отменяет завершение. */ @@ -533,6 +546,12 @@ class RadioAudioEngine { LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms") } + /** Отмена без хвоста передачи при выключении/потере разрешений. */ + fun cancelTalk() { + mainHandler.removeCallbacks(releaseTalk) + finishTalk() + } + private fun finishTalk() { if (!requestedTalk) return requestedTalk = false diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt index fdbaa8cf..528a5310 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt @@ -1,12 +1,10 @@ package com.utun.chat.data -import android.Manifest import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.graphics.PixelFormat import android.graphics.Point @@ -24,7 +22,6 @@ import android.view.WindowManager import android.widget.TextView import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout import com.utun.chat.ChatApplication import kotlin.math.abs @@ -32,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -72,45 +71,133 @@ class RadioOverlayService : Service() { @Volatile private var running = false @Volatile private var starting = false @Volatile private var pendingStop = false + private var restartAfterStop = false fun canDrawOverlays(ctx: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) - fun sync(ctx: Context) { - val provider = ChatApplication.instance.configProvider - if (RadioAudioEngine.instance().isActive() && - (provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0)) start(ctx) - else stop(ctx) + private var activityVisible = false + private var waitingOverlayPermission = false + var overlayPermissionRequester: (() -> Unit)? = null + private val _status = MutableStateFlow(null) + val status: StateFlow = _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) + } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 9a17aace..09aa3029 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -1,8 +1,6 @@ package com.utun.chat.ui.screens import android.content.ContentResolver -import android.content.Intent -import android.provider.Settings import android.net.Uri import android.os.SystemClock import android.provider.OpenableColumns @@ -319,7 +317,7 @@ fun ChatScreen( private fun RadioPttButton( modifier: Modifier = Modifier, isTalking: Boolean = false, - onTalkBegin: () -> Unit, + onTalkBegin: () -> Boolean, onTalkEnd: () -> Unit ) { val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS) @@ -341,11 +339,12 @@ private fun RadioPttButton( val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout val wasLatched = latched + var started = false if (wasLatched) { latched = false lastTapUp = 0L onTalkEnd() - } else onTalkBegin() + } else started = onTalkBegin() var released = false try { @@ -356,7 +355,7 @@ private fun RadioPttButton( if (!c.pressed) { released = true; break } } val upTime = SystemClock.uptimeMillis() - val tapped = released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS + val tapped = started && released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS if (!wasLatched && tapped && isSecondTap) { latched = true lastTapUp = 0L @@ -390,67 +389,57 @@ private fun RadioSourceBar(viewModel: ChatViewModel) { val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState() val btInputAvailable by viewModel.btInputAvailable.collectAsState() val radioRoute by viewModel.radioRoute.collectAsState() - val context = LocalContext.current val provider = ChatApplication.instance.configProvider val scope = rememberCoroutineScope() val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) - val overlayPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (RadioOverlayService.canDrawOverlays(context)) { - scope.launch { - provider.setRadioOverlay(true) - RadioOverlayService.sync(context) - LogManager.addLog("INFO", "RadioUI", "floating PTT enabled after permission grant") - } - } else LogManager.addLog("WARN", "RadioUI", "floating PTT permission not granted") - } + val overlayShown by RadioOverlayService.isShown.collectAsState() + val overlayStatus by RadioOverlayService.status.collectAsState() Surface(color = Color(0xFF0F1B22)) { - Row( - modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Column { Row( - modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp - RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}", - radioAuto, centerIcon = currentDeviceIcon) { - viewModel.setRadioAuto() - } - RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона", - !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) { - viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) - } - if (wiredInputAvailable) { - RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры", - !radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) { - viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp + RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}", + radioAuto, centerIcon = currentDeviceIcon) { + viewModel.setRadioAuto() } - } - if (btInputAvailable) { - RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры", - !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) { - viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) + RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона", + !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) { + viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) } - } - } - VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550)) - RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) { - if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) { - try { - overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:${context.packageName}"))) - } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioUI", "open overlay permission failed: ${e.message}") + if (wiredInputAvailable) { + RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры", + !radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) { + viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) + } + } + if (btInputAvailable) { + RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры", + !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) { + viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) + } } - } else scope.launch { - provider.setRadioOverlay(!overlayEnabled) - RadioOverlayService.sync(context) - LogManager.addLog("INFO", "RadioUI", "floating PTT enabled=${provider.getRadioOverlay()}") } + VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550)) + RadioControlButton(null, "Плавающая PTT-кнопка", overlayShown, floating = true) { + scope.launch { + if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions() + else RadioOverlayService.setEnabled(!overlayEnabled) + } + } + } + if (overlayEnabled && !overlayShown) overlayStatus?.let { message -> + Text(message, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp).clickable { RadioOverlayService.requestPermissions() }) } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt new file mode 100644 index 00000000..6775d8ff --- /dev/null +++ b/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(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 "Продолжить") + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt index 78a21dce..acad2014 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt @@ -1,142 +1,85 @@ package com.utun.chat.ui.screens import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.utun.chat.data.InviteData import com.utun.chat.data.InviteLink import com.utun.chat.data.LogManager -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun JoinChannelDialog( - autoLink: String? = null, - onScanQr: () -> Unit, - onConnect: (InviteData) -> Unit, - onDismiss: () -> Unit -) { - var link by remember { mutableStateOf(autoLink ?: "") } - var decoded by remember { mutableStateOf(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(null) } var connecting by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() + val connect by rememberUpdatedState(onConnect) - if (!autoLink.isNullOrBlank()) { - LaunchedEffect(autoLink) { - connecting = true - isError = false - - status = "Decoding..." - delay(300) - - val d = InviteLink.decode(autoLink.trim()) - if (d != null) { - decoded = d - status = "Connecting to peer..." - delay(800) + LaunchedEffect(Unit) { + val text = clipboard.getText()?.text?.trim().orEmpty() + if (text.startsWith("utun://")) { + LogManager.addLog("INFO", "INVITE", "Invite link pasted from clipboard") + link = text + } + } - LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" - .format(d.channelId, d.nodeId, d.addrs.size)) - onConnect(d) - } else { - connecting = false - status = "Invalid invite link" - isError = true - } + LaunchedEffect(link) { + error = null + val text = link.trim() + if (text.isEmpty() || connecting) return@LaunchedEffect + val invite = InviteLink.decode(text) + if (invite == null) { + LogManager.addLog("WARN", "INVITE", "Invalid invite link in join dialog") + error = "Invalid invite link" + } else { + connecting = true + LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" + .format(invite.channelId, invite.nodeId, invite.addrs.size)) + connect(invite) } } AlertDialog( - onDismissRequest = { if (!connecting) onDismiss() }, + onDismissRequest = onDismiss, title = { Text("Join Channel") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (autoLink.isNullOrBlank()) { - OutlinedTextField( - value = link, - onValueChange = { newVal -> - link = newVal - status = "" - isError = false - scope.launch { - delay(400) - if (link == newVal) { - if (newVal.isBlank()) { - decoded = null - } else { - decoded = InviteLink.decode(newVal.trim()) - } - } - } - }, - label = { Text("Invite link") }, - placeholder = { Text("utun://...") }, - trailingIcon = { - IconButton(onClick = onScanQr) { - Icon(Icons.Default.QrCodeScanner, "Scan QR") - } - }, - singleLine = true, - enabled = !connecting, - modifier = Modifier.fillMaxWidth() - ) - } - - decoded?.let { d -> - val addrStr = d.addrs.joinToString(", ") { InviteLink.addrToString(it) } - Column(Modifier.fillMaxWidth()) { - Text("Node: 0x%016x".format(d.nodeId), fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text("Channel: ${d.channelId}", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) - if (addrStr.isNotBlank()) - Text(addrStr, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - if (status.isNotBlank()) { - Text( - status, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, - fontSize = 14.sp, fontWeight = FontWeight.Medium + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (!connecting) { + QrScannerPreview( + modifier = Modifier.fillMaxWidth().height(180.dp), + onResult = { link = it } ) } + OutlinedTextField( + value = link, + onValueChange = { link = it }, + label = { Text("Invite link") }, + placeholder = { Text("utun://...") }, + trailingIcon = { + IconButton(onClick = { link = clipboard.getText()?.text.orEmpty() }, enabled = !connecting) { + Icon(Icons.Filled.ContentPaste, "Paste invite link") + } + }, + singleLine = true, + enabled = !connecting, + isError = error != null, + modifier = Modifier.fillMaxWidth() + ) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (connecting) Text("Connecting to peer...", color = MaterialTheme.colorScheme.primary) } }, - confirmButton = { - Button( - onClick = { - connecting = true - isError = false - status = "Connecting to peer..." - val d = InviteLink.decode(link.trim()) - if (d != null) { - onConnect(d) - } else { - connecting = false - status = "Invalid invite link" - isError = true - } - }, - enabled = !connecting && decoded != null && (autoLink.isNullOrBlank()) - ) { - Text(if (connecting) "..." else "Connect") - } - }, - dismissButton = { - if (autoLink.isNullOrBlank()) { - TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") } - } - } + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 0be93a01..3dbd68ff 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -14,6 +14,11 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import android.util.Log +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Star @@ -83,13 +88,8 @@ fun MemberListScreen( member = m, isSelected = selectedMember?.nodeId == m.nodeId, onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, - onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, - onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) }, - onEdit = { - editMember = m - showEditDialog = true - } + onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) } ) } } @@ -100,12 +100,24 @@ fun MemberListScreen( exit = slideOutVertically(targetOffsetY = { it }) ) { selectedMember?.let { detail -> + val member = members.find { it.nodeId == detail.nodeId } MemberDetailCard( detail = detail, memberLinks = memberLinks, localSocketStates = localSockets, - isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf }, - onDismiss = { } + isSelf = member?.isSelf == true, + canEdit = member != null, + onConnect = { + Log.i("utun-gui", "Member detail connect: channel=${channel.id} node=${detail.nodeId}") + viewModel.connectMemberNode(channel.id, detail.nodeId) + }, + onEdit = { + member?.let { + Log.i("utun-gui", "Member detail edit: channel=${channel.id} node=${it.nodeId}") + editMember = it + showEditDialog = true + } + } ) } } @@ -136,10 +148,8 @@ private fun MemberListItem( member: ChatMember, isSelected: Boolean = false, onClick: () -> Unit, - onConnect: () -> Unit, onCall: () -> Unit, - onMessage: () -> Unit, - onEdit: () -> Unit + onMessage: () -> Unit ) { val onlineColor = when { member.connecting -> Color(0xFFFFEB3B) // yellow: connecting @@ -156,7 +166,7 @@ private fun MemberListItem( tonalElevation = 0.dp ) { Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { if (member.isSelf) { @@ -185,6 +195,14 @@ private fun MemberListItem( Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) } + if (member.isStorage) { + Icon(Icons.Filled.Storage, "Storage", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + } + if (!member.isVerified) { + Icon(Icons.Filled.Close, "Not verified", tint = Color.Red, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + } Text( text = member.name, fontWeight = FontWeight.Medium, @@ -196,40 +214,24 @@ private fun MemberListItem( modifier = Modifier.weight(1f) ) } - val parts = mutableListOf() - 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, 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) - } + } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt deleted file mode 100644 index 4136ee74..00000000 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScanScreen.kt +++ /dev/null @@ -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(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 - } - ) - } - } -} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt new file mode 100644 index 00000000..7cc12e50 --- /dev/null +++ b/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(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) } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index d9aaebad..2d2f66b2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -493,7 +493,7 @@ private fun RadioSettingsTab() { scope.launch { provider.setValue("radio.vad_enabled", if (enabled) "1" else "0") NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0") - RadioOverlayService.sync(ChatApplication.instance) + if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ChatApplication.instance) RadioAudioEngine.instance().reconfigure() } }) @@ -533,38 +533,21 @@ private fun RadioSettingsTab() { HorizontalDivider(Modifier.padding(vertical = 4.dp)) /* ── Оверлей PTT поверх приложений ── */ - var overlayEnabled by remember { mutableStateOf(provider.getRadioOverlay()) } - var canOverlay by remember { mutableStateOf(RadioOverlayService.canDrawOverlays(ChatApplication.instance)) } - LifecycleResumeEffect(Unit) { - canOverlay = RadioOverlayService.canDrawOverlays(ChatApplication.instance) - onPauseOrDispose { } - } - val openOverlayPermission = { - try { - val i = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:${ChatApplication.instance.packageName}")) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ChatApplication.instance.startActivity(i) - } catch (e: Exception) { - LogManager.addLog("ERROR", "SETTINGS", "open overlay permission failed: ${e.message}") - } - } + val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) + val overlayShown by RadioOverlayService.isShown.collectAsState() + val overlayStatus by RadioOverlayService.status.collectAsState() Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text("PTT-кнопка поверх приложений") - if (!canOverlay) Text( - "Требуется разрешение «поверх других приложений» — откройте настройки", + if (overlayEnabled) Text( + overlayStatus ?: if (overlayShown) "Плавающая кнопка активна" else "Ожидание включения", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - modifier = Modifier.clickable { openOverlayPermission() }) + modifier = Modifier.clickable { RadioOverlayService.requestPermissions() } + ) } - Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = { - overlayEnabled = it - scope.launch { - provider.setRadioOverlay(it) - RadioOverlayService.sync(ChatApplication.instance) - } + Switch(checked = overlayEnabled, onCheckedChange = { enabled -> + scope.launch { RadioOverlayService.setEnabled(enabled) } }) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 9dcbbd96..ea8af038 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -581,14 +581,11 @@ class ChatViewModel : ViewModel() { _radioAuto.value = false } _radioActive.value = next - syncRadioOverlay() + if (next) RadioOverlayService.requestPermissions() else syncRadioOverlay() LogManager.addLog("INFO", "VM", "toggleRadio -> ${if (next) "on" else "off"} group=$groupId") } - fun radioTalkBegin() { - if (!_radioActive.value) return - radioAudio.talkBegin() - } + fun radioTalkBegin(): Boolean = _radioActive.value && radioAudio.talkBegin() fun radioTalkEnd() { if (!_radioActive.value) return