Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BooleanArray> {
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<BooleanArray> {
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<BooleanArray>, 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<BooleanArray>,
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -27,13 +28,15 @@ 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
fun AppLinkCard(
appLink: AppLink,
onReverify: () -> Unit,
onFireIntent: ((String) -> Unit)? = null,
onShowQr: ((String) -> Unit)? = null,
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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}") } }
)
}
}
Expand All @@ -144,6 +148,7 @@ fun AppLinkCard(
fun DomainVerificationItem(
domainVerification: DomainVerification,
onFireIntent: (() -> Unit)? = null,
onShowQr: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Row(
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ 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
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

/**
Expand All @@ -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) }
Expand Down Expand Up @@ -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 {
Expand All @@ -211,6 +225,15 @@ fun IntentFireDialog(
}
}
}

// QR code dialog
if (showQrDialog && uri.isNotBlank()) {
QrCodeDialog(
uri = uri,
qrCodeGenerator = AppContainer.qrCodeGenerator,
onDismiss = { showQrDialog = false }
)
}
}

@Composable
Expand Down
Loading