From 2dec424d8d258fd76be3ac61eeef317fc45f0f54 Mon Sep 17 00:00:00 2001 From: manjee Date: Sun, 15 Feb 2026 02:17:20 +0900 Subject: [PATCH] feat: feat: Magic QR Code Generator - instant QR code generation for deep links Closes #24 --- composeApp/build.gradle.kts | 1 + .../com/manjee/linkops/di/AppContainer.kt | 6 + .../infrastructure/qr/QrCodeGenerator.kt | 168 +++++++++++++ .../linkops/ui/component/AppLinkItem.kt | 24 +- .../linkops/ui/component/IntentFireDialog.kt | 23 ++ .../linkops/ui/component/QrCodeDialog.kt | 232 ++++++++++++++++++ .../linkops/ui/component/QrCodeImage.kt | 53 ++++ .../linkops/ui/screen/main/MainScreen.kt | 57 ++++- .../screen/manifest/ManifestAnalyzerScreen.kt | 37 +++ .../infrastructure/qr/QrCodeGeneratorTest.kt | 220 +++++++++++++++++ gradle/libs.versions.toml | 2 + 11 files changed, 815 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGenerator.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeDialog.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeImage.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGeneratorTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8b3c088..38e8c68 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.zxing.core) } jvmTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt index 7b32820..66e85b4 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -36,6 +36,7 @@ import com.manjee.linkops.domain.usecase.manifest.TestDeepLinkUseCase import com.manjee.linkops.infrastructure.adb.AdbBinaryManager import com.manjee.linkops.infrastructure.adb.AdbShellExecutor import com.manjee.linkops.infrastructure.network.AssetLinksClient +import com.manjee.linkops.infrastructure.qr.QrCodeGenerator /** * Simple dependency injection container @@ -52,6 +53,11 @@ object AppContainer { AdbShellExecutor(adbBinaryManager) } + // Infrastructure - QR + val qrCodeGenerator: QrCodeGenerator by lazy { + QrCodeGenerator() + } + // Infrastructure - Network private val assetLinksClient: AssetLinksClient by lazy { AssetLinksClient() diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGenerator.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGenerator.kt new file mode 100644 index 0000000..f55ebc0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGenerator.kt @@ -0,0 +1,168 @@ +package com.manjee.linkops.infrastructure.qr + +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.WriterException +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import java.awt.image.BufferedImage + +/** + * Generates QR codes from URI strings with automatic error correction level optimization. + * + * Error correction levels are selected based on URI length: + * - URI <= 300 chars: Level H (30% recovery, best scan reliability) + * - URI 301~500 chars: Level M (15% recovery, medium density) + * - URI > 500 chars: Level L (7% recovery, lowest density) + */ +class QrCodeGenerator { + + /** + * Generates a QR code matrix for Canvas rendering + * + * @param content URI string to encode + * @return Boolean matrix where true = dark module, false = light module + */ + fun generateMatrix(content: String): Array { + val ecLevel = selectErrorCorrectionLevel(content) + return encodeToMatrix(content, ecLevel) + } + + /** + * Generates a QR code as a BufferedImage for PNG export + * + * @param content URI string to encode + * @param size Image dimension in pixels (width = height) + * @return BufferedImage with the QR code + */ + fun generate(content: String, size: Int = DEFAULT_IMAGE_SIZE): BufferedImage { + val matrix = generateMatrix(content) + return renderToImage(matrix, size) + } + + /** + * Generates a QR code with metadata about the generation process + * + * @param content URI string to encode + * @return QrGenerationResult containing matrix, error correction level, and optional warning + */ + fun generateWithInfo(content: String): QrGenerationResult { + val ecLevel = selectErrorCorrectionLevel(content) + val warning = buildWarning(content, ecLevel) + val matrix = encodeToMatrix(content, ecLevel) + return QrGenerationResult( + matrix = matrix, + errorCorrectionLevel = ecLevel, + warning = warning + ) + } + + private fun selectErrorCorrectionLevel(content: String): ErrorCorrectionLevel { + return when { + content.length <= SHORT_URI_THRESHOLD -> ErrorCorrectionLevel.H + content.length <= MEDIUM_URI_THRESHOLD -> ErrorCorrectionLevel.M + else -> ErrorCorrectionLevel.L + } + } + + private fun buildWarning(content: String, ecLevel: ErrorCorrectionLevel): String? { + if (content.length > MEDIUM_URI_THRESHOLD) { + return "URI is very long (${content.length} chars). QR code uses low error correction and may be difficult to scan." + } + return null + } + + private fun encodeToMatrix(content: String, ecLevel: ErrorCorrectionLevel): Array { + val hints = mapOf( + EncodeHintType.ERROR_CORRECTION to ecLevel, + EncodeHintType.MARGIN to QR_MARGIN + ) + val writer = QRCodeWriter() + val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, 0, 0, hints) + val width = bitMatrix.width + val height = bitMatrix.height + return Array(height) { y -> + BooleanArray(width) { x -> + bitMatrix.get(x, y) + } + } + } + + private fun renderToImage(matrix: Array, size: Int): BufferedImage { + val matrixHeight = matrix.size + val matrixWidth = if (matrixHeight > 0) matrix[0].size else 0 + + val image = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB) + val graphics = image.createGraphics() + + // Fill white background + graphics.color = java.awt.Color.WHITE + graphics.fillRect(0, 0, size, size) + + // Draw dark modules + graphics.color = java.awt.Color.BLACK + val cellWidth = size.toDouble() / matrixWidth + val cellHeight = size.toDouble() / matrixHeight + + for (y in matrix.indices) { + for (x in matrix[y].indices) { + if (matrix[y][x]) { + val px = (x * cellWidth).toInt() + val py = (y * cellHeight).toInt() + val pw = ((x + 1) * cellWidth).toInt() - px + val ph = ((y + 1) * cellHeight).toInt() - py + graphics.fillRect(px, py, pw, ph) + } + } + } + + graphics.dispose() + return image + } + + companion object { + const val SHORT_URI_THRESHOLD = 300 + const val MEDIUM_URI_THRESHOLD = 500 + const val DEFAULT_IMAGE_SIZE = 512 + private const val QR_MARGIN = 1 + } +} + +/** + * Result of QR code generation with metadata + * + * @param matrix Boolean matrix where true = dark module + * @param errorCorrectionLevel The EC level used for this generation + * @param warning Optional warning message (null = OK) + */ +data class QrGenerationResult( + val matrix: Array, + val errorCorrectionLevel: ErrorCorrectionLevel, + val warning: String? +) { + /** + * Human-readable description of the error correction level + */ + val ecLevelDescription: String + get() = when (errorCorrectionLevel) { + ErrorCorrectionLevel.H -> "H (30% recovery)" + ErrorCorrectionLevel.M -> "M (15% recovery)" + ErrorCorrectionLevel.Q -> "Q (25% recovery)" + ErrorCorrectionLevel.L -> "L (7% recovery)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is QrGenerationResult) return false + return matrix.contentDeepEquals(other.matrix) && + errorCorrectionLevel == other.errorCorrectionLevel && + warning == other.warning + } + + override fun hashCode(): Int { + var result = matrix.contentDeepHashCode() + result = 31 * result + errorCorrectionLevel.hashCode() + result = 31 * result + (warning?.hashCode() ?: 0) + return result + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/AppLinkItem.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/AppLinkItem.kt index 9debd3a..a6812f1 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/AppLinkItem.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/AppLinkItem.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* @@ -27,6 +28,7 @@ import com.manjee.linkops.ui.theme.LinkOpsColors * @param appLink AppLink data to display * @param onReverify Callback when re-verify button is clicked * @param onFireIntent Callback when fire intent is requested for a domain + * @param onShowQr Callback when QR code is requested for a domain URI * @param modifier Modifier for the card */ @Composable @@ -34,6 +36,7 @@ fun AppLinkCard( appLink: AppLink, onReverify: () -> Unit, onFireIntent: ((String) -> Unit)? = null, + onShowQr: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) { var isExpanded by remember { mutableStateOf(false) } @@ -128,7 +131,8 @@ fun AppLinkCard( appLink.domains.forEach { domain -> DomainVerificationItem( domainVerification = domain, - onFireIntent = onFireIntent?.let { { it("https://${domain.domain}") } } + onFireIntent = onFireIntent?.let { { it("https://${domain.domain}") } }, + onShowQr = onShowQr?.let { { it("https://${domain.domain}") } } ) } } @@ -144,6 +148,7 @@ fun AppLinkCard( fun DomainVerificationItem( domainVerification: DomainVerification, onFireIntent: (() -> Unit)? = null, + onShowQr: (() -> Unit)? = null, modifier: Modifier = Modifier ) { Row( @@ -195,9 +200,24 @@ fun DomainVerificationItem( // Verification badge VerificationBadge(state = domainVerification.verificationState) + // QR code button (optional) + if (onShowQr != null) { + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = onShowQr, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.QrCode2, + contentDescription = "Generate QR Code", + modifier = Modifier.size(16.dp) + ) + } + } + // Fire intent button (optional) if (onFireIntent != null) { - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(4.dp)) TextButton( onClick = onFireIntent, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/IntentFireDialog.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/IntentFireDialog.kt index 8576cdf..9d099a4 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/IntentFireDialog.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/IntentFireDialog.kt @@ -3,6 +3,8 @@ package com.manjee.linkops.ui.component import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -10,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.IntentConfig /** @@ -28,6 +31,7 @@ fun IntentFireDialog( var uri by remember { mutableStateOf(initialUri) } var action by remember { mutableStateOf(IntentConfig.ACTION_VIEW) } var packageName by remember { mutableStateOf("") } + var showQrDialog by remember { mutableStateOf(false) } // Intent flags var newTask by remember { mutableStateOf(false) } @@ -185,6 +189,16 @@ fun IntentFireDialog( Text("Cancel") } Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { showQrDialog = true }, + enabled = uri.isNotBlank() + ) { + Icon( + Icons.Default.QrCode2, + contentDescription = "Generate QR Code" + ) + } + Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { val flags = buildSet { @@ -211,6 +225,15 @@ fun IntentFireDialog( } } } + + // QR code dialog + if (showQrDialog && uri.isNotBlank()) { + QrCodeDialog( + uri = uri, + qrCodeGenerator = AppContainer.qrCodeGenerator, + onDismiss = { showQrDialog = false } + ) + } } @Composable diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeDialog.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeDialog.kt new file mode 100644 index 0000000..9a27d46 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeDialog.kt @@ -0,0 +1,232 @@ +package com.manjee.linkops.ui.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.manjee.linkops.infrastructure.qr.QrCodeGenerator +import com.manjee.linkops.ui.theme.LinkOpsColors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.awt.FileDialog +import java.awt.Frame +import javax.imageio.ImageIO + +/** + * Dialog that displays a QR code for a given URI with copy and save options + * + * @param uri The URI to encode as QR code + * @param title Dialog title + * @param qrCodeGenerator Generator instance for creating QR codes + * @param onDismiss Callback when dialog is dismissed + */ +@Composable +fun QrCodeDialog( + uri: String, + title: String = "QR Code", + qrCodeGenerator: QrCodeGenerator, + onDismiss: () -> Unit +) { + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + val qrResult = remember(uri) { + qrCodeGenerator.generateWithInfo(uri) + } + + val isCustomScheme = remember(uri) { + val scheme = uri.substringBefore("://", "") + scheme.isNotEmpty() && scheme != "http" && scheme != "https" + } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .width(420.dp) + .heightIn(max = 640.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Title + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // QR Code + QrCodeImage( + content = uri, + qrCodeGenerator = qrCodeGenerator, + modifier = Modifier.size(280.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Warning for long URIs + qrResult.warning?.let { warning -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = LinkOpsColors.Warning, + modifier = Modifier.size(16.dp) + ) + Text( + text = warning, + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Warning + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Custom scheme warning + if (isCustomScheme) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = LinkOpsColors.Info, + modifier = Modifier.size(16.dp) + ) + Text( + text = "Custom scheme URI may not be recognized by default camera apps. Use a QR scanner app instead.", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Info + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // URI display + Text( + text = uri, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // EC Level info + Text( + text = "EC Level: ${qrResult.ecLevelDescription}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + clipboardManager.setText(AnnotatedString(uri)) + }, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Copy URI") + } + + OutlinedButton( + onClick = { + scope.launch(Dispatchers.IO) { + saveQrPng(uri, qrCodeGenerator) + } + }, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Save PNG") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Close button + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End) + ) { + Text("Close") + } + } + } + } +} + +/** + * Saves QR code as PNG using a file dialog + */ +private fun saveQrPng(uri: String, qrCodeGenerator: QrCodeGenerator) { + val image = qrCodeGenerator.generate(uri) + + val sanitized = uri + .replace(Regex("[^a-zA-Z0-9._-]"), "_") + .take(50) + + val dialog = FileDialog(null as Frame?, "Save QR Code", FileDialog.SAVE).apply { + file = "qr_$sanitized.png" + isVisible = true + } + + val directory = dialog.directory + val filename = dialog.file + + if (directory != null && filename != null) { + val file = java.io.File(directory, filename) + ImageIO.write(image, "PNG", file) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeImage.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeImage.kt new file mode 100644 index 0000000..919d62d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/QrCodeImage.kt @@ -0,0 +1,53 @@ +package com.manjee.linkops.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import com.manjee.linkops.infrastructure.qr.QrCodeGenerator + +/** + * Composable that renders a QR code using Canvas + * + * @param content URI string to encode as QR code + * @param qrCodeGenerator Generator instance for creating the QR matrix + * @param modifier Modifier for the Canvas + */ +@Composable +fun QrCodeImage( + content: String, + qrCodeGenerator: QrCodeGenerator, + modifier: Modifier = Modifier +) { + val matrix = remember(content) { + qrCodeGenerator.generateMatrix(content) + } + + Canvas(modifier = modifier) { + val matrixHeight = matrix.size + val matrixWidth = if (matrixHeight > 0) matrix[0].size else 0 + if (matrixWidth == 0 || matrixHeight == 0) return@Canvas + + val cellWidth = size.width / matrixWidth + val cellHeight = size.height / matrixHeight + + // Draw white background + drawRect(Color.White, Offset.Zero, size) + + // Draw dark modules + for (y in matrix.indices) { + for (x in matrix[y].indices) { + if (matrix[y][x]) { + drawRect( + color = Color.Black, + topLeft = Offset(x * cellWidth, y * cellHeight), + size = Size(cellWidth, cellHeight) + ) + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainScreen.kt index c012c42..92f9536 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* @@ -28,6 +29,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import com.manjee.linkops.LocalSearchFocusTrigger +import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.Favorite import com.manjee.linkops.domain.model.IntentConfig import com.manjee.linkops.ui.component.* @@ -50,6 +52,8 @@ fun MainScreen( var showIntentDialog by remember { mutableStateOf(false) } var intentUri by remember { mutableStateOf("") } + var showQrDialog by remember { mutableStateOf(false) } + var qrDialogUri by remember { mutableStateOf("") } val isLoading = uiState.isLoadingDevices || uiState.isLoadingAppLinks || uiState.isFiringIntent @@ -81,6 +85,10 @@ fun MainScreen( viewModel.fireFavorite(uri) }, onRemoveFavorite = { id -> viewModel.removeFavorite(id) }, + onShowQr = { uri -> + qrDialogUri = uri + showQrDialog = true + }, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -119,6 +127,15 @@ fun MainScreen( initialUri = intentUri ) } + + // QR code dialog + if (showQrDialog && qrDialogUri.isNotBlank()) { + QrCodeDialog( + uri = qrDialogUri, + qrCodeGenerator = AppContainer.qrCodeGenerator, + onDismiss = { showQrDialog = false } + ) + } } } @@ -139,6 +156,7 @@ private fun ControlPanel( onOpenIntentDialog: () -> Unit, onFireFavorite: (String) -> Unit, onRemoveFavorite: (String) -> Unit, + onShowQr: (String) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -190,7 +208,8 @@ private fun ControlPanel( selectedDevice = uiState.selectedDevice, isFiring = uiState.isFiringIntent, onFireIntent = onFireIntent, - onOpenAdvanced = onOpenIntentDialog + onOpenAdvanced = onOpenIntentDialog, + onShowQr = onShowQr ) } @@ -203,7 +222,8 @@ private fun ControlPanel( favorites = uiState.favorites, selectedDevice = uiState.selectedDevice, onFireFavorite = onFireFavorite, - onRemoveFavorite = onRemoveFavorite + onRemoveFavorite = onRemoveFavorite, + onShowQr = onShowQr ) } } @@ -463,7 +483,8 @@ private fun FireIntentSection( selectedDevice: com.manjee.linkops.domain.model.Device?, isFiring: Boolean, onFireIntent: () -> Unit, - onOpenAdvanced: () -> Unit + onOpenAdvanced: () -> Unit, + onShowQr: (String) -> Unit ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -509,6 +530,16 @@ private fun FireIntentSection( ) { Text("Advanced...") } + + IconButton( + onClick = { onShowQr(intentUri) }, + enabled = intentUri.isNotBlank() + ) { + Icon( + Icons.Default.QrCode2, + contentDescription = "Generate QR Code" + ) + } } } } @@ -521,7 +552,8 @@ private fun FavoritesSection( favorites: List, selectedDevice: com.manjee.linkops.domain.model.Device?, onFireFavorite: (String) -> Unit, - onRemoveFavorite: (String) -> Unit + onRemoveFavorite: (String) -> Unit, + onShowQr: (String) -> Unit ) { var isExpanded by remember { mutableStateOf(true) } @@ -567,7 +599,8 @@ private fun FavoritesSection( favorite = favorite, hasDevice = selectedDevice != null, onFire = { onFireFavorite(favorite.uri) }, - onRemove = { onRemoveFavorite(favorite.id) } + onRemove = { onRemoveFavorite(favorite.id) }, + onShowQr = { onShowQr(favorite.uri) } ) } } @@ -583,7 +616,8 @@ private fun FavoriteItem( favorite: Favorite, hasDevice: Boolean, onFire: () -> Unit, - onRemove: () -> Unit + onRemove: () -> Unit, + onShowQr: () -> Unit ) { Card( colors = CardDefaults.cardColors( @@ -630,6 +664,17 @@ private fun FavoriteItem( ) } + IconButton( + onClick = onShowQr, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.QrCode2, + contentDescription = "Generate QR Code", + modifier = Modifier.size(16.dp) + ) + } + IconButton( onClick = onRemove, modifier = Modifier.size(28.dp) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt index 5b390c1..906a67d 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.PictureAsPdf @@ -29,6 +30,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.manjee.linkops.LocalSearchFocusTrigger +import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.* import com.manjee.linkops.domain.model.IntentConfig import com.manjee.linkops.domain.repository.PackageFilter @@ -56,6 +58,10 @@ fun ManifestAnalyzerScreen( var showIntentDialog by remember { mutableStateOf(false) } var intentDialogUri by remember { mutableStateOf("") } + // QR code dialog state + var showQrDialog by remember { mutableStateOf(false) } + var qrDialogUri by remember { mutableStateOf("") } + // Auto-select first device if not selected LaunchedEffect(devices) { if (uiState.selectedDevice == null && devices.isNotEmpty()) { @@ -152,6 +158,10 @@ fun ManifestAnalyzerScreen( intentDialogUri = uri showIntentDialog = true }, + onShowQr = { uri -> + qrDialogUri = uri + showQrDialog = true + }, onToggleFavorite = { uri, name -> viewModel.toggleFavorite(uri, name) }, onExportMarkdown = { uiState.analysisResult?.let { result -> @@ -189,6 +199,15 @@ fun ManifestAnalyzerScreen( initialUri = intentDialogUri ) } + + // QR code dialog + if (showQrDialog && qrDialogUri.isNotBlank()) { + QrCodeDialog( + uri = qrDialogUri, + qrCodeGenerator = AppContainer.qrCodeGenerator, + onDismiss = { showQrDialog = false } + ) + } } /** @@ -418,6 +437,7 @@ private fun AnalysisResultsPanel( onClear: () -> Unit, onTestDeepLink: (String) -> Unit, onSendDeepLink: (String) -> Unit, + onShowQr: (String) -> Unit, onToggleFavorite: (uri: String, name: String) -> Unit, onExportMarkdown: () -> Unit, onExportPdf: () -> Unit @@ -501,6 +521,7 @@ private fun AnalysisResultsPanel( favoriteUris = favoriteUris, onTestDeepLink = onTestDeepLink, onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, onToggleFavorite = onToggleFavorite ) } @@ -517,6 +538,7 @@ private fun AnalysisResultsPanel( favoriteUris = favoriteUris, onTestDeepLink = onTestDeepLink, onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, onToggleFavorite = onToggleFavorite ) } @@ -536,6 +558,7 @@ private fun AnalysisResultsPanel( favoriteUris = favoriteUris, onTestDeepLink = onTestDeepLink, onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, onToggleFavorite = onToggleFavorite ) } @@ -821,6 +844,7 @@ private fun DeepLinksCard( favoriteUris: Set, onTestDeepLink: (String) -> Unit, onSendDeepLink: (String) -> Unit, + onShowQr: (String) -> Unit, onToggleFavorite: (uri: String, name: String) -> Unit ) { Card( @@ -855,6 +879,7 @@ private fun DeepLinksCard( isFavorite = favoriteUris.contains(link.sampleUri), onTestDeepLink = onTestDeepLink, onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, onToggleFavorite = { onToggleFavorite(link.sampleUri, link.patternDescription) } ) } @@ -872,6 +897,7 @@ private fun DeepLinkItem( isFavorite: Boolean, onTestDeepLink: (String) -> Unit, onSendDeepLink: (String) -> Unit, + onShowQr: (String) -> Unit, onToggleFavorite: () -> Unit ) { // Find verification status for this link's domain @@ -966,6 +992,17 @@ private fun DeepLinkItem( ) } + IconButton( + onClick = { onShowQr(link.sampleUri) }, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.QrCode2, + contentDescription = "Generate QR Code", + modifier = Modifier.size(16.dp) + ) + } + FilledTonalButton( onClick = { onTestDeepLink(link.sampleUri) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGeneratorTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGeneratorTest.kt new file mode 100644 index 0000000..9be1cc7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/infrastructure/qr/QrCodeGeneratorTest.kt @@ -0,0 +1,220 @@ +package com.manjee.linkops.infrastructure.qr + +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QrCodeGeneratorTest { + + private val generator = QrCodeGenerator() + + // --- generateMatrix tests --- + + @Test + fun `generateMatrix should return non-empty matrix for simple URI`() { + val matrix = generator.generateMatrix("https://example.com") + assertTrue(matrix.isNotEmpty(), "Matrix should not be empty") + assertTrue(matrix[0].isNotEmpty(), "Matrix rows should not be empty") + } + + @Test + fun `generateMatrix should return square matrix`() { + val matrix = generator.generateMatrix("https://example.com/path") + assertEquals(matrix.size, matrix[0].size, "Matrix should be square") + } + + @Test + fun `generateMatrix should contain both dark and light modules`() { + val matrix = generator.generateMatrix("https://example.com") + val hasDark = matrix.any { row -> row.any { it } } + val hasLight = matrix.any { row -> row.any { !it } } + assertTrue(hasDark, "Matrix should contain dark modules") + assertTrue(hasLight, "Matrix should contain light modules") + } + + @Test + fun `generateMatrix should handle custom scheme URI`() { + val matrix = generator.generateMatrix("myapp://product/123") + assertTrue(matrix.isNotEmpty(), "Matrix should not be empty for custom scheme") + } + + @Test + fun `generateMatrix should handle URI with special characters`() { + val matrix = generator.generateMatrix("https://example.com/path?q=hello%20world&lang=en") + assertTrue(matrix.isNotEmpty(), "Matrix should handle special characters") + } + + @Test + fun `generateMatrix should handle very short URI`() { + val matrix = generator.generateMatrix("a") + assertTrue(matrix.isNotEmpty(), "Matrix should handle very short input") + } + + // --- generate (BufferedImage) tests --- + + @Test + fun `generate should return image with default size`() { + val image = generator.generate("https://example.com") + assertEquals(QrCodeGenerator.DEFAULT_IMAGE_SIZE, image.width, "Image width should match default") + assertEquals(QrCodeGenerator.DEFAULT_IMAGE_SIZE, image.height, "Image height should match default") + } + + @Test + fun `generate should return image with custom size`() { + val image = generator.generate("https://example.com", size = 256) + assertEquals(256, image.width, "Image width should match custom size") + assertEquals(256, image.height, "Image height should match custom size") + } + + @Test + fun `generate should produce image with black and white pixels`() { + val image = generator.generate("https://example.com") + val black = 0xFF000000.toInt() + val white = 0xFFFFFFFF.toInt() + + var hasBlack = false + var hasWhite = false + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val rgb = image.getRGB(x, y) + if (rgb == black) hasBlack = true + if (rgb == white) hasWhite = true + if (hasBlack && hasWhite) break + } + if (hasBlack && hasWhite) break + } + assertTrue(hasBlack, "Image should contain black pixels") + assertTrue(hasWhite, "Image should contain white pixels") + } + + // --- generateWithInfo tests --- + + @Test + fun `generateWithInfo should use EC level H for short URI`() { + val result = generator.generateWithInfo("https://example.com") + assertEquals(ErrorCorrectionLevel.H, result.errorCorrectionLevel, "Short URI should use EC level H") + assertNull(result.warning, "Short URI should not have warning") + } + + @Test + fun `generateWithInfo should use EC level H for URI at 300 chars threshold`() { + val uri = "https://example.com/" + "a".repeat(280) + assertEquals(300, uri.length, "URI should be exactly 300 chars") + val result = generator.generateWithInfo(uri) + assertEquals(ErrorCorrectionLevel.H, result.errorCorrectionLevel, "URI at 300 chars should use EC level H") + assertNull(result.warning, "URI at 300 chars should not have warning") + } + + @Test + fun `generateWithInfo should use EC level M for URI above 300 chars`() { + val uri = "https://example.com/" + "a".repeat(281) + assertEquals(301, uri.length, "URI should be 301 chars") + val result = generator.generateWithInfo(uri) + assertEquals(ErrorCorrectionLevel.M, result.errorCorrectionLevel, "URI at 301 chars should use EC level M") + assertNull(result.warning, "URI at 301 chars should not have warning") + } + + @Test + fun `generateWithInfo should use EC level M for URI at 500 chars threshold`() { + val uri = "https://example.com/" + "a".repeat(480) + assertEquals(500, uri.length, "URI should be exactly 500 chars") + val result = generator.generateWithInfo(uri) + assertEquals(ErrorCorrectionLevel.M, result.errorCorrectionLevel, "URI at 500 chars should use EC level M") + assertNull(result.warning, "URI at 500 chars should not have warning") + } + + @Test + fun `generateWithInfo should use EC level L for URI above 500 chars`() { + val uri = "https://example.com/" + "a".repeat(481) + assertEquals(501, uri.length, "URI should be 501 chars") + val result = generator.generateWithInfo(uri) + assertEquals(ErrorCorrectionLevel.L, result.errorCorrectionLevel, "URI above 500 chars should use EC level L") + assertNotNull(result.warning, "Long URI should have warning") + assertTrue(result.warning!!.contains("501"), "Warning should include URI length") + } + + @Test + fun `generateWithInfo should include warning for very long URI`() { + val uri = "https://example.com/" + "a".repeat(1000) + val result = generator.generateWithInfo(uri) + assertEquals(ErrorCorrectionLevel.L, result.errorCorrectionLevel) + assertNotNull(result.warning, "Very long URI should have warning") + assertTrue(result.warning!!.contains("difficult to scan"), "Warning should mention scan difficulty") + } + + // --- ecLevelDescription tests --- + + @Test + fun `ecLevelDescription should return correct string for each level`() { + val shortResult = generator.generateWithInfo("short") + assertEquals("H (30% recovery)", shortResult.ecLevelDescription) + + val mediumUri = "https://example.com/" + "a".repeat(281) + val medResult = generator.generateWithInfo(mediumUri) + assertEquals("M (15% recovery)", medResult.ecLevelDescription) + + val longUri = "https://example.com/" + "a".repeat(481) + val longResult = generator.generateWithInfo(longUri) + assertEquals("L (7% recovery)", longResult.ecLevelDescription) + } + + // --- Consistency tests --- + + @Test + fun `generateMatrix should produce same result for same input`() { + val content = "https://example.com/consistent" + val matrix1 = generator.generateMatrix(content) + val matrix2 = generator.generateMatrix(content) + + assertEquals(matrix1.size, matrix2.size, "Matrices should have same height") + for (y in matrix1.indices) { + assertTrue( + matrix1[y].contentEquals(matrix2[y]), + "Matrix row $y should be identical" + ) + } + } + + @Test + fun `different URIs should produce different matrices`() { + val matrix1 = generator.generateMatrix("https://example.com/a") + val matrix2 = generator.generateMatrix("https://example.com/b") + + var hasDifference = false + val minRows = minOf(matrix1.size, matrix2.size) + for (y in 0 until minRows) { + val minCols = minOf(matrix1[y].size, matrix2[y].size) + for (x in 0 until minCols) { + if (matrix1[y][x] != matrix2[y][x]) { + hasDifference = true + break + } + } + if (hasDifference) break + } + assertTrue(hasDifference, "Different URIs should produce different matrices") + } + + // --- Edge case: Unicode and special characters --- + + @Test + fun `generateMatrix should handle URI with unicode characters`() { + val matrix = generator.generateMatrix("https://example.com/path?name=\u00e9\u00e8\u00ea") + assertTrue(matrix.isNotEmpty(), "Matrix should handle unicode characters") + } + + @Test + fun `generateMatrix should handle URI with fragments`() { + val matrix = generator.generateMatrix("https://example.com/page#section-1") + assertTrue(matrix.isNotEmpty(), "Matrix should handle fragment URIs") + } + + @Test + fun `generateMatrix should handle deep nested path`() { + val matrix = generator.generateMatrix("https://example.com/a/b/c/d/e/f/g/h/i/j") + assertTrue(matrix.isNotEmpty(), "Matrix should handle deep nested paths") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7aeb930..c77754b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.7.3" ktor = "3.0.3" +zxing = "3.5.3" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -21,6 +22,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } [plugins] composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }