feat: Add KMP URI handling, import, and QR code generation support (#4856)

This commit is contained in:
James Rich 2026-03-19 13:36:19 -05:00 committed by GitHub
parent 4eb711ce58
commit 1e55e554be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 379 additions and 209 deletions

View file

@ -1,70 +0,0 @@
/*
* 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.util
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
actual fun generateQrCode(text: String, size: Int): ImageBitmap? = try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix = multiFormatWriter.encode(text, BarcodeFormat.QR_CODE, size, size)
bitMatrix.toBitmap().asImageBitmap()
} catch (e: com.google.zxing.WriterException) {
co.touchlab.kermit.Logger.e(e) { "Failed to generate QR code" }
null
}
private fun BitMatrix.toBitmap(): Bitmap {
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)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}
@Composable
actual fun SetScreenBrightness(brightness: Float) {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context.findActivity()
val originalBrightness = activity?.window?.attributes?.screenBrightness ?: -1f
activity?.window?.let { window ->
val params = window.attributes
params.screenBrightness = brightness
window.attributes = params
}
onDispose {
activity?.window?.let { window ->
val params = window.attributes
params.screenBrightness = originalBrightness
window.attributes = params
}
}
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 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.util
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun SetScreenBrightness(brightness: Float) {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = (context as? Activity)?.window
val layoutParams = window?.attributes
val originalBrightness = layoutParams?.screenBrightness
layoutParams?.screenBrightness = brightness
window?.attributes = layoutParams
onDispose {
layoutParams?.screenBrightness = originalBrightness ?: -1f
window?.attributes = layoutParams
}
}
}

View file

@ -17,6 +17,8 @@
package org.meshtastic.core.ui.component
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
@ -24,6 +26,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
@ -32,9 +36,16 @@ class ImportFabUiTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun importFab_expands_onButtonClick() {
fun importFab_expands_onButtonClick_whenSupported() {
val testTag = "import_fab"
composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
composeTestRule.setContent {
CompositionLocalProvider(
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
) {
MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag)
}
}
// Expand the FAB
composeTestRule.onNodeWithTag(testTag).performClick()
@ -45,6 +56,27 @@ class ImportFabUiTest {
composeTestRule.onNodeWithTag("url_import").assertIsDisplayed()
}
@Test
fun importFab_hidesNfcAndQr_whenNotSupported() {
val testTag = "import_fab"
composeTestRule.setContent {
CompositionLocalProvider(
LocalBarcodeScannerSupported provides false,
LocalNfcScannerSupported provides false,
) {
MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag)
}
}
// Expand the FAB
composeTestRule.onNodeWithTag(testTag).performClick()
// Verify menu items are visible using their tags
composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist()
composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist()
composeTestRule.onNodeWithTag("url_import").assertIsDisplayed()
}
@Test
fun importFab_showsUrlDialog_whenUrlItemClicked() {
val testTag = "import_fab"

View file

@ -19,13 +19,12 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.getSharedContactUrl
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.share_contact
import org.meshtastic.core.ui.util.generateQrCode
import org.meshtastic.core.ui.util.rememberQrCodePainter
import org.meshtastic.proto.SharedContact
/**
@ -40,11 +39,11 @@ fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
val commonUri = contactToShare.getSharedContactUrl()
val uriString = commonUri.toString()
val qrCode = remember(uriString) { generateQrCode(uriString, 960) }
val qrPainter = rememberQrCodePainter(uriString, 960)
QrDialog(
title = stringResource(Res.string.share_contact),
uriString = uriString,
qrCode = qrCode,
qrPainter = qrPainter,
onDismiss = onDismiss,
)
}

View file

@ -56,7 +56,9 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.rememberOpenNfcSettings
import org.meshtastic.proto.SharedContact
@ -97,6 +99,8 @@ fun MeshtasticImportFAB(
val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } }
val nfcScanner = LocalNfcScannerProvider.current
val isNfcSupported = LocalNfcScannerSupported.current
val isBarcodeSupported = LocalBarcodeScannerSupported.current
if (isNfcScanning) {
nfcScanner(
@ -142,8 +146,10 @@ fun MeshtasticImportFAB(
)
}
val items =
mutableListOf(
val items = mutableListOf<MenuFABItem>()
if (isNfcSupported) {
items.add(
MenuFABItem(
label =
stringResource(
@ -153,6 +159,11 @@ fun MeshtasticImportFAB(
onClick = { isNfcScanning = true },
testTag = "nfc_import",
),
)
}
if (isBarcodeSupported) {
items.add(
MenuFABItem(
label =
stringResource(
@ -162,16 +173,20 @@ fun MeshtasticImportFAB(
onClick = { barcodeScanner.startScan() },
testTag = "qr_import",
),
MenuFABItem(
label =
stringResource(
if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url,
),
icon = Icons.Rounded.Link,
onClick = { showUrlDialog = true },
testTag = "url_import",
),
)
}
items.add(
MenuFABItem(
label =
stringResource(
if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url,
),
icon = Icons.Rounded.Link,
onClick = { showUrlDialog = true },
testTag = "url_import",
),
)
onShareChannels?.let {
items.add(

View file

@ -34,8 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
@ -53,7 +52,7 @@ import org.meshtastic.core.ui.util.createClipEntry
private const val QR_IMAGE_SIZE = 320
@Composable
fun QrDialog(title: String, uriString: String, qrCode: ImageBitmap?, onDismiss: () -> Unit) {
fun QrDialog(title: String, uriString: String, qrPainter: Painter?, onDismiss: () -> Unit) {
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val label = stringResource(Res.string.url)
@ -67,9 +66,9 @@ fun QrDialog(title: String, uriString: String, qrCode: ImageBitmap?, onDismiss:
onConfirm = onDismiss,
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (qrCode != null) {
if (qrPainter != null) {
Image(
painter = BitmapPainter(qrCode),
painter = qrPainter,
contentDescription = stringResource(Res.string.qr_code),
modifier = Modifier.size(QR_IMAGE_SIZE.dp),
contentScale = ContentScale.Fit,

View file

@ -29,3 +29,5 @@ val LocalBarcodeScannerProvider =
}
}
}
val LocalBarcodeScannerSupported = compositionLocalOf { false }

View file

@ -21,3 +21,5 @@ import androidx.compose.runtime.compositionLocalOf
val LocalNfcScannerProvider =
compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }
val LocalNfcScannerSupported = compositionLocalOf { false }

View file

@ -17,14 +17,65 @@
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
/** Generates a QR code for the given text. */
expect fun generateQrCode(text: String, size: Int): ImageBitmap?
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import qrcode.QRCode
/**
* A Composable that sets the screen brightness while it is in the composition.
* Generates a QR code painter directly using the Skia/Compose canvas API in pure Kotlin.
*
* @param brightness The brightness value (0.0 to 1.0).
* This implementation avoids any platform-specific bitmap APIs (like Android's [android.graphics.Bitmap] or Java AWT's
* BufferedImage), making it fully compatible with Android, Desktop, iOS, and Web.
*/
@Composable expect fun SetScreenBrightness(brightness: Float)
@Suppress("MagicNumber")
@Composable
fun rememberQrCodePainter(text: String, size: Int = 512): Painter {
val qrCode = androidx.compose.runtime.remember(text) { QRCode.ofSquares().build(text) }
val rawMatrix = androidx.compose.runtime.remember(qrCode) { qrCode.rawData }
val matrixSize = androidx.compose.runtime.remember(qrCode) { rawMatrix.size }
val quietZone = 4 // QR standard quiet zone is 4 modules on all sides
val totalModules = matrixSize + (quietZone * 2)
return androidx.compose.runtime.remember(qrCode, size) {
val bitmap = ImageBitmap(size, size)
val canvas = androidx.compose.ui.graphics.Canvas(bitmap)
val drawScope = CanvasDrawScope()
drawScope.draw(
density = Density(1f),
layoutDirection = LayoutDirection.Ltr,
canvas = canvas,
size = androidx.compose.ui.geometry.Size(size.toFloat(), size.toFloat()),
) {
val squareSize = size.toFloat() / totalModules
// Fill background white
drawRect(
color = Color.White,
topLeft = Offset.Zero,
size = androidx.compose.ui.geometry.Size(size.toFloat(), size.toFloat()),
)
// Draw dark squares
for (row in 0 until matrixSize) {
for (col in 0 until matrixSize) {
if (rawMatrix[row][col].dark) {
drawRect(
color = Color.Black,
topLeft = Offset((col + quietZone) * squareSize, (row + quietZone) * squareSize),
size = Size(squareSize, squareSize),
)
}
}
}
}
BitmapPainter(bitmap)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 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.util
import androidx.compose.runtime.Composable
/**
* A Composable that sets the screen brightness while it is in the composition.
*
* @param brightness The brightness value (0.0 to 1.0).
*/
@Composable expect fun SetScreenBrightness(brightness: Float)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 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
@ -17,10 +17,6 @@
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
/** JVM stub — QR code generation not yet implemented on Desktop. */
actual fun generateQrCode(text: String, size: Int): ImageBitmap? = null
/** JVM no-op — screen brightness control is not available on Desktop. */
@Composable