mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add KMP URI handling, import, and QR code generation support (#4856)
This commit is contained in:
parent
4eb711ce58
commit
1e55e554be
33 changed files with 379 additions and 209 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -29,3 +29,5 @@ val LocalBarcodeScannerProvider =
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalBarcodeScannerSupported = compositionLocalOf { false }
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ import androidx.compose.runtime.compositionLocalOf
|
|||
|
||||
val LocalNfcScannerProvider =
|
||||
compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }
|
||||
|
||||
val LocalNfcScannerSupported = compositionLocalOf { false }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue