diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 792e02152..e9effcff2 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 39d56a42c..0b4eb4a34 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -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)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt new file mode 100644 index 000000000..6d7a6f457 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt @@ -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 +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95a41277f..4e75f8df0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -160,6 +160,7 @@ vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", versi zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }