DEV Community

Cover image for Don’t trust the WebView: blank screens, bank handoffs, and false returns
Anton22255
Anton22255

Posted on

Don’t trust the WebView: blank screens, bank handoffs, and false returns

Two Android payment bugs looked harmless: a white WebView and a foreground return. Neither meant what it seemed to mean.

A QA report said the payment page was "still loading". It was not. The bank page had already tried to leave through bankapp://..., startActivity had failed, and the WebView was stuck on a blank document. The second bug was worse: the customer came back to our activity and we treated that foreground return as a successful payment attempt.

That is the setup here. The customer picks a bank, the bank may have both an app and an HTTPS web client, and when the app is missing we keep the web client inside our app and hand off only the known bank deeplink.

Vendor guides already show the obvious half: allow http/https in the WebView, send the bank scheme out with ACTION_VIEW, and ask your backend for status. They do not show how to tell a dead document from a spinner, or why onResume after that handoff is the wrong signal.

Here is the flow at a glance:
Flow from a bank WebView: a failed intent is checked against the page, and coming back to the foreground is not treated as a paid order

A blank page after a failed bank intent

startActivity returning false is not yet a UX state. The bank page often redirects to its own scheme and leaves a blank document behind. A white WebView is also what a slow web client looks like for the first second. If you show "install the bank app" immediately, you close a payment that was still drawing.

Start a DOM probe only after the launch has failed. Poll every 150 ms for at most 2.5 seconds. Read readyState, the number of element children on body, and the length of innerText. A child or any text means the page is alive. A document with neither, whose readyState is no longer loading, is empty. In parallel, arm a hard timeout of 5.5 seconds. If no usable snapshot arrives, close the screen and tell the customer to install the app.

private const val DOM_POLL_MS = 150L
private const val DOM_POLL_BUDGET_MS = 2_500L
private const val LAUNCH_FAILURE_TIMEOUT_MS = 5_500L

private const val DOM_PROBE_JS = """
(function () {
  var root = document.body;
  return JSON.stringify({
    readyState: document.readyState,
    childCount: root ? root.children.length : -1,
    textLength: root && root.innerText ? root.innerText.length : 0
  });
})();
"""

fun isEmptyDocument(readyState: String?, childCount: Int, textLength: Int): Boolean {
    if (childCount > 0 || textLength > 0) return false
    if (readyState.equals("loading", ignoreCase = true)) return false
    return true
}
Enter fullscreen mode Exit fullscreen mode

evaluateJavascript does not hand you that JSON object. The callback receives a quoted string, so decode it as a JSON string literal before parsing. Pages built with Angular are worse. Zone.js wraps the value in __zone_symbol__value. If that field is present, unwrap it and parse the inner JSON.

Keep the probe off during a normal load. An empty body in the first second is a spinner. Raise the flag only from the startActivity == false branch, and lower it again when a snapshot contains children or text.

This detector loses to layout that is visually blank and structurally not. One invisible node, or a space in innerText, looks alive. The opposite happens too: the bank draws a spinner after the 2.5 second budget, and you close a payment that was about to render.

Returning to the foreground is not success

Nothing in the DOM of the bank page is a receipt. Three channels can continue the flow, and only the server can finish it.

A redirect inside the WebView to a known return URL starts verification. Match https, your host, and the paths you actually own (/checkout, /checkout/processing, /checkout/success, including nested segments). The same matcher must accept the same URL when it arrives as an app link in onNewIntent. If the matchers diverge, a URL that verifies inside the WebView opens the storefront from the outside.

fun isReturnUrl(value: String): Boolean {
    val uri = value.toUri()
    if (!uri.scheme.equals("https", ignoreCase = true)) return false
    if (!uri.host.equals("shop.example", ignoreCase = true)) return false
    val path = uri.path?.trimEnd('/').orEmpty()
    return path == "/checkout" ||
        path == "/checkout/processing" ||
        path.startsWith("/checkout/processing/") ||
        path == "/checkout/success" ||
        path.startsWith("/checkout/success/")
}
Enter fullscreen mode Exit fullscreen mode

Lifecycle is the trap.

When we opened the bank app ourselves, a real trip to the background may start verification. When this flow does not need a server check, that same return just continues, and a launch that never happened is a dismiss.

When the bank app was opened from the WebView and the order must be verified, both callbacks are null. The WebView is still underneath. Coming back to the foreground looks identical for a finished payment, a system chooser, and a back press inside the bank before the customer confirmed anything. Verification in that mode starts from the return URL or the app link. Not from onResume.

Where lifecycle is tracked at all, one session is not enough if it only listens to the host fragment. Process stop and process start mean the whole app left the screen and came back. Host pause and host resume only mean the screen that fired the intent. A chooser often pauses the host without stopping the process: the app is alive, the bank is not chosen yet. Folding that into one callback turns the disambiguation dialog into "the customer returned, verify the order".

fun onProcessStart(backgroundedAtMs: Long?, nowMs: Long): Decision {
    if (phase != Phase.InBank) return Decision.None
    if (nowMs - (backgroundedAtMs ?: return Decision.None) < 800L) {
        return restorePhaseBeforeStop()
    }
    return Decision.CompleteReturn
}
Enter fullscreen mode Exit fullscreen mode

After startActivity, give the bank 10 seconds to appear. If the process never stops, the launch did not happen. That is a dismiss, not a return. A host pause without a process stop is usually the chooser, so replace the short timer with 60 seconds. Process stop moves the session to "in the bank". Process start counts as a return only after at least 800 ms in the background.

The WebView handoff that must verify an order does not subscribe to this session. There is nothing to call. A return there must not verify by itself.

The timers have edges. A bank that bounces back in under 800 ms will not count as a return, and verification then waits for the return URL. Ten seconds is short if the customer is staring at a chooser and we never observed the host pause. Sixty seconds on pause keeps the payment open after the customer has wandered off.

The return URL is still not "paid". It starts one status check. We poll up to six times, the first immediately, then every 3 seconds. Success and a hard failure stop early. Attempts exhausted while the server still says processing is its own error.

If I had to compress this into one rule, it would be this: the WebView may route the customer, lifecycle may describe where they went, but only your backend is allowed to say the payment succeeded.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more