Image Github

Calling Java from Go in a Gio UI Android App

August 12, 2025

Lately I’ve been playing around with Gio UI, a UI library for Go. Although Gio supports Android, getting it to interoperate with Java code that uses the Android SDK turned out to be more complex than expected due to a lack of solid resources on the topic. So I thought I’d share what I managed to figure out. Here, I’ll walk through how to call Java code from Go in an Android app.

Let’s start with a basic Gio app that shows “Hello World!” on the screen:

package main

import (
	"log"
	"os"

	"gioui.org/app"
	"gioui.org/font/gofont"
	"gioui.org/op"
	"gioui.org/text"
	"gioui.org/widget/material"
)

func main() {
	go func() {
		w := new(app.Window)
		if err := loop(w); err != nil {
			log.Fatal(err)
		}
		os.Exit(0)
	}()
	app.Main()
}

func loop(w *app.Window) error {
	th := material.NewTheme()
	th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
	var ops op.Ops
	for {
		switch e := w.Event().(type) {
		case app.DestroyEvent:
			return e.Err
		case app.FrameEvent:
			gtx := app.NewContext(&ops, e)
			l := material.H1(th, "Hello world!")
			l.Alignment = text.Middle
			l.Layout(gtx)
			e.Frame(gtx.Ops)
		}
	}
}

To build it for Android, follow the official Gio Android setup guide. Then run these commands:

# Runs any //go:generate directives (we’ll add some later for Java)
go generate

# The -ldflags "-checklinkname=0" flag is needed because of the
# [anet](https://github.com/wlynxg/anet) library Gio uses for networking.
gogio -ldflags "-checklinkname=0" -target android github.com/yourusername/yourmodule -o demo.apk

# Install to Android device
adb install demo.apk

Moving on to the Java side, we’ll create a method to write a file to the Android Downloads folder using the Android SDK’s MediaStore API:

package com.example.demo;

import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;

import java.io.OutputStream;

public class Utils {
    public static void writeToDownloadsFolder(
        Context context, byte[] contents, String filename, String mimetype) {
        try {
            ContentResolver resolver = context.getContentResolver();
            ContentValues values = new ContentValues();
            values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
            values.put(MediaStore.MediaColumns.MIME_TYPE, mimetype);
            values.put(MediaStore.MediaColumns.RELATIVE_PATH,
                Environment.DIRECTORY_DOWNLOADS + "/");

            Uri uri = resolver.insert(MediaStore.Files.getContentUri("external"), values);
            OutputStream output = resolver.openOutputStream(uri);
            output.write(contents);
            output.flush();
            output.close();
        } catch (Exception e) {
            Log.e("demo-project", "Exception occurred!", e);
        }
    }
}

Now we need to compile the Java code into a jar and include it into our Gio app. We can automatically do that by add go:generate directives at the top of the Go code:

//go:generate javac -classpath $ANDROID_HOME/platforms/android-36/android.jar -d /tmp/java_classes Utils.java
//go:generate jar cf Utils.jar -C /tmp/java_classes .

Replace android-36 with your Android SDK version. These commands compile Utils.java into class files, using the Android SDK’s android.jar for dependencies and packages the class files into Utils.jar.

Now we get to the tricky part: calling our Java method from Go. Since Android apps run in the Android Runtime (ART), a JVM compatible environment and our app is written in Go, we need a way to bridge the two. This is where the Java Native Interface (JNI) comes in.

The JNI enables native code (written in C/C++) to interact with Java code. It operates through a set of functions exposed via a JNIEnv pointer, which serves as the entry point for most JNI operations. These functions allow you to locate java classes by name, create java objects from native data, invoke java methods, handle exceptions and manage JVM state.

The Android Native Development Kit (NDK) implements those functions for the ART. We’ll also be using jnigi, since it wraps the raw C JNI functions in nice Go abstractions.

The workflow for our Go code would be to get a JNIEnv pointer tied to the current thread (since you can’t share a JNIEnv between threads), convert Go arguments to the corresponding Java objects, then locate the java class and methods and invoke it with arguments.

Given our example, it would look like this:

import (
	"github.com/timob/jnigi"
	"gioui.org/app"
)

