Iden Tumuhirwe
All writing

June 6, 2026 · 7 min read

A fake phone for a protocol three carriers can't agree on

Kotlin · Android · Jetpack Compose

If you've ever dialed *185# to check a mobile money balance, you've used USSD. It's ancient, it's session-based, it's not really a protocol so much as a loose agreement that a phone will send a string and a gateway will send one back. And every carrier I've had to integrate against, Africa's Talking, MTN, Airtel, implements that agreement slightly differently. Nothing about testing against them is fast: you dial a real shortcode, on a real SIM, against a real gateway, and wait to see what comes back. Doing that in a loop while you're debugging your own service is miserable.

So over about ten days in late May and early June I built USSDSandbox, a native Android app that pretends to be the phone, the SIM, and the gateway all at once, so I could test against my own service without touching a real network.

The dialects are the whole problem

Africa's Talking sends everything as application/x-www-form-urlencoded, and the entire conversation so far gets re-sent on every request as one asterisk-joined string in a text field. Type 1, then 2, then 400, and step three's request body says text=1*2*400. The response tells you whether the session is still alive with a literal prefix on the body: CON to keep listening, END to close.

MTN's dialect looks nothing like that. It's JSON, it sends only the latest keystroke rather than the whole history, and session state lives in an action field: REQUEST or CONTINUE means keep going, RESPONSE or RELEASE means it's over.

Getting the CON/END parsing right took longer than it should have, because the naive version is "does the body start with CON," and the naive version is wrong. A response of CONTINENTAL BANKING SERVICES is not a continuation prefix, it's a bank name that happens to start with the same four letters. The fix is to require the prefix be followed by whitespace or end-of-string before you treat it as the token:

private fun stripAnchoredPrefix(body: String, keyword: String): String? {
    if (body.length < keyword.length) return null
    if (!body.regionMatches(0, keyword, 0, keyword.length, ignoreCase = true)) return null
    val rest = body.substring(keyword.length)
    if (rest.isNotEmpty() && !rest[0].isWhitespace()) return null
    return rest.trimStart()
}

I only found this because I typed a bank name into a mock response while testing and watched my own parser truncate it. That's a fairly small bug as bugs go, and also exactly the kind of thing you'd never think to unit test until it happens to you once.

Both dialects, plus Airtel's own JSON variant, sit behind one UssdResponseParser interface, a strategy per gateway, so the rest of the app, the dialog rendering, the session bookkeeping, the trace log, never needs to know which carrier it's talking to. There's also a fourth, fully configurable CustomParser that reads a message key, a status key, and a couple of regexes from settings instead of code, for the gateway I haven't seen yet.

The network layer is not allowed to throw

The one rule I set for myself early on: a bad response from the gateway is data, not an exception. A 500, a malformed JSON body, a socket timeout, a DNS failure, none of it should ever crash the app or even show a generic error screen. It should show up in the trace log as exactly what it was, because the entire point of the tool is to let you see what your own backend does when the network misbehaves.

return runCatching {
    withContext(ioDispatcher) {
        client.newCall(request).execute().use { response ->
            HttpExchange(/* ...status, headers, body, latency... */)
        }
    }
}.getOrElse { throwable ->
    HttpExchange(/* ...same shape, statusCode = null, error = throwable... */)
}

HttpExchange is the same data class either way, a successful response and a socket timeout both produce one, just with different fields populated. The trace log doesn't need a separate code path for failure because there isn't one.

That guarantee is also what makes the fault injector safe to build on top of. FaultMode lets you force an HTTP_500, a 503, a 404, or a hard socket drop on the next request, synthesized locally without ever touching the real endpoint. Since the success path and the injected-fault path both terminate in the same HttpExchange shape, adding fault injection didn't require touching the parsing or UI code at all. It's the kind of design payoff you don't appreciate until the second time you add a new fault type and realize you're only touching one file.

Rewinding a conversation you already had

USSD sessions are stateful and linear, you dial, you answer, the menu changes, and once you've answered you can't easily go back and try a different branch without starting over from the shortcode. That's exactly what you don't want while you're debugging a menu tree eight steps deep and the bug is on step three.

Every successful step pushes an immutable snapshot onto a stack: the step index, the accumulated inputs up to that point, the rendered dialog state, and the keystroke that got you there.

data class SessionSnapshot(
    val stepIndex: Int,
    val inputs: List<String>,
    val uiState: UssdUiState,
    val triggeringInput: String?,
)

Rewinding just restores that frame and truncates the stack after it:

fun timeTravelTo(snapshot: SessionSnapshot) {
    committedInputs.clear()
    committedInputs.addAll(snapshot.inputs)
    _history.update { it.take(snapshot.stepIndex + 1) }
    _uiState.value = snapshot.uiState.copy(isLoading = false, errorMessage = null)
}

Answer differently from that point and you branch off in a new direction, no re-dial required. It's a small feature on paper, a list and an index, but it's the one that turns "I have to start the whole flow over to test that edge case" into "let me just rewind two steps."

The other rule that makes this trustworthy: inputs are only committed, and a snapshot only pushed, after a step succeeds. If the gateway returns a fault mid-step, the keystroke that triggered it never gets folded into committedInputs. A failed request doesn't quietly corrupt the conversation you're replaying.

Small detail I didn't expect to need

UssdUiState carries an exceedsGsmLimit flag that flags any menu text over 160 characters, the GSM 7-bit single-packet limit. USSD menus that go over that boundary get concatenated across multiple packets by real handsets in ways that don't always round-trip cleanly, and a menu that looks fine in your gateway's test console can render truncated or garbled on an actual phone. It's one boolean and one constant, and it exists because that gap, fine in a gateway's test console, broken on an actual handset, is exactly the kind of thing worth flagging before a tester finds it the hard way.

What's underneath

Kotlin, Jetpack Compose for the dialer canvas and the developer HUD side by side, MVVM with a unidirectional data flow, one StateFlow<UssdUiState> that the whole UI observes. Koin for DI rather than Hilt, mostly because the module graph here is small enough that Koin's lack of compile-time safety never became a real cost. Room for the parts that need to survive a process restart: saved target configs, mock profiles, and recorded macros. Everything else, the live session, the trace log, the snapshot stack, is in-memory and gone the moment you dial again, which is exactly what you want for a debugging tool and exactly wrong for the app it's simulating.

Where it's thin

The custom parser's regex-driven mode is powerful and also the easiest place in the whole app to shoot yourself in the foot: a malformed regex just gets silently treated as "no match" (runCatching { Regex(this) }.getOrNull()) rather than surfaced as a config error, so a typo in your close-pattern doesn't fail loudly, it just quietly never closes the session. Latency injection is a flat delay before the request, not a distribution, so it can't yet reproduce the kind of network jitter that actually causes the intermittent bugs I built this to catch in the first place. And there's no way to record a real gateway's responses and replay them later. Right now every mock still has to be typed in by hand, which is fine for the shortcode I was actually testing against and would get tedious fast for anyone with more than one integration to maintain.