diff --git a/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt new file mode 100644 index 0000000000..1b944757ea --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.ui.components.qrcode + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap +import kotlin.test.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +class QrCodeGeneratorAndroidTest { + + @Test + fun `encodeQrBitmap produces a square bitmap at the configured size`() { + val qr = encodeQrBitmap(QR_CONTENT, useHighEcc = false) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap without a logo returns a square code`() { + val qr = generateQrBitmap(content = QR_CONTENT, logo = null) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap composites a logo when the content fits the capacity`() { + val logo = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).asImageBitmap() + + val qr = generateQrBitmap(content = "short", logo = logo) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap skips the logo when the content exceeds the capacity`() { + val logo = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).asImageBitmap() + val tooLong = "1".repeat(MAX_QR_BYTES_WITH_LOGO + 1) + + val qr = generateQrBitmap(content = tooLong, logo = logo) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + private companion object { + const val QR_CONTENT = "https://google.com" + } +} diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt index d8aaa505eb..c89755a4c8 100644 --- a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt @@ -25,7 +25,7 @@ import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { +actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { val hints = mapOf( EncodeHintType.ERROR_CORRECTION to diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt index 23b076a7c8..528fd8deb8 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt @@ -48,29 +48,6 @@ import org.groundplatform.ui.theme.sizes @VisibleForTesting const val TEST_TAG_GROUND_QR_CODE = "TEST_TAG_GROUND_QR_CODE" -/** - * Maximum content size (in UTF-8 bytes) for which a center logo is displayed. - * - * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on - * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold - * to ensure the QR code remains reliably scannable even with a logo applied. - */ -private const val MAX_QR_BYTES_WITH_LOGO = 1000 - -/** - * The relative size of the center logo as a fraction of the QR code's total rendered size. - * - * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of - * high error correction (ECC level H), which can tolerate approximately 30% data loss. - */ -private const val LOGO_SIZE_FRACTION = 0.15f - -/** - * Displays a QR code generated from the given [content] string. - * - * The composable is intentionally generic, it accepts any string payload, making it reusable for - * GeoJSON, URLs, or any other data that fits within QR code capacity limits. - */ @Composable fun GroundQrCode( modifier: Modifier = Modifier, @@ -80,12 +57,15 @@ fun GroundQrCode( centerLogoPainter: Painter?, footer: String, ) { - val contentBytes = remember(content) { content.encodeToByteArray().size } - val showLogo = centerLogoPainter != null && contentBytes <= MAX_QR_BYTES_WITH_LOGO + val fitsLogo = remember(content) { fitsLogoCapacity(content) } + val showLogo = centerLogoPainter != null && fitsLogo val qrBitmap by produceState(initialValue = null, key1 = content, key2 = showLogo) { - value = withContext(Dispatchers.Default) { generateQrBitmap(content, showLogo) } + value = + withContext(Dispatchers.Default) { + encodeQrBitmap(content = content, useHighEcc = showLogo) + } } Column( diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt index 529a7e0d19..2c3d6b4584 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt @@ -15,8 +15,73 @@ */ package org.groundplatform.ui.components.qrcode +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize internal const val QR_SIZE_PX = 512 -expect fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap +/** + * Maximum content size (in UTF-8 bytes) for which a center logo is displayed. + * + * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on + * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold + * to ensure the QR code remains reliably scannable even with a logo applied. + */ +const val MAX_QR_BYTES_WITH_LOGO = 1000 + +/** + * Default relative size of the center logo as a fraction of the QR code's total size. + * + * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of + * high error correction (ECC level H), which can tolerate approximately 30% data loss. + */ +const val LOGO_SIZE_FRACTION = 0.15f +/** PDF document has more space to display the QR code, so we can use a larger fraction. */ +const val PDF_LOGO_SIZE_FRACTION = 0.25f + +/** + * Encodes [content] into a bare QR bitmap, using high error correction when [useHighEcc] is set. + */ +expect fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap + +/** + * Generates a QR bitmap for a given [content] with a [logo] in its center. The logo is only applied + * when one is supplied and the content size is below [MAX_QR_BYTES_WITH_LOGO] to keep the code + * scannable. + */ +fun generateQrBitmap( + content: String, + logo: ImageBitmap?, + logoSizeFraction: Float = LOGO_SIZE_FRACTION, +): ImageBitmap = + if (logo == null || !fitsLogoCapacity(content)) { + encodeQrBitmap(content, useHighEcc = false) + } else { + encodeQrBitmap(content, useHighEcc = true).withCenteredLogo(logo, logoSizeFraction) + } + +internal fun fitsLogoCapacity(content: String): Boolean = + content.encodeToByteArray().size <= MAX_QR_BYTES_WITH_LOGO + +/** Draws [logo] centered over the receiver, scaled to [fraction] of its size, into a new bitmap. */ +private fun ImageBitmap.withCenteredLogo(logo: ImageBitmap, fraction: Float): ImageBitmap { + val output = ImageBitmap(width, height) + val canvas = Canvas(output) + val paint = Paint().apply { filterQuality = FilterQuality.High } + canvas.drawImage(this, Offset.Zero, paint) + + val logoWidth = (width * fraction).toInt() + val logoHeight = (height * fraction).toInt() + canvas.drawImageRect( + image = logo, + dstOffset = IntOffset((width - logoWidth) / 2, (height - logoHeight) / 2), + dstSize = IntSize(logoWidth, logoHeight), + paint = paint, + ) + return output +} diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt new file mode 100644 index 0000000000..edd4e8891f --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.ui.components.qrcode + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QrCodeGeneratorTest { + + @Test + fun `fitsLogoCapacity is true for empty content`() { + assertTrue(fitsLogoCapacity("")) + } + + @Test + fun `fitsLogoCapacity is true for content below the byte limit`() { + assertTrue(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO - 1))) + } + + @Test + fun `fitsLogoCapacity is true for content exactly at the byte limit`() { + assertTrue(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO))) + } + + @Test + fun `fitsLogoCapacity is false for content above the byte limit`() { + assertFalse(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO + 1))) + } + + @Test + fun `fitsLogoCapacity counts UTF-8 bytes rather than characters`() { + // Each "€" encodes to 3 UTF-8 bytes, so this exceeds the limit despite the smaller char count. + val charCount = MAX_QR_BYTES_WITH_LOGO / 3 + 1 + val content = "€".repeat(charCount) + + assertTrue(content.length <= MAX_QR_BYTES_WITH_LOGO) + assertFalse(fitsLogoCapacity(content)) + } +} diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt index 80c2139c79..58ea1067de 100644 --- a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt @@ -56,7 +56,7 @@ private const val INPUT_CORRECTION_LEVEL_KEY = "inputCorrectionLevel" private val ciContext: CIContext = CIContext.contextWithOptions(null) -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { +actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { val ciImage = createQrCIImage(content, useHighEcc) val scaled = scaleToTargetSize(ciImage) return scaled.toComposeImageBitmap()