android

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: BSD-3-Clause Imports: 12 Imported by: 0

README

go-widgets/android

ci Go Reference Go Report Card

A pure-Go, CGO-free Android back-end for the go-widgets toolkit: a real, installable Android app whose entire user interface is laid out and painted by a Go process built with CGO_ENABLED=0.

a go-widgets tree running as an Android app

Why it is split in two

Android hands no drawable surface to a process that is not the app. Every path to one — ANativeWindow, Surface, NativeActivity — is behind JNI, and JNI needs cgo. purego does not rescue it either: its dlfcn_android.go routes Dlopen through internal/cgo, unlike its darwin and non-cgo linux paths. And there is no wire-protocol back door the way X11 and Wayland have one: SurfaceFlinger sits behind Binder, and a Surface only ever comes from the WindowManager against an Activity token.

So the app is two processes:

Java host (host/, ~360 lines) owns the Activity, the SurfaceView, touch, keys and the lifecycle. Blits pixels. Knows nothing about widgets.
Go application (cmd/gwapp) an ordinary CGO_ENABLED=0 GOOS=android executable. Owns layout, widgets, theme, hit-testing and focus — unchanged from every other back-end.

This is the split the Linux back-ends already live with — a socket protocol plus a shared pixel buffer — with the Java host standing exactly where the X server or the Wayland compositor stands.

  MotionEvent ─► LocalSocket ─► android.Client ─► toolkit widget tree
                                      │
  Surface ◄── Bitmap ◄── mmap'd file ◄─┘  painter.PixelPainter
                    ▲
                    └── MsgFrame{x,y,w,h}: which rectangle changed

