feat(sharing): Refactor QR/NFC scanning with ML Kit and CameraX (#4471)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-05 22:16:16 -06:00 committed by GitHub
parent 3971c0a9f4
commit 96551761c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1455 additions and 464 deletions

View file

@ -14,54 +14,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("detekt:ALL")
package org.meshtastic.core.ui.component
import android.Manifest
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.QrCodeScanner
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.journeyapps.barcodescanner.BarcodeEncoder
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.google.zxing.common.BitMatrix
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.CONTACT_SHARE_PATH
import org.meshtastic.core.model.util.CONTACT_URL_PREFIX
import org.meshtastic.core.model.util.MESHTASTIC_HOST
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.qr_code
import org.meshtastic.core.strings.scan_qr_code
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.ui.R
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
@ -72,84 +49,18 @@ import java.net.MalformedURLException
*
* @param modifier Modifier for this composable.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
sharedContact: SharedContact?,
modifier: Modifier = Modifier,
onSharedContactRequested: (SharedContact?) -> Unit,
onResult: (Uri) -> Unit,
onShareChannels: (() -> Unit)? = null,
onDismissSharedContact: () -> Unit,
) {
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
val uri = result.contents.toUri()
val sharedContact =
try {
uri.toSharedContact()
} catch (ex: MalformedURLException) {
Logger.e { "URL was malformed: ${ex.message}" }
null
}
if (sharedContact != null) {
onSharedContactRequested(sharedContact)
}
}
}
sharedContact?.let { SharedContactImportDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
fun zxingScan() {
Logger.d { "Starting zxing QR code scanner" }
val zxingScan = ScanOptions()
zxingScan.setCameraId(CAMERA_ID)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
barcodeLauncher.launch(zxingScan)
}
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(cameraPermissionState.status) {
if (cameraPermissionState.status.isGranted) {
Logger.d { "Camera permission granted" }
} else {
Logger.d { "Camera permission denied" }
}
}
FloatingActionButton(
modifier = modifier,
onClick = {
if (cameraPermissionState.status.isGranted) {
zxingScan()
} else {
cameraPermissionState.launchPermissionRequest()
}
},
) {
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(Res.string.scan_qr_code))
}
}
@Composable
private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image(
painter = uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode),
contentDescription = stringResource(Res.string.qr_code),
modifier = modifier,
contentScale = ContentScale.Inside,
)
@Composable
private fun SharedContact(contactUri: Uri) {
Column {
QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp))
Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = contactUri.toString(), modifier = Modifier.weight(1f))
CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp))
}
}
ImportFab(onImport = onResult, modifier = modifier, onShareChannels = onShareChannels, isContactContext = true)
}
/**
@ -161,48 +72,52 @@ private fun SharedContact(contactUri: Uri) {
@Composable
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
if (contact == null) return
val sharedContact = SharedContact(user = contact.user, node_num = contact.num)
val uri = sharedContact.getSharedContactUrl()
SimpleAlertDialog(
title = Res.string.share_contact,
text = {
Column {
Text(contact.user.long_name)
SharedContact(contactUri = uri)
}
},
onDismiss = onDismiss,
)
val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
val uri = contactToShare.getSharedContactUrl()
QrDialog(title = stringResource(Res.string.share_contact), uri = uri, qrCode = uri.qrCode, onDismiss = onDismiss)
}
@Preview
/**
* Displays a dialog for importing a shared contact.
*
* @param sharedContact The [SharedContact] to import.
* @param onDismiss Callback invoked when the dialog is dismissed.
*/
@Composable
private fun ShareContactPreview() {
SharedContact(contactUri = "https://example.com".toUri())
fun SharedContactImportDialog(sharedContact: SharedContact, onDismiss: () -> Unit) {
org.meshtastic.core.ui.share.SharedContactDialog(sharedContact = sharedContact, onDismiss = onDismiss)
}
/** Bitmap representation of the Uri as a QR code, or null if generation fails. */
@Suppress("detekt:MagicNumber")
val Uri.qrCode: Bitmap?
get() =
try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix =
multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE)
val barcodeEncoder = BarcodeEncoder()
barcodeEncoder.createBitmap(bitMatrix)
val bitMatrix = multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, 960, 960)
bitMatrix.toBitmap()
} catch (ex: WriterException) {
Logger.e { "URL was too complex to render as barcode: ${ex.message}" }
null
}
private const val BARCODE_PIXEL_SIZE = 960
private const val MESHTASTIC_HOST = "meshtastic.org"
private const val CONTACT_SHARE_PATH = "/v/"
@Suppress("detekt:MagicNumber")
private fun BitMatrix.toBitmap(): Bitmap {
val width = width
val height = height
val pixels = IntArray(width * height)
for (y in 0 until height) {
val offset = y * width
for (x in 0 until width) {
pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
}
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}
/** Prefix for Meshtastic contact sharing URLs. */
internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
private const val CAMERA_ID = 0
@Suppress("MagicNumber")
@Throws(MalformedURLException::class)
@ -217,7 +132,7 @@ fun Uri.toSharedContact(): SharedContact {
fun SharedContact.getSharedContactUrl(): Uri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return "$URL_PREFIX$enc".toUri()
return "$CONTACT_URL_PREFIX$enc".toUri()
}
/** Compares two [User] objects and returns a string detailing the differences. */
@ -230,7 +145,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
}
if (oldUser.macaddr != newUser.macaddr) {
changes.add("macaddr: ${oldUser.macaddr?.base64()} -> ${newUser.macaddr?.base64()}")
changes.add("macaddr: ${oldUser.macaddr?.base64String()} -> ${newUser.macaddr?.base64String()}")
}
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
if (oldUser.is_licensed != newUser.is_licensed) {
@ -238,7 +153,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
}
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
if (oldUser.public_key != newUser.public_key) {
changes.add("public_key: ${oldUser.public_key?.base64()} -> ${newUser.public_key?.base64()}")
changes.add("public_key: ${oldUser.public_key?.base64String()} -> ${newUser.public_key?.base64String()}")
}
return if (changes.isEmpty()) {
@ -255,13 +170,13 @@ fun userFieldsToString(user: User): String {
fieldLines.add("id: ${user.id}")
fieldLines.add("long_name: ${user.long_name}")
fieldLines.add("short_name: ${user.short_name}")
fieldLines.add("macaddr: ${user.macaddr?.base64()}")
fieldLines.add("macaddr: ${user.macaddr?.base64String()}")
fieldLines.add("hw_model: ${user.hw_model}")
fieldLines.add("is_licensed: ${user.is_licensed}")
fieldLines.add("role: ${user.role}")
fieldLines.add("public_key: ${user.public_key?.base64()}")
fieldLines.add("public_key: ${user.public_key?.base64String()}")
return fieldLines.joinToString("\n")
}
private fun ByteString.base64(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()

View file

@ -0,0 +1,181 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import android.net.Uri
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Nfc
import androidx.compose.material.icons.twotone.QrCodeScanner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.input_shared_contact_url
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.scan_channels_nfc
import org.meshtastic.core.strings.scan_channels_qr
import org.meshtastic.core.strings.scan_nfc
import org.meshtastic.core.strings.scan_nfc_text
import org.meshtastic.core.strings.scan_shared_contact_nfc
import org.meshtastic.core.strings.scan_shared_contact_qr
import org.meshtastic.core.strings.share_channels_qr
import org.meshtastic.core.strings.url
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.QrCode2
/**
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL.
*
* @param modifier Modifier for this composable.
* @param onImport Callback when a valid Meshtastic URI is scanned or input.
* @param onShareChannels Optional callback to trigger sharing channels.
* @param isContactContext Hint to customize UI strings for contact importing context.
*/
@Suppress("LongMethod")
@Composable
fun ImportFab(
onImport: (Uri) -> Unit,
modifier: Modifier = Modifier,
onShareChannels: (() -> Unit)? = null,
isContactContext: Boolean = false,
) {
var expanded by remember { mutableStateOf(false) }
var showUrlDialog by remember { mutableStateOf(false) }
var isNfcScanning by remember { mutableStateOf(false) }
val barcodeScanner =
rememberBarcodeScanner(
onResult = { contents ->
contents?.toUri()?.let {
onImport(it)
isNfcScanning = false
}
},
)
if (isNfcScanning) {
NfcScannerEffect(
onResult = { contents ->
contents?.toUri()?.let {
onImport(it)
isNfcScanning = false
}
},
)
NfcScanningDialog(onDismiss = { isNfcScanning = false })
}
if (showUrlDialog) {
InputUrlDialog(
title = stringResource(Res.string.input_shared_contact_url),
onDismiss = { showUrlDialog = false },
onConfirm = { contents ->
onImport(contents.toUri())
showUrlDialog = false
},
)
}
val items =
mutableListOf(
MenuFABItem(
label =
stringResource(
if (isContactContext) Res.string.scan_shared_contact_nfc else Res.string.scan_channels_nfc,
),
icon = Icons.Rounded.Nfc,
onClick = { isNfcScanning = true },
),
MenuFABItem(
label =
stringResource(
if (isContactContext) Res.string.scan_shared_contact_qr else Res.string.scan_channels_qr,
),
icon = Icons.TwoTone.QrCodeScanner,
onClick = { barcodeScanner.startScan() },
),
MenuFABItem(
label = stringResource(Res.string.input_shared_contact_url),
icon = Icons.Rounded.Link,
onClick = { showUrlDialog = true },
),
)
onShareChannels?.let {
items.add(
MenuFABItem(
label = stringResource(Res.string.share_channels_qr),
icon = MeshtasticIcons.QrCode2,
onClick = it,
),
)
}
MenuFAB(
expanded = expanded,
onExpandedChange = { expanded = it },
items = items,
modifier = modifier.padding(bottom = 16.dp),
)
}
@Composable
private fun NfcScanningDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.scan_nfc)) },
text = { Text(stringResource(Res.string.scan_nfc_text)) },
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
@Composable
private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var urlText by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
OutlinedTextField(
value = urlText,
onValueChange = { urlText = it },
label = { Text(stringResource(Res.string.url)) },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
)
},
confirmButton = { TextButton(onClick = { onConfirm(urlText) }) { Text(stringResource(Res.string.okay)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OfflineShare
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButtonMenu
import androidx.compose.material3.FloatingActionButtonMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleFloatingActionButton
import androidx.compose.material3.ToggleFloatingActionButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MenuFAB(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
items: List<MenuFABItem>,
modifier: Modifier = Modifier,
) {
FloatingActionButtonMenu(
modifier = modifier,
expanded = expanded,
button = {
ToggleFloatingActionButton(
checked = expanded,
onCheckedChange = onExpandedChange,
content = {
val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare
Icon(imageVector = imageVector, contentDescription = null)
},
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
)
},
horizontalAlignment = Alignment.End,
) {
items.forEach { item ->
FloatingActionButtonMenuItem(
onClick = {
item.onClick()
onExpandedChange(false)
},
icon = { Icon(item.icon, contentDescription = null) },
text = { Text(item.label) },
)
}
}
}
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,6 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("detekt:ALL")
package org.meshtastic.core.ui.component
@ -28,7 +29,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
@ -44,54 +44,46 @@ fun PreferenceFooter(
modifier: Modifier = Modifier,
) {
PreferenceFooter(
modifier = modifier,
enabled = enabled,
negativeText = stringResource(negativeText),
onNegativeClicked = onNegativeClicked,
positiveText = stringResource(positiveText),
onPositiveClicked = onPositiveClicked,
modifier = modifier,
)
}
@Composable
fun PreferenceFooter(
enabled: Boolean,
negativeText: String,
onNegativeClicked: () -> Unit,
positiveText: String,
onPositiveClicked: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
negativeText: String? = null,
onNegativeClicked: () -> Unit = {},
positiveText: String? = null,
onPositiveClicked: () -> Unit = {},
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ElevatedButton(
modifier = Modifier.height(48.dp).weight(1f),
colors = ButtonDefaults.filledTonalButtonColors(),
onClick = onNegativeClicked,
) {
Text(text = negativeText)
if (negativeText != null) {
ElevatedButton(
modifier = Modifier.height(48.dp).weight(1f),
colors = ButtonDefaults.filledTonalButtonColors(),
onClick = onNegativeClicked,
) {
Text(text = negativeText)
}
}
ElevatedButton(
modifier = Modifier.height(48.dp).weight(1f),
colors = ButtonDefaults.buttonColors(),
onClick = { if (enabled) onPositiveClicked() },
) {
Text(text = positiveText)
if (positiveText != null) {
ElevatedButton(
modifier = Modifier.height(48.dp).weight(1f),
colors = ButtonDefaults.buttonColors(),
onClick = { if (enabled) onPositiveClicked() },
) {
Text(text = positiveText)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PreferenceFooterPreview() {
PreferenceFooter(
enabled = true,
negativeText = "Cancel",
onNegativeClicked = {},
positiveText = "Save",
onPositiveClicked = {},
)
}

View file

@ -0,0 +1,131 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("detekt:ALL")
package org.meshtastic.core.ui.component
import android.content.ClipData
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.qr_code
import org.meshtastic.core.strings.url
import org.meshtastic.core.ui.util.findActivity
private const val QR_IMAGE_SIZE = 320
@Composable
fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val label = stringResource(Res.string.url)
DisposableEffect(Unit) {
val activity = context.findActivity()
val originalBrightness = activity?.window?.attributes?.screenBrightness ?: -1f
activity?.window?.let { window ->
val params = window.attributes
params.screenBrightness = 1f
window.attributes = params
}
onDispose {
activity?.window?.let { window ->
val params = window.attributes
params.screenBrightness = originalBrightness
window.attributes = params
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.padding(16.dp),
title = { Text(text = title) },
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (qrCode != null) {
Image(
painter = BitmapPainter(qrCode.asImageBitmap()),
contentDescription = stringResource(Res.string.qr_code),
modifier = Modifier.size(QR_IMAGE_SIZE.dp),
contentScale = ContentScale.Fit,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = uri.toString(),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Visible,
softWrap = true,
)
IconButton(
onClick = {
coroutineScope.launch {
clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText(label, uri.toString())))
}
},
) {
Icon(
imageVector = Icons.TwoTone.ContentCopy,
contentDescription = stringResource(Res.string.copy),
)
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.okay)) } },
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
package org.meshtastic.core.ui.timezone
@ -76,7 +75,7 @@ internal fun ZonedDateTime.timeZoneShortName(): String {
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
internal fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val transition = rule.createTransition(Year.now().value)
@ -84,7 +83,7 @@ internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionR
}
@Suppress("MagicNumber")
internal fun formatPosixOffset(offset: ZoneOffset): String {
fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,10 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.widget.Toast
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@ -33,3 +34,10 @@ suspend fun Context.showToast(stringResource: StringResource, vararg formatArgs:
suspend fun Context.showToast(text: String) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
/** Finds the [Activity] from a [Context]. */
fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}