Skip to content

[Bug] JavaScript bridge exit() can finish the Activity from a WebView background thread #79

@venkyqz

Description

@venkyqz

Hi WebUI X Team,

I’m a PhD student researching Android thread-related issues. My research group recently ran a static analysis scan for thread-related bugs in real-world Android apps, and our prototype flagged a potential issue in Webui X.

Checked target

  • Source-level caller: com.dergoogler.mmrl.webui.interfaces.ApplicationInterface.exit()
  • Detected API / pattern: @JavascriptInterface callback directly reaches Activity.finish()
  • Observed context: WebView JavaScript-interface background thread
  • Expected context: main/UI thread before Activity lifecycle operations

What I found

WXView registers ApplicationInterface as one of the built-in JavaScript interfaces:

private fun addJavascriptInterfaces() {
    addJavascriptInterface<FileInputInterface>()
    addJavascriptInterface<FileOutputInterface>()
    addJavascriptInterface<ApplicationInterface>()
    addJavascriptInterface<FileInterface>()
    addJavascriptInterface<ModuleInterface>()
    addJavascriptInterface<UserManagerInterface>()
    addJavascriptInterface<PackageManagerInterface>()
    addJavascriptInterface<IntentInterface>()
    ...
}

ApplicationInterface exposes the bridge object name as webui:

class ApplicationInterface(
    wxOptions: WXOptions,
) : WXInterface(wxOptions) {
    override var name: String = "webui"
    override var tag: String = "ApplicationInterface"
    ...
}

The JavaScript-callable exit() method directly calls into the Activity helper:

@JavascriptInterface
fun exit() {
    withActivity {
        exit(options)
    }
}

withActivity { ... } directly invokes the block on the current thread:

fun <R> withActivity(block: Activity.() -> R): R? {
    if (activity == null) {
        console.trace("withActivity -> activity == null")
        console.error("[$tag->withActivity] Activity not found")
        return null
    }

    return block(activity)
}

The target exit(options) helper calls finish():

internal fun Activity.exit(options: WebUIOptions) {
    if (!options.forceKillWebUIProcess) {
        finish()
        return
    }

    finish()
    Process.killProcess(Process.myPid())
    exitProcess(0)
}

The Android documentation for the legacy addJavascriptInterface(...) bridge says the system calls annotated methods on a background thread and requires careful synchronization on the Kotlin/Java side. Therefore, a JavaScript call to webui.exit() can reach Activity.finish() from the WebView bridge background thread.

Verified bug trace

WebUI page JavaScript
  -> calls webui.exit()
  -> WebView invokes ApplicationInterface.exit() on the JavaScript-bridge background thread
  -> ApplicationInterface.exit()
  -> withActivity { exit(options) }
  -> WXActivity.Companion.exit(options)
  -> Activity.finish()
  -> Activity lifecycle operation is reached from the WebView bridge thread
  -> possible wrong-thread lifecycle access / inconsistent close behavior

This looks like a source-verified candidate because the latest source still registers ApplicationInterface, exposes exit() via @JavascriptInterface, and reaches Activity.finish() without a WebView.post { ... }, Handler(Looper.getMainLooper()).post { ... }, or runOnUiThread { ... } hop.

I also searched the current GitHub issues and pull requests for this pattern using terms such as JavascriptInterface exit finish thread and ApplicationInterface exit, and did not find an existing matching discussion.

Why this matters

WebView JavaScript bridge methods are easy to treat like ordinary UI callbacks, but they are not delivered on the Android main thread. Closing or finishing an Activity is a lifecycle/UI operation and should be performed on the main thread.

This issue may be hard to reproduce consistently because finish() is often tolerant in simple cases, but the pattern is fragile. It can become more problematic when the Activity is already changing state, when WebView teardown is happening at the same time, or when device/WebView implementations are stricter about UI-thread access.

Possible fix

Marshal the Activity operation back to the main thread before calling exit(options).

A minimal local fix is to use the existing post { ... } helper:

@JavascriptInterface
fun exit() {
    post {
        withActivity {
            exit(options)
        }
    }
}

Alternatively, withActivity could be split into two helpers: one for pure Activity access and another for Activity/UI operations that always dispatches to the main thread.

For example:

fun withActivityOnMain(block: Activity.() -> Unit) {
    post {
        withActivity {
            block()
        }
    }
}

Then exit() can become:

@JavascriptInterface
fun exit() {
    withActivityOnMain {
        exit(options)
    }
}

Reference

Relevant Android documentation wording:

The system calls methods on a background thread, requiring careful synchronization on the Kotlin or Java side.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions