From 237ba93e1a641e1aa338d121ae3e2eb4ce1bb95b Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 12:19:47 +0100 Subject: [PATCH] Report invitation presentation errors without exposing link secrets --- .../utun/chat/ui/screens/InviteLinkDialog.kt | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt index b07170fb..9ba50dc3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt @@ -36,6 +36,11 @@ private fun candidateLabel(c: InviteCandidate): String = else -> "" } +/** Показ зарегистрированной ссылки/QR и отправка её устройству в LAN; генерацией и корреляцией link/pending владеет caller. + * Первый candidate — визуальный fallback; явный выбор вызывает onSelectNode, начальный запрос делает caller. + * При открытии запрашивает LAN-снимок и снимает только прежний терминальный результат; активная отправка переживает закрытие. + * Результат LAN относится к сохранённому адресату, не означает JOIN. Состояние копирования сбрасывается при смене ссылки. + * Ошибки QR оставляют текст/копирование доступными; ошибки clipboard показываются в диалоге. Содержимое ссылки не логируется. */ @Composable fun InviteLinkDialog( candidates: List, @@ -45,7 +50,8 @@ fun InviteLinkDialog( onDismiss: () -> Unit ) { val context = LocalContext.current - var copied by remember { mutableStateOf(false) } + var copied by remember(link) { mutableStateOf(false) } + var copyFailed by remember(link) { mutableStateOf(false) } var selectedNodeId by remember { mutableStateOf(null) } var menuOpen by remember { mutableStateOf(false) } val devices by LocalDiscovery.devices.collectAsState() @@ -53,7 +59,14 @@ fun InviteLinkDialog( var selectedDevice by remember { mutableStateOf(null) } var devicesOpen by remember { mutableStateOf(false) } val device = devices.firstOrNull { it.nodeId == selectedDevice } ?: devices.firstOrNull() - LaunchedEffect(Unit) { LocalDiscovery.clearDelivery(); NativeLib.requestLocalDevices() } + LaunchedEffect(Unit) { + LocalDiscovery.clearDelivery() + try { + if (!NativeLib.requestLocalDevices()) LogManager.addLog("WARN", "INVITE", "LAN device snapshot request rejected") + } catch (error: Exception) { + LogManager.addLog("ERROR", "INVITE", "LAN device snapshot request failed: ${error.javaClass.simpleName}") + } + } val effectiveSelected = remember(candidates, selectedNodeId) { candidates.firstOrNull { it.nodeId == selectedNodeId } ?: candidates.firstOrNull() @@ -110,6 +123,7 @@ fun InviteLinkDialog( } else -> Text(stringResource(R.string.invite_build_failed), fontFamily = FontFamily.Monospace, fontSize = 11.sp) } + if (copyFailed) Text(stringResource(R.string.invite_copy_failed), color = MaterialTheme.colorScheme.error) if (devices.isNotEmpty()) { Spacer(Modifier.height(8.dp)) Text(stringResource(R.string.local_invite_devices), style = MaterialTheme.typography.bodyMedium) @@ -128,24 +142,37 @@ fun InviteLinkDialog( }) { Text(stringResource(R.string.local_invite_send)) } } delivery?.let { status -> - Text(stringResource(when { + val text = stringResource(when { status.pending -> R.string.local_invite_sending status.forbidden -> R.string.local_invite_forbidden status.received -> R.string.local_invite_delivered else -> R.string.local_invite_failed - }), style = MaterialTheme.typography.bodySmall) + }) + Text(stringResource(R.string.invite_delivery_status, status.device.label, text), + style = MaterialTheme.typography.bodySmall) } } }, confirmButton = { Button( - enabled = link != null, + enabled = link != null && !pending, onClick = { link?.let { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("invite", it)) - copied = true - LogManager.addLog("INFO", "INVITE", "Link copied to clipboard") + copyFailed = false; copied = false + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + copyFailed = true + LogManager.addLog("ERROR", "INVITE", "Clipboard service unavailable") + } else { + clipboard.setPrimaryClip(ClipData.newPlainText("invite", it)) + copied = true + LogManager.addLog("INFO", "INVITE", "Link copied to clipboard") + } + } catch (error: Exception) { + copyFailed = true + LogManager.addLog("ERROR", "INVITE", "Clipboard copy failed: ${error.javaClass.simpleName}") + } } } ) { Text(stringResource(if (copied) R.string.copied else R.string.copy_link)) } @@ -156,6 +183,7 @@ fun InviteLinkDialog( ) } +/** QR 400×400 для текущей ссылки; ошибка логируется без секретных данных и оставляет владельцу текстовый fallback. */ private fun generateQRCode(text: String): Bitmap? { return try { val writer = MultiFormatWriter() @@ -168,6 +196,7 @@ private fun generateQRCode(text: String): Bitmap? { } bitmap } catch (e: Exception) { + LogManager.addLog("ERROR", "INVITE", "QR generation failed length=${text.length}: ${e.javaClass.simpleName}") null } }