Pixels travel through a memfd the application creates and hands to the host as an ancillary descriptor on the socket, so they live in memory and never dirty page cache the kernel writes to storage. (A file in the app's own storage is the fallback where memfd_create is missing.) The Go side writes RGBA_8888, which is byte-for-byte what Android's ARGB_8888 Bitmap holds in memory, so the blit is a copy with no conversion — and only the damaged rectangle is copied, measured on an Android 15 arm64 device:

damage on a 1080×2400 surface whole-surface copy damage-only copy
400×300 (a widget) 883 µs 328 µs
full surface (a plain tree) 3335 µs 1957 µs

(median of 41 and 21 blits; the full-surface case gets faster too because the gathered tile is drawn with an offset blit rather than a src/dst rect one.)

The memfd is worth the same kind of measurement — /proc/meminfo Dirty, idle versus painting, same session and same taps:

framebuffer idle painting delta
file in app storage 192 kB 10188 kB +9996 kB
memfd 188 kB 140 kB −48 kB

Ten megabytes of dirty page cache per painting session, written out to flash half a minute later, for pixels that are pure scratch — gone.

arm64 only, and why

android/arm64 is the only Android target Go links CGO-free:

$ CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build ./cmd/gwapp   # fine
$ CGO_ENABLED=0 GOOS=android GOARCH=arm   go build ./cmd/gwapp
android/arm requires external (cgo) linking, but cgo is not enabled

android/amd64 and android/386 answer the same. So the premise this back-end rests on — a sovereign application binary with no C tool chain — holds on 64-bit ARM alone, which is every Android phone and tablet shipped for years, but not the x86 emulator images. CI asserts both halves of that, so the day Go lifts the restriction is a red build rather than a silent one.

Usage

c, err := android.Dial("my app", nil) // nil theme = toolkit.DefaultDark()
if errors.Is(err, android.ErrUnsupported) {
    // Not running under a host: the module still builds and vets everywhere.
    return nil
}
defer c.Close()
return c.Run(myWidgetTree()) // blocks until the Activity goes away

Client satisfies go-widgets/window's Backend (Run/Close/Size/ String) and its Repainter, so an application moves between this back-end and X11, Wayland, Cocoa, Win32 or wasmbox without changing a line above the window.

System bars

An Android window is edge-to-edge from API 35: the surface really is the whole screen, and the status bar, the navigation bar, a display cutout and the soft keyboard are painted on top of it rather than shrinking it. The tree is therefore laid out inside what they leave, so its first and last rows are not hidden; the margins are still painted in the theme background, so the bars sit on the app's own colour.

Client.Insets() reports those four edges, and Client.SetFullBleed(true) opts back out to the whole surface — for a root that means to reach under the bars (a photo, a map, a video) and takes responsibility for keeping anything readable out of the way.

Layout

protocol.go       the sovereign codec — wire messages, framing, and the
                  input→toolkit.Event mapping. No syscall, no net: it
                  builds, and is tested, on every GOOS.
client.go         the transport — dials the host, maps the framebuffer,
                  drives the widget tree. //go:build linux
client_other.go   the same surface reporting ErrUnsupported, so an
                  application still cross-builds off Android.
cmd/gwapp/        the demo application.
host/             the Java host, its manifest, and build.sh.

Building the APK

No Gradle and no Kotlin: the host is a handful of Java files and the application is a Go binary, so the SDK's own tools are the whole tool chain.

export ANDROID_HOME=... JAVA_HOME=...
sdkmanager --install "platforms;android-35" "build-tools;35.0.0"
host/build.sh                        # → host/out/gwhost.apk
adb install host/out/gwhost.apk

build.sh runs go build, javac, d8, aapt2 link, zipalign and apksigner, in that order, and picks the newest platform and build-tools the SDK has installed. APP=./cmd/myapp host/build.sh packages your own application instead of the demo. Two things it does that are worth knowing:

  • the Go executable ships as lib/<abi>/libgwapp.so with extractNativeLibs="true", because nativeLibraryDir is the one place an Android app may execute from. It is a plain PIE executable; nothing ever dlopens it;
  • the debug keystore lives beside the sources, never under the build output. A fresh key per build changes the signing certificate, and Android then refuses to update an installed app (INSTALL_FAILED_UPDATE_INCOMPATIBLE).

Testing

The transport is Linux, and Android is Linux: the abstract socket it dials and the shared mapping it paints into are ordinary Linux facilities. So the suite runs against a fake host over a real socket and a real mmap, not a mock — on the CI Linux runner under -race, and on the device itself:

go test -c -cover -coverpkg=. -o android.test .
adb push android.test /data/local/tmp/ && adb shell /data/local/tmp/android.test

100.0% statement coverage, gated in CI, covering every decode error, every framebuffer failure, the lifecycle pause and the damage-rectangle path. -race runs on the Linux lane only: the race detector needs cgo, which is the very thing an Android application binary must not have.

Proven on device

Android 15 / arm64:

  • the app installs and launches; the whole window is the go-widgets tree;
  • a touch reaches the widget — three taps on the button leave clicks: 3, and a pixel diff bounds the repaint to the button and its label alone;
  • a live rotation is survived in-process: the Activity keeps configChanges, the surface is remapped at 2400×1080, the tree is laid out again, and taps still land (landscape);
  • the surface geometry and display density cross the socket — the demo reports surface: 1080x2400 px, density 263, the panel's true 2.625×;
  • the system bars do not hide anything: with the device reporting statusBars top=128 and navigationBars bottom=126, the tree's first text row moves from y=234 to y=337 and its last from y=2160 to y=2063 — the +103 and −97 a five-child box redistributed over the safe area gives, to the pixel (without insets vs with).

Known gaps

Deliberate, and none of them protocol-deep:

  • single touch — the protocol carries a pointer id, the host forwards one. Multi-touch, fling and inertial scroll are toolkit work, not host work;
  • no IME — a soft keyboard needs InputConnection on the host and a text model above;
  • no accessibility — the host can expose an AccessibilityNodeProvider fed by the same tree the AT-SPI, UIA and NSAccessibility bridges already walk;

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

This file is the transport that binds the sovereign codec (protocol.go) to a live Java host: it dials the host's abstract unix socket, creates and maps the shared framebuffer, hands its descriptor over, paints the go-widgets root into it and posts a frame per damaged rectangle. It is CGO-free — the whole Android side of the process is a socket and an mmap, both of which Go makes on its own — so the application binary stays exactly as sovereign as it is on X11 or Wayland.

Package android implements the application half of the go-widgets Android host protocol, so a go-widgets application runs inside a real Android app exactly as it runs on X11, Wayland, Cocoa or Win32.

Android hands out no drawable surface to a process that is not the app: the whole graphics API is behind JNI, and JNI needs cgo. So the app is split in two. A thin Java host owns the Activity, the SurfaceView and the input stream; the go-widgets application is an ordinary CGO-free executable the host spawns, which paints into a shared mapping and tells the host which rectangle changed. The split is the same one the Linux back-ends already live with — a socket protocol plus a shared pixel buffer — with the Java host standing where the X server or the Wayland compositor stands.

This file is the SOVEREIGN, transport-agnostic codec: the wire messages, the framing, and the input→toolkit.Event mapping, over plain Go values. It carries no syscall and no net dependency, so it builds — and is unit-tested to 100% — on every GOOS. The transport that dials the host socket, maps the buffer and drives a widget tree lives in client.go.

Index

Constants

View Source
const (
	// MsgConfig carries the surface geometry and the shared buffer path. The
	// host sends it once at start-up and again on every resize or rotation.
	MsgConfig uint8 = 0x01
	// MsgTouch carries one pointer sample in surface pixels.
	MsgTouch uint8 = 0x02
	// MsgKey carries one key event: an Android key code plus the unicode rune
	// the host's key-character map produced (0 when the key produces none).
	MsgKey uint8 = 0x03
	// MsgLifecycle carries an Activity transition: the app keeps its widget
	// tree across a pause, but stops painting until it resumes.
	MsgLifecycle uint8 = 0x04
	// MsgClose asks the application to end its Run loop.
	MsgClose uint8 = 0x05
	// MsgInsets carries the area of the surface the system is drawing over.
	// It is its own message rather than a Config field because insets change
	// on their own schedule: the soft keyboard opening does not resize the
	// surface, and a bar auto-hiding does not either.
	MsgInsets uint8 = 0x06

	// MsgReady tells the host the shared buffer is mapped at the announced
	// size, so the host may map it in turn. Every MsgFrame that follows
	// refers to this mapping, until the next MsgReady replaces it.
	MsgReady uint8 = 0x81
	// MsgFrame tells the host which surface-local rectangle changed.
	MsgFrame uint8 = 0x82
	// MsgTitle updates the host's window title.
	MsgTitle uint8 = 0x83
	// MsgBye tells the host the application ended.
	MsgBye uint8 = 0x84
)

Message types. Host→app messages are below 0x80, app→host at or above it, so a misrouted message is a decode error rather than a plausible other message.

View Source
const (
	TouchDown uint8 = 0
	TouchUp   uint8 = 1
	TouchMove uint8 = 2
)

Touch actions, matching the three MotionEvent actions the host forwards.

View Source
const (
	KeyDown uint8 = 0
	KeyUp   uint8 = 1
)

Key actions.

View Source
const (
	LifecyclePause  uint8 = 0
	LifecycleResume uint8 = 1
)

Lifecycle states.

View Source
const EnvSocket = "GW_ANDROID_SOCKET"

EnvSocket names the environment variable the Java host sets to the abstract socket it is listening on. The host generates a fresh name per launch, so two instances of the app never collide.

View Source
const MaxPayload = 1 << 16

MaxPayload bounds one decoded message body. The largest message a host legitimately sends is a Config carrying a filesystem path, so a frame beyond this is a desynchronised stream — refused rather than allocated.

Variables

View Source
var ErrShortPayload = errors.New("android: truncated message payload")

ErrShortPayload reports a message whose body is too short for its type.

View Source
var ErrUnsupported = errors.New("android: no Android host on this platform")

ErrUnsupported reports an environment with no Android host: every GOOS but Linux, where the abstract socket and the shared mapping the host protocol needs do not exist. A cross-built application gets this from Dial and can report it and exit cleanly, exactly as go-widgets/window does off its supported back-ends.

Functions

func DecodeReady

func DecodeReady(b []byte) (w, h int, err error)

DecodeReady parses a MsgReady body.

func EncodeConfig

func EncodeConfig(c Config) []byte

EncodeConfig builds a MsgConfig body.

func EncodeFrame

func EncodeFrame(r Rect) []byte

EncodeFrame builds a MsgFrame body naming the damaged rectangle.

func EncodeInsets added in v0.3.0

func EncodeInsets(i Insets) []byte

EncodeInsets builds a MsgInsets body.

func EncodeKey

func EncodeKey(k Key) []byte

EncodeKey builds a MsgKey body.

func EncodeReady

func EncodeReady(w, h int) []byte

EncodeReady builds a MsgReady body: the size the application actually mapped.

func EncodeTouch

func EncodeTouch(t Touch) []byte

EncodeTouch builds a MsgTouch body.

func FrameMessage added in v0.4.0

func FrameMessage(typ uint8, body []byte) []byte

FrameMessage returns one framed message: a 4-byte big-endian length covering the type byte and the body, then the type byte, then the body. Big-endian keeps the Java host on DataInputStream.readInt with no byte-swapping.

It exists as bytes rather than as writes because a message that carries an ancillary descriptor has to reach the host in ONE sendmsg: split across two writes, the host could attribute the descriptor to the wrong message.

func MapKey

func MapKey(k Key) []toolkit.Event

MapKey maps one Android key event to toolkit events, mirroring the wasmbox and X11 mappings: a named key is one EventKeyDown/EventKeyUp; a key that committed a character is an EventKeyDown followed by an EventChar on press, and an EventKeyUp on release. A key that is neither named nor printable reaches the tree as nothing.

func MapTouch

func MapTouch(t Touch, held bool) []toolkit.Event

MapTouch maps one pointer sample to toolkit events. It mirrors the wasmbox and X11 mappings: a press is a click, a move with the finger down is a drag. A touch screen has no hover, so a move with no finger down cannot occur and is mapped to a plain move rather than dropped, keeping a synthetic host (a test, a replay) honest.

func ReadMessage

func ReadMessage(r io.Reader) (typ uint8, body []byte, err error)

ReadMessage reads one framed message. It returns io.EOF when the stream ends cleanly between messages, so a caller can tell a closed host from a truncated one.

func WriteMessage

func WriteMessage(w io.Writer, typ uint8, body []byte) error

WriteMessage writes one framed message.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is an open Android host surface bound to a go-widgets scene. It satisfies window.Backend (Run/Close/Size/String), so a go-widgets app runs through Open→Run unchanged whether the backend is X11, Wayland, Cocoa, Win32, wasmbox or — here — a Java host Activity.

func Dial

func Dial(title string, theme *toolkit.Theme) (*Client, error)

Dial connects to the Java host named by $GW_ANDROID_SOCKET and blocks until the host has announced its surface geometry, so the returned Client is ready to paint. It is the Android-environment analogue of openX11/openWayland.

func (*Client) Close

func (c *Client) Close() error

Close ends the session: it tells the host the application is going away, unmaps the framebuffer and ends Run. Idempotent.

func (*Client) Density

func (c *Client) Density() int

Density returns the display density in hundredths, as the host read it from Android's DisplayMetrics (a 3x panel is 300). It is this back-end's spelling of the backing-scale factor.

func (*Client) Insets added in v0.3.0

func (c *Client) Insets() Insets

Insets returns the margin of the surface the system is drawing over: the status and navigation bars, a display cutout, the soft keyboard. By default the widget tree is laid out inside what they leave; see Client.SetFullBleed.

func (*Client) Repaint

func (c *Client) Repaint()

Repaint asks for a repaint from ANY goroutine, satisfying window.Repainter.

func (*Client) Run

func (c *Client) Run(root toolkit.Widget) error

Run binds root, paints the seed frame and blocks while host messages drive the widget tree, until the host closes the surface (or Close ends it). It is the Android analogue of the X11 and Wayland event loops.

func (*Client) SetFullBleed added in v0.3.0

func (c *Client) SetFullBleed(on bool)

SetFullBleed lays the widget tree out over the WHOLE surface, insets and all. It is for a root that means to reach under the system bars — a photo, a map, a video — and is then responsible for keeping anything readable out of the area Client.Insets reports.

func (*Client) SetTitle

func (c *Client) SetTitle(title string)

SetTitle updates the host's window title.

func (*Client) Size

func (c *Client) Size() (int, int)

Size returns the current surface size in physical pixels.

func (*Client) String

func (c *Client) String() string

String identifies the surface for debugging.

type Config

type Config struct {
	// W and H are the surface size in physical pixels.
	W, H int
	// Density is the display density in hundredths (Android's
	// DisplayMetrics.density × 100, so a 3.0x panel arrives as 300). It is the
	// Android spelling of the backing-scale factor the Cocoa back-end reads
	// from the screen.
	Density int
	// BufPath is the file the application maps as its framebuffer. The host
	// picks it inside the app's own storage, which both processes share.
	BufPath string
}

Config is the host's geometry announcement.

func DecodeConfig

func DecodeConfig(b []byte) (Config, error)

DecodeConfig parses a MsgConfig body.

type Insets added in v0.3.0

type Insets struct{ Left, Top, Right, Bottom int }

Insets is the margin of the surface the system draws over, in pixels.

An Android window is edge-to-edge from API 35: the surface really is the whole screen, and the status bar, the navigation bar, a display cutout and the soft keyboard are painted ON TOP of it rather than shrinking it. So a widget tree laid out to the full surface is correct in size and wrong in practice — its first and last rows are behind the bars. These are the four edges to keep clear.

func DecodeInsets added in v0.3.0

func DecodeInsets(b []byte) (Insets, error)

DecodeInsets parses a MsgInsets body.

func (Insets) Apply added in v0.3.0

func (i Insets) Apply(w, h int) Rect

Apply returns the part of a w×h surface that nothing is drawn over. It never returns a negative extent: insets wider than the surface (a phone folded to a sliver, a bad host) collapse the area to zero rather than inverting it.

func (Insets) Empty added in v0.3.0

func (i Insets) Empty() bool

Empty reports whether nothing is covering the surface.

type Key

type Key struct {
	Action uint8
	// Code is the Android KeyEvent key code.
	Code int
	// Rune is the character the key produced, or 0 for a key that produces
	// none (an arrow, a modifier, the back key).
	Rune rune
}

Key is one key event.

func DecodeKey

func DecodeKey(b []byte) (Key, error)

DecodeKey parses a MsgKey body.

type Rect

type Rect struct{ X, Y, W, H int }

Rect is a surface-local rectangle in pixels. It mirrors toolkit.Rect but is kept local so the codec stays a leaf with one toolkit dependency (the event model).

func ClampRect

func ClampRect(r Rect, w, h int) Rect

ClampRect clips r to a w×h surface, returning a zero-area rectangle when nothing of r is inside. The host trusts the rectangle it is given, so the application clamps before sending.

func DecodeFrame

func DecodeFrame(b []byte) (Rect, error)

DecodeFrame parses a MsgFrame body.

type Touch

type Touch struct {
	Action uint8
	X, Y   int
	// ID is the pointer index, so a later multi-touch host can be told apart
	// from this one without a protocol break. Single-touch hosts send 0.
	ID int
}

Touch is one pointer sample.

func DecodeTouch

func DecodeTouch(b []byte) (Touch, error)

DecodeTouch parses a MsgTouch body.

Directories

Path Synopsis
cmd
gwapp command
Command gwapp is the go-widgets application half of the Android host demo.
Command gwapp is the go-widgets application half of the Android host demo.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL