DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Don't rebuild the link: a test that a scanned UPI QR reaches the next app byte for byte

Disclosure: I'm the developer of the app described here, and this post is published from my account. It was drafted and published by an AI agent working from the project's source code.

Auto UPI QR reads a UPI QR from a picture and hands the link to the UPI app you already use. The whole job is to be a clean pipe: what the QR says goes in, the same thing comes out.

The tempting code is the opposite of a pipe. You have just parsed the link, you have the fields, so you build a fresh one:

// Looks tidy. Changes the link.
val rebuilt = Uri.Builder()
    .scheme("upi").authority("pay")
    .appendQueryParameter("pa", qr.payeeVpa)
    .appendQueryParameter("pn", qr.payeeName)
    .appendQueryParameter("am", qr.amount)
    .build()
Enter fullscreen mode Exit fullscreen mode

This post is about why that is wrong, and the small Robolectric test that keeps it from coming back.

What a round trip does to a UPI link

Take a merchant-style link. The values here are made up:

upi://pay?pa=shop@okaxis&pn=A+B%26C&mc=5411&tr=ORD%2F17&sign=MEUCIQDx+/ab==
Enter fullscreen mode Exit fullscreen mode

Decode and re-encode it through Uri and several things move:

  • @ becomes %40. appendQueryParameter encodes everything outside a small safe set, so pa=shop@okaxis becomes pa=shop%40okaxis.
  • + becomes a space, then %20. getQueryParameter treats + as a space when decoding. In a name that may be harmless. In a Base64 signature, + is data.
  • Duplicate keys collapse. getQueryParameter returns the first value, so a second pa= silently disappears.
  • Unknown parameters vanish. Rebuild from the fields you know about and you drop orgid, mode, sign and whatever else the issuer put there.
  • Order changes to whatever order your code happens to append.

The last two matter most. A signed QR's signature was computed over something. I don't know exactly which bytes each verifier checks, and neither does your app. The only safe assumption is that all of them count.

Keep the string, read from a copy

The fix is structural. The class holds the original text as its identity, and parsed fields are read-only views over it:

data class UpiQr(val raw: String) {
    private val uri: Uri = Uri.parse(raw)

    val payeeVpa: String? get() = param("pa")
    val payeeName: String? get() = param("pn")

    val viewUri: Uri get() = uri

    private fun param(name: String): String? =
        try { uri.getQueryParameter(name)?.trim() } catch (e: Exception) { null }
}
Enter fullscreen mode Exit fullscreen mode

Uri.parse doesn't normalise. It wraps the string and decodes lazily when you ask for a part, and toString() gives back what went in. So the decoded values can drive the screen ("you are paying this name") while the hand-off uses the untouched original:

fun payIntent(qr: UpiQr, app: UpiApp): Intent =
    Intent(Intent.ACTION_VIEW, qr.viewUri).setPackage(app.packageName)
Enter fullscreen mode Exit fullscreen mode

There is no setter and no builder anywhere on that path, so there is nothing for a later "small cleanup" to hook into. No extras are added either, because the link is the entire message.

The one exception, and why it exists

One normalisation does happen. Some stickers encode UPI://PAY in capitals, and Android matches intent-filter schemes case-sensitively, so that link would open nothing. So the prefix is matched case-insensitively and rewritten, and trailing whitespace is trimmed:

private val PREFIX = Regex("^\\s*upi://pay", RegexOption.IGNORE_CASE)

fun parse(text: String?): UpiQr? {
    if (text == null) return null
    val m = PREFIX.find(text) ?: return null
    val normalised = "upi://pay" + text.substring(m.range.last + 1).trimEnd()
    val qr = UpiQr(normalised)
    return if (qr.payeeVpa.isNullOrBlank()) null else qr
}
Enter fullscreen mode Exit fullscreen mode

Everything after the prefix is copied by substring. It never passes through a parser on the way to the output. That's the property worth protecting: the exception is written so it cannot reach the query string.

The test

The assertion is one line. The value is in the sample list, where each entry is a shape that round-tripping would damage:

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PayRouterIntentTest {
    private val app = PayRouter.UpiApp("com.example.upi", "Example", null)

    private val samples = listOf(
        // percent-escapes, '+' and '/' inside a signature
        "upi://pay?ver=01&mode=19&pa=shop@okaxis&pn=A%20SHOP&tr=X1&cu=INR&mc=5211&sign=MEUCIQDx+/ab==",
        // '+' and an encoded '&' in a name, an empty amount
        "upi://pay?pn=A+B%26C&pa=a.b-c_d@okhdfcbank&am=&cu=INR",
        // duplicate key, non-ASCII escapes, empty values
        "upi://pay?pa=x@ybl&pa=y@ybl&tn=caf%C3%A9%20%E2%82%B9&orgid=000000&mid=&msid=",
        // encoded slashes and a whole URL inside a value
        "upi://pay?pa=m@sbi&pn=Shop&am=2400.00&tr=ORD%2F2026%2F17&url=https%3A%2F%2Fexample.in%2Fo%3Fid%3D1",
    )

    @Test fun `pay intent carries the decoded string byte for byte`() {
        for (s in samples) {
            val i = PayRouter.payIntent(UpiQr.parse(s)!!, app)
            assertEquals(Intent.ACTION_VIEW, i.action)
            assertEquals(s, i.dataString)
            assertEquals(app.packageName, i.`package`)
            assertNull("no extras may be added", i.extras)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things make this a useful test rather than a tautology:

  1. It uses Robolectric, not a mocked Uri. The behaviour under test belongs to the framework classes. A mock would only confirm what the mock was told, so the test needs a real Uri and a real Intent.
  2. It asserts on dataString, the exact text the receiving app will see. Comparing Uri objects or decoded fields would pass even after the encoding changed.
  3. The samples are ugly on purpose. A tidy pa=a@b&pn=Name survives almost any implementation. Only odd order, empty values, duplicates and + catch a rewrite.

This is trimmed from the real test, with the sample values changed. The same check runs on the other routes into the hand-off: the system chooser (unwrap EXTRA_INTENT and compare), a link found inside a longer shared message, and the upper-case scheme case, which asserts that only the prefix and the trailing whitespace changed.

What the test doesn't promise

It proves the link leaves this app unchanged. It says nothing about what happens next. The receiving UPI app and the banks behind it can still refuse a payment for their own reasons, and no amount of string care changes that. Keeping the pipe clean only rules this app out as the cause, which is most of what you want when something fails after the PIN screen.

If you hand any signed or third-party link to another app

  • Keep the original string as the source of truth, and treat parsed fields as display values.
  • Never route the output through a builder, even "just to add one parameter".
  • Write any normalisation so it touches a known prefix and cannot reach the rest.
  • Test against the string the receiver will read, with samples chosen to break a round trip.

Auto UPI QR is on Google Play: https://play.google.com/store/apps/details?id=com.ashwar.autoupiqr. It reads UPI QR codes from images and hands the link to your own UPI app. It is not a bank and never sees your PIN. The code and test here are from the current source, and I don't claim which Play build contains them. Always check the payee name before entering your UPI PIN.

Top comments (0)