func writeToDownloadsFolder(filename, mimetype string, contents []byte) error {
	env, cleanup := getJNIEnv()
	defer cleanup()

	context := jnigi.WrapJObject(app.AppContext(), "android/content/Context", false)

	filenameObj, err := env.NewObject("java/lang/String", []byte(filename))
	if err != nil {
		return err
	}

	mimetypeObj, err := env.NewObject("java/lang/String", []byte(mimetype))
	if err != nil {
		return err
	}

	contentsObj := env.NewByteArrayFromSlice(contents)

	err = env.CallStaticMethod(
		"com/example/demo/Utils",
		"writeToDownloadsFolder",
		nil, // returns void
		context, contentsObj, filenameObj, mimetypeObj,
	)
	return err
}

Gio Android backend gives us the app’s Context, which is a data structure that gives the app access to Android ressources.

Now to get the JNIEnv pointer required for JNI operations, we’ll use CGo to call C functions from the Android NDK:

/*
#cgo LDFLAGS: -llog -landroid

#include <android/log.h>
#include <android/native_window_jni.h>
#include <stdlib.h>

static jint jni_GetEnvOrAttach(JavaVM *vm, JNIEnv **env, jint *attached) {
    jint res = (*vm)->GetEnv(vm, (void **)env, JNI_VERSION_1_6);
    if (res == JNI_EDETACHED) {
        res = (*vm)->AttachCurrentThread(vm, (void **)env, NULL);
        *attached = res == JNI_OK;
    }
    return res;
}

static void jni_DetachCurrent(JavaVM *vm) {
    (*vm)->DetachCurrentThread(vm);
}
*/
import "C"
import (
    "runtime"
    "unsafe"
)

func getJNIEnv() (*jnigi.Env, func()) {
	runtime.LockOSThread()

	jvm := app.JavaVM()
	cJVM := (*C.JavaVM)(unsafe.Pointer(jvm))

	var cEnv *C.JNIEnv
	var attached C.jint
	C.jni_GetEnvOrAttach(cJVM, &cEnv, &attached)
	_, env := jnigi.UseJVM(unsafe.Pointer(jvm), unsafe.Pointer(cEnv), nil)

	cleanup := func() {
		if attached != 0 {
			C.jni_DetachCurrent(cJVM)
		}
		runtime.UnlockOSThread()
	}
	return env, cleanup
}

jni_GetEnvOrAttach uses the JavaVM pointer to query the current JNIEnv with GetEnv. If the current thread is not attached, it attaches it. This is necessary because JNI requires threads to be explicitly attached to the JVM to obtain a valid JNIEnv. We specify JNI_VERSION_1_6 for compatibility.

jni_DetachCurrent calls DetachCurrentThread to release the thread from the JVM, preventing resource leaks.

In getJNIEnv we bind the goroutine to a specific OS thread, get the JavaVM pointer from Gio’s Android backend, call our C functions and define a cleanup function that detaches the thread if it’s attached and unlocks the OS thread. Note that the nil when calling jnigi.UseJVM is for the optional thiz (this in Java), which we won’t need for static methods.

And that’s that! For debugging purposes we’ll add a panic handler:

import (
	"fmt"
	"runtime"
    "runtime/debug"
	"strings"
	"unsafe"
)

func androidCrashHandler() {
	if r := recover(); r != nil {
        str := fmt.Sprintf("Crash: %v\n%s", r, debug.Stack())
        tag := C.CString("demo-project")
        defer C.free(unsafe.Pointer(tag))

        lines := strings.Split(str, "\n")
        for _, line := range lines {
            msg := C.CString(line)
            C.__android_log_write(C.ANDROID_LOG_INFO, tag, msg) // from <android/log.h>
            C.free(unsafe.Pointer(msg))
        }
	}
}

And update the main function to include the panic handler and test the Java call:

func main() {
	if runtime.GOOS == "android" {
		defer androidCrashHandler()

        contents := []byte("Open your mind...")
        if err := writeToDownloadsFolder("this-works.txt", "text/plain", contents); err != nil {
            panic(fmt.Sprintf("Failed to write to Downloads: %s", err))
        }
	}

	go func() {
		w := new(app.Window)
		if err := loop(w); err != nil {
			log.Fatal(err)
		}
		os.Exit(0)
	}()
	app.Main()
}

Now, if you build and run the app, and check the Download folder in Internal Storage, you should see this-works.txt. If not you can inspect the logs with

adb logcat -s demo-project

You can find the full code here.