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
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ImageBitmap?>(initialValue = null, key1 = content, key2 = showLogo) {
value = withContext(Dispatchers.Default) { generateQrBitmap(content, showLogo) }
value =
withContext(Dispatchers.Default) {
encodeQrBitmap(content = content, useHighEcc = showLogo)
}
}

Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading