Skip to main content

Command Palette

Search for a command to run...

Under the Hood: How Compose Multiplatform Opens a URL

Updated
5 min read

As mobile developers moving into the Multiplatform space, we often take high-level APIs for granted. A perfect example is opening a hyperlink. In Jetpack Compose, we simply grab the LocalUriHandler and call openUri(). But what happens after that call? How does a single line of Kotlin code trigger the default browser on Android, iOS, Windows, macOS, and Linux?

Today, we are diving deep into the call stack—from the Compose API down to the C++ JNI calls in the JDK.

For example, UriHandler is used like this (Kotlin Multiplatform RSS Reader sample):

val uriHandler = LocalUriHandler.current
// ... some code omitted
uriHandler.openUri(url)

This allows opening a link in an external browser on Android, iOS, Desktop (Java/AWT) and Web (wasmJs).

Compose API

UriHandler interface (AndroidX source code):

interface UriHandler {
    fun openUri(uri: String)
}

Android implementation (AndroidUriHandler):

class AndroidUriHandler(private val context: Context) : UriHandler {
    override fun openUri(uri: String) {
        try {
            context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
        } catch (e: ActivityNotFoundException) {
            throw IllegalArgumentException("Can't open $uri.", e)
        }
    }
}

Desktop / iOS / Web (Skiko-based)

Next, Compose delegates to a Skiko-based implementation (PlatformUriHandler):

internal class PlatformUriHandler : UriHandler, URIManager()

URIManager and URIHandler_openUri are defined in Skiko:

open class URIManager {
    open fun openUri(uri: String) = URIHandler_openUri(uri)
}
internal expect fun URIHandler_openUri(uri: String)

Skiko platform actuals

Desktop (AWT / Java):

// excerpt
val desktop = Desktop.getDesktop()
if (desktop.isSupported(Desktop.Action.BROWSE)) {
    desktop.browse(URI(uri))
    return
}
// Linux fallback: Runtime.getRuntime().exec(arrayOf("xdg-open", URI(uri).toString()))

macOS (Kotlin/Native):

internal actual fun URIHandler_openUri(uri: String) {
    NSWorkspace.sharedWorkspace.openURL(NSURL.URLWithString(uri)!!)
}

Web (wasmJs):

internal actual fun URIHandler_openUri(uri: String) {
    window.open(uri, target = "_blank")
}

iOS (UIKit):

internal actual fun URIHandler_openUri(uri: String) {
    UIApplication.sharedApplication.openURL(
        url = URLWithString(uri)!!,
        options = emptyMap<Any?, Any>(),
        completionHandler = null
    )
}

What java.awt.Desktop.browse() actually does (OpenJDK)

Next: what java.awt.Desktop.browse() (AWT, not Compose) actually does, and how it opens the browser on different OSes.

High-level flow

In java.awt.Desktop, browse() does a few checks and delegates to the platform peer:

public void browse(URI uri) throws IOException {
    checkAWTPermission();
    checkExec();
    checkActionSupport(Action.BROWSE);
    Objects.requireNonNull(uri);
    peer.browse(uri);
}

The peer comes from SunToolkit.createDesktopPeer(this) inside the private constructor:

private Desktop() {
    Toolkit defaultToolkit = Toolkit.getDefaultToolkit();
    // same cast as in isDesktopSupported()
    if (defaultToolkit instanceof SunToolkit) {
        peer = ((SunToolkit) defaultToolkit).createDesktopPeer(this);
    }
}

Sources:

Files:

Windows

On Windows, the peer is sun.awt.windows.WDesktopPeer. It calls a native ShellExecute(...) wrapper:

@Override
public void browse(URI uri) throws IOException {
    this.ShellExecute(uri, ACTION_OPEN_VERB);
}

private void ShellExecute(URI uri, String verb) throws IOException {
    String errmsg = ShellExecute(uri.toString(), verb);

    if (errmsg != null) {
        throw new IOException("Failed to " + verb + " " + uri
                + ". Error message: " + errmsg);
    }
}

private static native String ShellExecute(String fileOrUri, String verb);

Native side uses Windows ShellExecute (after COM init):

JNIEXPORT jstring JNICALL Java_sun_awt_windows_WDesktopPeer_ShellExecute
  (JNIEnv *env, jclass cls, jstring fileOrUri_j, jstring verb_j)
{
    ...
    HRESULT hr = ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED |
                                        COINIT_DISABLE_OLE1DDE);
    HINSTANCE retval;
    DWORD error;
    if (SUCCEEDED(hr)) {
        retval = ::ShellExecute(NULL, verb_c, fileOrUri_c, NULL, NULL,
                                SW_SHOWNORMAL);
        error = ::GetLastError();
        ::CoUninitialize();
    }
    ...
}

