feat(desktop): add QR code scanning for bunker login

Adds a QR scan button to the login card that decodes QR codes from
clipboard images or image files using ZXing. Useful for scanning
bunker:// URIs from Amber's QR display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-05 14:00:57 +02:00
parent 5661c6df27
commit fd1b435020
4 changed files with 130 additions and 8 deletions
+4
View File
@@ -46,6 +46,10 @@ dependencies {
// JSON
implementation(libs.jackson.module.kotlin)
// QR code decoding from images
implementation(libs.zxing)
implementation(libs.zxing.javase)
// Collections
implementation(libs.kotlinx.collections.immutable)
@@ -30,10 +30,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -56,6 +60,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
@@ -113,14 +119,62 @@ fun LoginCard(
Spacer(Modifier.height(16.dp))
KeyInputField(
value = keyInput,
onValueChange = {
keyInput = it
errorMessage = null
},
errorMessage = errorMessage,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
KeyInputField(
value = keyInput,
onValueChange = {
keyInput = it
errorMessage = null
},
errorMessage = errorMessage,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = {
// Try clipboard first, then file picker
val clipboardResult = decodeQrFromClipboard()
if (clipboardResult != null) {
keyInput = clipboardResult
errorMessage = null
} else {
val chooser =
JFileChooser().apply {
fileFilter =
FileNameExtensionFilter(
"Images",
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
)
dialogTitle = "Select QR code image"
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
val decoded = decodeQrFromFile(chooser.selectedFile)
if (decoded != null) {
keyInput = decoded
errorMessage = null
} else {
errorMessage = "No QR code found in image"
}
}
}
},
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = "Scan QR code",
tint = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.height(8.dp))
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.auth
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.client.j2se.BufferedImageLuminanceSource
import com.google.zxing.common.HybridBinarizer
import java.awt.Image
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
fun decodeQrFromFile(file: File): String? {
val image = ImageIO.read(file) ?: return null
return decodeQrFromImage(image)
}
fun decodeQrFromClipboard(): String? {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
if (!clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor)) return null
val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return null
return decodeQrFromImage(image.toBufferedImage())
}
private fun decodeQrFromImage(image: BufferedImage): String? =
try {
val source = BufferedImageLuminanceSource(image)
val bitmap = BinaryBitmap(HybridBinarizer(source))
MultiFormatReader().decode(bitmap).text
} catch (_: NotFoundException) {
null
}
private fun Image.toBufferedImage(): BufferedImage {
if (this is BufferedImage) return this
val buffered = BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_ARGB)
val g = buffered.createGraphics()
g.drawImage(this, 0, 0, null)
g.dispose()
return buffered
}