Files:

macOS

On macOS, the peer is sun.lwawt.macosx.CDesktopPeer. browse() delegates to lsOpen(uri) which calls native _lsOpenURI(...):

@Override
public void browse(URI uri) throws IOException {
    this.lsOpen(uri);
}

private void lsOpen(URI uri) throws IOException {
    int status = _lsOpenURI(uri.toString());

    if (status != 0 /* noErr */) {
        throw new IOException("Failed to mail or browse " + uri + ". Error code: " + status);
    }
}

private static native int _lsOpenURI(String uri);

Native side uses LaunchServices (LSOpenURLsWithRole):

JNIEXPORT jint JNICALL Java_sun_lwawt_macosx_CDesktopPeer__1lsOpenURI
(JNIEnv *env, jclass clz, jstring uri)
{
    OSStatus status = noErr;
JNI_COCOA_ENTER(env);

    // So we use LaunchServices directly.
    NSURL *url = [NSURL URLWithString:JavaStringToNSString(env, uri)];

    LSLaunchFlags flags = kLSLaunchDefaults;
    LSApplicationParameters params = {0, flags, NULL, NULL, NULL, NULL, NULL};
    status = LSOpenURLsWithRole((CFArrayRef)[NSArray arrayWithObject:url],
                                kLSRolesAll, NULL, &params, NULL, 0);

JNI_COCOA_EXIT(env);
    return status;
}

Files:

Linux / X11 (Unix)

On Linux/X11, the peer is sun.awt.X11.XDesktopPeer. Java delegates to a native gnome_url_show(...):

public void browse(URI uri) throws IOException {
    launch(uri);
}

private void launch(URI uri) throws IOException {
    byte[] uriByteArray = ( uri.toString() + '\0' ).getBytes();
    boolean result = false;
    XToolkit.awtLock();
    try {
        if (!nativeLibraryLoaded) {
            throw new IOException("Failed to load native libraries.");
        }
        result = gnome_url_show(uriByteArray);
    } finally {
        XToolkit.awtUnlock();
    }
    if (!result) {
        throw new IOException("Failed to show URI:" + uri);
    }
}

private native boolean gnome_url_show(byte[] url);
private static native boolean init(int gtkVersion, boolean verbose);

Native side tries GTK first (gtk_show_uri), then GNOME (gnome_url_show):

JNIEXPORT jboolean JNICALL Java_sun_awt_X11_XDesktopPeer_init
  (JNIEnv *env, jclass cls, jint version, jboolean verbose)
{
    if (gtk_has_been_loaded || gnome_has_been_loaded) {
        return JNI_TRUE;
    }

    if (gtk_load(env, version, verbose) && gtk->show_uri_load(env)) {
        gtk_has_been_loaded = TRUE;
        return JNI_TRUE;
    } else if (gnome_load()) {
        gnome_has_been_loaded = TRUE;
        return JNI_TRUE;
    }

...

JNIEXPORT jboolean JNICALL Java_sun_awt_X11_XDesktopPeer_gnome_1url_1show
  (JNIEnv *env, jobject obj, jbyteArray url_j)
{
    ...
    if (gtk_has_been_loaded) {
        gtk->gdk_threads_enter();
        success = gtk->gtk_show_uri(NULL, url_c, GDK_CURRENT_TIME, NULL);
        gtk->gdk_threads_leave();
    } else if (gnome_has_been_loaded) {
        success = (*gnome_url_show)(url_c, NULL);
    }
    ...
}

Files:

Other platforms

In OpenJDK, desktop support is primarily implemented for Windows, macOS, and Unix/X11. On headless/minimal builds (or when native libraries are missing), Desktop may report that BROWSE is unsupported and throw UnsupportedOperationException / IOException instead of opening a browser.

Summary

When you write uriHandler.openUri("https://hashnode.com/") in your common Compose code, you are triggering a massive chain of abstractions:

  1. Compose: LocalUriHandler delegates to platform implementation.

  2. Skiko: Delegates to java.awt.Desktop (on JVM).

  3. AWT: Delegates to OS-specific Peers (WDesktopPeer, CDesktopPeer, XDesktopPeer).

  4. JNI: Crosses the boundary into C/C++/Objective-C.

  5. OS API: Finally calls ShellExecute, LSOpenURLsWithRole, or gtk_show_uri.

It is a long journey for a simple click, but it highlights the power of Kotlin Multiplatform: writing once, and letting the framework handle the system-level complexity.