Help us shape the future of Go! Take the 2026 Go Developer Survey

android

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2026 License: BSD-3-Clause Imports: 13 Imported by: 0

README

go-xrkit

go-xrkit/android

CI Go Reference coverage license

Screen capture on Android from pure Go, CGO_ENABLED=0, for an XR compositor that redraws every frame.

if !android.Authorized() {
        ok, err := android.RequestAuthorization(ctx) // the system consent dialog
        ...
}
d, _ := android.DefaultDisplay(ctx)

s, err := android.CaptureDisplay(ctx, d, android.Options{FPS: 60}) // a CEILING
if err != nil {
        return err
}
defer s.Close()

for {
        f, fresh := s.Frame() // BORROWED pixels, no copy, no allocation
        if fresh {
                composite(f.Pix, f.Width, f.Height, f.Stride) // RGBA, stride carried
        }
        render()
}

It is the Android member of a family — go-macos/screencapture, go-freedesktop/screencast, go-mswin/screencapture — deliberately shaped so one consumer drives all of them through near-identical adapters: Displays, CaptureDisplay, Stream.Frame() (Frame, bool) lending borrowed pixels with a stride carried and never assumed, an idempotent Close.


Read this first: what Android will and will not allow

The interesting question for an XR virtual desktop is not "can I capture the screen" — it is "can I create extra desktops and put applications on them", the way go-xrkit/desk does on macOS with real virtual displays.

Half of it is no, and the other half is yes — and the earlier edition of this file got the boundary wrong by drawing it in the wrong place. It is not your own app versus other people's, and it is not virtual display versus real one. It is an activity launch versus a window, on a display you made versus a display you were given:

a display you were GIVEN (external, overlay) a display you MADE (createVirtualDisplay)
launch an activity on it (setLaunchDisplayId) yes — ours and Settings no — SecurityException, even for our own activity
show a Presentation on it yes yes, and the pixels arrive

So an ordinary, unprivileged APK cannot manufacture desktops for other people's applications, which is what a macOS virtual display gives go-xrkit/desk. But it can manufacture several hundred independent displays of its own content, each rendered by the real Android view system and each read back as pixels. That is a great deal more than one mirrored phone, and it is what the Android ribbon is actually built on.

Everything below was read off a live Android 15 (API 35, arm64) system, not off documentation. host/../docs carries no prose about this: the transcript is the documentation. The probe that produced it is XrDisplayProbeActivity, and it is in the APK: every number below can be re-read by running it.

Creating a virtual display and launching something on it
what was asked for what Android 15 answered
VIRTUAL_DISPLAY_FLAG_PUBLIC | PRESENTATION SecurityException: Requires CAPTURE_VIDEO_OUTPUT or CAPTURE_SECURE_VIDEO_OUTPUT permission, or an appropriate MediaProjection token in order to create a screen sharing virtual display.
… | OWN_CONTENT_ONLY created — display id 2, flags=0x8 [PRESENTATION]. Note what is absent: FLAG_TRUSTED.
launch Settings on it, ActivityOptions.setLaunchDisplayId(2) SecurityException: Permission Denial: starting Intent { act=android.settings.SETTINGS … } from ProcessRecord{… org.goxrkit.probe/u0a151} … with launchDisplayId=2
launch our own activity on it the same denial — an app may not put even itself on its own virtual display
… | VIRTUAL_DISPLAY_FLAG_TRUSTED (1 << 10) SecurityException: Requires ADD_TRUSTED_DISPLAY permission to create a trusted virtual display.
But a Presentation on that same display works, and the pixels arrive

This is the door the first edition of this file never tried, and it was sitting in plain sight: the display it did create came back carrying the very flag that says a Presentation may be shown there. A Presentation is a Dialog attached to a Display — not an activity start — and the check that refused the table above is an activity-start check.

Q1 created display 17 "xr-probe-own-0" real=640x480 @320dpi (density 2.0) appBounds=640x480 refresh=60.0Hz flags=0xc state=2 valid=true
Q1 ANSWER: Presentation.show() SUCCEEDED on the app's OWN virtual display 17
Q2 frame 640x480 rowStride=2560 pixelStride=4 distinctColours>=64 black=0/307200 meanRGB=85 topLeft=#FF00FF00 topRight=#FFFF0000 bottomLeft=#FFFF0000 bottomRight=#FF0000FF
Q2 ANSWER: the PIXELS ARRIVED and are the ones the Presentation drew — green top-left, blue bottom-right, red elsewhere, at the exact sampled coordinates

No exception, no permission, no consent dialog, and no MediaProjection.

The second line is the one that matters, because a show() that returns and a buffer that arrives are two different facts, and a buffer that arrives black is the third. So the probe does not look at the picture: it draws solid quadrants of exactly known colour and samples four coordinates, then reports the black count and the distinct-colour count alongside them. black=0/307200 and four sentinels that hold is a frame that carries what was drawn. A run where the sentinels did not hold would be reported as a failure, not as a success.

The picture is there anyway, because a person should be able to look: presentation-own-virtual-display.png, 640×480, the quadrants plus a label drawn by Android's own text stack. It is not committed — see Where captures go.

Display.getFlags() answers 0xc for that display — FLAG_PRESENTATION|FLAG_PRIVATE. The 0x8 [PRESENTATION] quoted in the table above is dumpsys's view of the underlying device, which does not carry the private bit. Same display, two objects, and neither is FLAG_TRUSTED. The trusted bit gates the activity launch and nothing else here.

How many at once: 304, and then the system dies

Not a graceful refusal — that is the finding, and it is the reason a product must impose its own limit rather than discovering the platform's.

Q3 1920x1080 #303 id=307 OK — frame 1920x1080 rowStride=7680 pixelStride=4 distinctColours>=64 black=0/2073600 meanRGB=85 topLeft=#FF00FF00 …
E AndroidRuntime: *** FATAL EXCEPTION IN SYSTEM PROCESS: android.ui
E AndroidRuntime: android.view.Surface$OutOfResourcesException: NO_MEMORY
E AndroidRuntime: 	at android.view.SurfaceControl.nativeCreate(Native Method)
E AndroidRuntime: 	at com.android.server.wm.WindowContainer.createSurfaceControl(WindowContainer.java:678)
E AndroidRuntime: 	at com.android.server.wm.DisplayAreaPolicyBuilder$PendingArea.instantiateChildren(DisplayAreaPolicyBuilder.java:963)
E AndroidRuntime: 	at com.android.server.wm.DisplayContent.<init>(DisplayContent.java:1224)
E AndroidRuntime: 	at com.android.server.wm.RootWindowContainer.onDisplayAdded(RootWindowContainer.java:2769)
E AndroidRuntime: DeadSystemException: The system died; earlier logs will point to the root cause

system_server takes the exception, dies, and the device soft-reboots. An ordinary APK with no permissions at all did that.

Two runs, and the number is worth reading carefully:

displays created display size result
304 640×480 #303 fine; creating #304 killed system_server
304 1920×1080 #303 fine; creating #304 killed system_server — the same count

The same count at nine times the pixels, with the app's Java heap at 14 MiB of 192. So it is not graphics memory: it is SurfaceControl handles, and DisplayContent's constructor builds a whole DisplayAreaPolicy tree of them per display. The ceiling therefore does not move if you make the screens smaller, which is exactly the mitigation somebody would reach for.

64 displays at 1920×1080, each with a Presentation whose pixels arrived correct, cost 14 MiB of Java heap and nothing else observable. A ribbon wants a dozen. The useful reading is "as many as you want, with a hard cap you set yourself" — and 304 is a number measured on one emulator, not a constant to hard-code.

VIRTUAL_DISPLAY_FLAG_TRUSTED is not merely undocumented — it is not in the public SDK at all. javap on the API 35 android.jar lists exactly five flags, and TRUSTED is not among them:

$ javap -classpath $ANDROID_HOME/platforms/android-35/android.jar \
        android.hardware.display.DisplayManager | grep VIRTUAL_DISPLAY
  public static final int VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR;
  public static final int VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY;
  public static final int VIRTUAL_DISPLAY_FLAG_PRESENTATION;
  public static final int VIRTUAL_DISPLAY_FLAG_PUBLIC;
  public static final int VIRTUAL_DISPLAY_FLAG_SECURE;

And the permissions that would unlock it are, on the same device:

$ adb shell pm list permissions -f
  permission:android.permission.ADD_TRUSTED_DISPLAY   protectionLevel:signature|role
  permission:android.permission.CAPTURE_VIDEO_OUTPUT  protectionLevel:signature
  permission:android.permission.CREATE_VIRTUAL_DEVICE protectionLevel:internal|role

signature means "signed with the platform key". internal|role cannot even be granted by signature. None of the three is reachable by an APK you or I can install.

VirtualDeviceManager (Android 14+) does not change the answer

It is the obvious thing to hope for, and it is closed twice over. The public SDK class carries no createVirtualDevice at all — only read-only queries:

$ javap … android.companion.virtual.VirtualDeviceManager
  public java.util.List<VirtualDevice> getVirtualDevices();
  public VirtualDevice getVirtualDevice(int);
  public void registerVirtualDeviceListener(…);
  public void unregisterVirtualDeviceListener(…);

The method does exist on the platform class, so it was called reflectively to get the platform's own answer rather than the header's:

vdm: createVirtualDevice EXISTS on the platform class:
     public VirtualDeviceManager$VirtualDevice createVirtualDevice(int, VirtualDeviceParams)
vdm: createVirtualDevice REFUSED:
     java.lang.SecurityException: Access denied, requires: android.permission.CREATE_VIRTUAL_DEVICE
So what an XR application on Android actually gets

Mirroring, plus a ring of its own screens. One panel carrying the phone's real screen, and as many independent displays of the application's own content as it cares to create — each a real Android display with a real view hierarchy on it, each read back as pixels through an ImageReader.

What it does not get is other people's applications on those screens. That half of the refusal is real and is not going anywhere: ADD_TRUSTED_DISPLAY is signature. The ring is ours to fill.

This is the same shape as the answer on Linux, where xrandr --setmonitor carves virtual monitors out of a real one with no privileged API at all.


The Go API for owned displays

The probe proved the platform allows it; this is how a consumer uses it. A Wall is a set of displays this application created, and the limit on how many may exist at once.

w, err := android.NewWall(0)          // 0 = DefaultMaxDisplays (8)
defer w.Close()

d, err := w.Open(ctx, android.DisplaySpec{
        Width: 1280, Height: 720,
        Content: android.Web{URL: "https://example.org/"},
})
if errors.Is(err, android.ErrTooManyDisplays) { ... }
defer d.Close()

for {
        f, fresh := d.Frame()          // BORROWED pixels, stride carried
        if fresh {
                composite(f.Pix, f.Width, f.Height, f.Stride)
        }
}

OwnedDisplay embeds *Stream, so a ribbon consumes a display it made exactly as it consumes the mirrored phone — Frame, WaitFrame, Stats, Format, Size, an idempotent Close. That is the point: the ribbon does not have to know which kind of panel it is holding.

NewWall(max int) (*Wall, error) 0 means DefaultMaxDisplays; past MaxDisplays, or negative, is ErrTooManyDisplays and no wall
(*Wall).Open(ctx, DisplaySpec) (*OwnedDisplay, error) one display, one Presentation, one feed
(*Wall).Len() int, (*Wall).Max() int how full, and how full it may get
(*Wall).Close() error releases every display it still holds; idempotent
(*OwnedDisplay).ID() int the android.view.Display id, for logs and dumpsys
(*OwnedDisplay).Spec() DisplaySpec the spec, with its zero fields filled in
(*OwnedDisplay).Close() error releases the display and gives the slot back; idempotent
SentinelColorAt(w, h, x, y) (uint32, bool) the colour a correct sentinel frame holds there, or false where anti-aliasing makes it unsafe to assert
The drawing seam: a DECLARATION, not pixels

DisplaySpec.Content names what Android should render. The obvious alternative — hand the host a framebuffer to blit — was rejected, and the reason is worth stating because it looks like the natural design:

it would be a round trip with no product. Pixels this process drew, sent to Android, read back unchanged. An application that is drawing should composite in Go and skip the display entirely.

The reason to route a ribbon panel through a real Android display is the opposite one: to get at what Android renders and a CGO-free Go process cannot — a WebView, a MediaCodec surface, a PdfRenderer, a maps view. None is reachable without JNI, all are ordinary Views, and a Presentation is how a View gets onto a display. So Content names it, the Java host builds it, and the pixels come back through the same borrowed-frame path as a capture. It also keeps the rule the rest of this package is built on: a URL is a decision the application made; a WebView is the host obeying it.

Two implementations, and the set is closed — a Content written outside this package would name a Java class the host does not have:

  • Web{URL} — a WebView. http, https and data: URLs; JavaScript on, file access off. A network URL needs android.permission.INTERNET (protectionLevel normal); a data: URL needs nothing.
  • Sentinel{Label} — flat quadrants of exactly known colour. It is the package's self-test content and it is exported because a consumer needs it too: a feed that "works" and delivers a black buffer is the silent failure of this whole mechanism, and the only way to catch it is to draw something whose pixels are known in advance and then sample them.
The limit is the safety, and it refuses in both directions

Wall refuses past its limit with ErrTooManyDisplays before asking the host, so a full wall costs no round trip and creates nothing. XrWallService enforces MAX_DISPLAYS = 32 independently, because a process that does not use this API must not be able to get past it either.

DefaultMaxDisplays 8 — a ribbon uses six to eight
MaxDisplays 32 — the most NewWall accepts however explicitly it is asked
measured on one emulator 304, at which system_server died and the device rebooted

Read this before raising the limit. The ceiling was the same count at 640×480 and at 1920×1080 — nine times the pixels, same number, app heap at 14 MiB of 192. It is SurfaceControl handles, not graphics memory, and making the screens smaller does not help, which is exactly the mitigation somebody reaches for. 304 is one emulator's measurement, is deliberately not a constant anywhere in the code, and a real device's number is unknown and could be lower.

Proved on the device, in both directions:

LIMIT 2 of 2 opened, as they must
LIMIT refused past 2, as it must: android: too many virtual displays: this wall holds 2 of at most 2; close one before opening another
LIMIT NewWall refuses past the ceiling of 32, as it must
One connection per display

The capture host memoises a single process-wide session, because there is one screen and one projection. A wall is the opposite: several displays live at once, each with its own shared buffer and its own frame stream. Multiplexing them down one socket would mean a feed id on every frame message and routing on the hot path, where a bug mixes two panels' pixels.

A connection each costs one socket per panel — nothing, for the six to eight a ribbon uses — and buys three things: Stream is reused unchanged, including its tested frame plumbing; a display's lifetime is exactly a socket's lifetime, so closing one cannot disturb another; and a client that dies has its displays released by the kernel closing its sockets.

XrWallService is therefore a second service on its own socket (<package>.xrwall). It needs no MediaProjection, no consent dialog and no foreground service — an owned display asks for no permission at all — so opening a ribbon panel cannot put a "recording your screen" chip in the status bar, and a capture session ending cannot take a panel with it.

A Presentation shown from a Service context works, which was not obvious and was measured rather than assumed: Presentation is a Dialog, and a Dialog from a non-Activity context normally fails with BadTokenException. It does not here, on a private display the app owns.

What the live proof reports

cmd/xrwall, packaged into the APK and run on the device:

WALL available=true
WALL package default 8, ceiling 32
OPEN owned display 7 640x480 @320dpi sentinel 0
FRAME panel 0 640x480 stride 2560 seq 1: sampled 115921, wrong 0, black 0/307200
OPEN owned display 8 640x480 @320dpi sentinel 1
FRAME panel 1 640x480 stride 2560 seq 1: sampled 115921, wrong 0, black 0/307200
OPEN owned display 9 640x480 @320dpi sentinel 2
FRAME panel 2 640x480 stride 2560 seq 1: sampled 115921, wrong 0, black 0/307200
WALL holds 3 of 8
OPEN owned display 10 640x480 @320dpi web data:text/html,<body style='margin:0;background:%23FFFF00'>...
FRAME web panel 640x480 seq 2: 98.5% of the pixels are the page's own colour
RESULT every panel carried the pixels its content drew, and the limit refused in both directions

115 921 pixels asserted per panel, none wrong, none black — sampled at the coordinates SentinelColorAt vouches for, never by looking at the picture. The web panel is asserted the same way, on the page's own background colour rather than on the absence of an error. The artefact is wall-sentinel.png; it is not committed — see Where captures go.

APP=./cmd/xrwall host/build.sh && adb install -r host/out/xrhost.apk
# `--ez capture false` skips the mediaProjection foreground service, which the
# platform KILLS THE PROCESS for starting without the project_media app-op.
adb shell am start -n org.goxrkit.androidhost/org.goxrkit.android.XrDemoActivity --ez capture false
adb logcat -s xrcapture xr-wall
host/pull-artifacts.sh wall-sentinel.png

What a capture delivers

Measured, on the emulator named at the bottom of this file, with the cmd/xrcapture binary packaged into the APK:

DISPLAY display 0 "Built-in Screen" 1080x2400 @420dpi 60Hz (default)
capture 1080x2400 stride 4320 slots 3 ceiling 60.0 fps
lent 3 slots of 10368000 bytes
FIRST frame 1080x2400 stride 4320 (width*4 = 4320, padding 0) seq 1
RESULT frames=492 over 11.99s = 41.0 fps; identical-to-previous=68/491
RESULT Frame(): 22.8 ns/op, 0.000 allocations/op over 200000 calls
RESULT stats {Frames:493 Superseded:1 Interval:16.755125ms}
size the display's native pixels, 1080×2400 here — no downscale is imposed
format RGBA_8888 (android.graphics.PixelFormat 1), packed, 4 bytes per pixel
stride 4320 bytes, which happens to be exactly width*4 on this device. The API carries the number anyway, because a graphics allocator is free to pad and a sheared image is the classic result of assuming it does not
rate 41.0 fps sustained at 1080×2400 on a software-rendered emulator, with the capturing app in the background. A second run of the same binary, against a slower-changing screen, measured 18.5 fps — the ceiling is a ceiling, and what the screen does sets the rest. A Java-only probe of the same ImageReader path, with no Go in it, measured 48.0 fps, which bounds this package's own overhead at a few frames a second
Frame() 22.8 ns and 0.000 allocations per call, over 200 000 calls on the device. That is the borrow: Frame.Pix aliases the shared mapping the host wrote into
The content really changes

A capture that hands back a frozen buffer looks exactly like a working one until somebody hashes it, so cmd/xrcapture hashes every frame: 68 of 491 frames were identical to their predecessor and 423 were not, while another application scrolled in front. A run where every frame matched would be reported as a failure, not as a capture.

One frame is saved as a PNG artefact: testdata/artifacts/android-capture.png — 1080×2400, the Settings app scrolling, taken while this app was in the background. The cast indicator in the status bar is Android's own "something is recording your screen" chip.

Frames only arrive when something changes

Like ScreenCaptureKit on macOS, a MediaProjection is change-driven and Options.FPS is a ceiling, not a rate. Left in front of a static screen, the same cmd/xrcapture binary took 35 frames in the first 0.6 seconds and then nothing at all for the remaining eleven — RESULT frames=35 over 0.63s. That is the platform saying nothing moved; the second return value of Frame is the truth about it, not a timer.

Going to the background does not stop it

This matters more here than in most apps, because our app is the thing being covered. The probe deliberately calls moveTaskToBack(true) the instant consent is granted, and then captures another application for twelve seconds: onCapturedContentVisibilityChanged(visible=true), 492 frames, no interruption.

What is required for that is a foreground service of type mediaProjection, and from API 34 it is required anyway: the platform refuses to issue a projection token to a process that is not already running one. FOREGROUND_SERVICE_MEDIA_PROJECTION is protectionLevel:normal, so an ordinary APK simply declares it.

MediaProjectionManager.createScreenCaptureIntent() must be answered by the user, the token is single-use, and nothing remembers the answer — unlike a macOS TCC grant. Authorized() therefore answers "right now", not "ever", and RequestAuthorization exists as a separate call because the dialog takes over the screen and an application should choose when that happens.


Reaching the glasses

On a phone with XR glasses on USB-C DP Alt Mode, Android sees an ordinary second Display. Two routes were exercised against a trusted, public secondary display (see the honesty note below about which one):

  • Presentation — a window of ours on the other display while the phone shows something else. Presentation.show() succeeded, reporting presentation display=8 context size=1920x1080 dpi=320 — the whole display — and dumpsys window confirms it from the outside: Display: mDisplayId=8 … mObscuringWindow=Window{961b015 u0 org.goxrkit.probe}.

  • ActivityOptions.setLaunchDisplayId — this one is the surprise. Launching our own activity worked, and so did launching Settings:

    Display #8 (activities from top to bottom):
        topResumedActivity=ActivityRecord{… com.android.settings/.homepage.SettingsHomepageActivity}
    Display #0 (activities from top to bottom):
        topResumedActivity=ActivityRecord{… org.goxrkit.probe/.Probe}
    

    The framework's rule is "anyone may launch on a public display"; the restriction that bites in the table above applies to virtual displays that are not trusted. So on the one display the glasses provide, an ordinary app can place other applications. It still cannot make a second one.

The display an app-created virtual display gets is flags=0x8 [PRESENTATION] as a device and 0xc [PRESENTATION|PRIVATE] as a Display. The system-created secondary display is flags=0x88 [PRESENTATION|TRUSTED], and the built-in panel is 0x4083. The trusted bit is the whole difference.

"A screen larger than the phone's" is the glasses' own display

A competing application shows, in the glasses, a screen bigger than the phone's panel. There is nothing clever behind it and it is worth saying so rather than inferring a mechanism: it is the external display, used at its own resolution. Nothing virtual is involved.

Q5 display 0  "Built-in Screen" real=1080x1920 @420dpi (density 2.625) appBounds=1080x1920 refresh=60.0Hz flags=0x4083
Q5 display 16 "Overlay #1"      real=1920x1080 @320dpi (density 2.0)   appBounds=1920x1080 refresh=60.0Hz flags=0x88
Q5 GIVEN display 16 "Overlay #1": Presentation.show() SUCCEEDED, presentation context 1920x1080 @320dpi
Q5 GIVEN display 16 after layout, decor 1920x1080

An ordinary app gets the whole of it: 1920×1080 at the display's own 320 dpi, not the phone's 420, and the decor view really lays out at 1920×1080 — measured after layout, not read off the metrics, because a window that reports a size and lays out smaller is the thing worth catching. The phone stays 1080×1920 at 420 dpi and is free to show something else.

One trap, since it produced a wrong number here first: asking getMaximumWindowMetrics() of a plain createDisplayContext(d) answers with the default display's bounds. It has to be a window context — createDisplayContext(d).createWindowContext(TYPE_APPLICATION, null) — and the wrong answer looks entirely plausible.

The phone as a trackpad

The device's own touchscreen keeps working normally while the content people are looking at is somewhere else, and the Presentation does not steal the events:

Q4 READY: presentation up on display 146, phone touches so far 0
TOUCH phone view ACTION_DOWN (340.00195,930.0) displayId=0
TOUCH phone view ACTION_UP (340.00195,930.0) displayId=0
…
Q4 ANSWER: phone view received 16 MotionEvents while its content was on display 146;
           the presentation view received 0; last phone event ACTION_UP (620.001,1140.0)

Sixteen MotionEvents to the view on display 0, zero to the Presentation's view — which is the half that makes a trackpad possible rather than a conflict. An app-created virtual display has no input device attached to it at all (its DisplayViewport is touch VIRTUAL), so there is nothing to arbitrate: the phone's digitiser belongs to the phone's window, and the pointer drawn into the ribbon is ours to place. Nothing new is needed on the Go side — go-widgets/android already forwards touch as MsgPointer; what changes is only where the application chooses to draw the cursor.

What a headset's Display reports — its Name is what the catalogue in go-xrkit/xrkit/glasses matches models on. For the built-in panel Android reports a device string ("Built-in Screen" on this emulator); for an external sink it comes from the EDID. No Android artifact for any real headset exists here — see below, and please read that section before believing anything about a specific model.

Input

Nothing new is needed, and that is the finding rather than an omission. A Bluetooth or USB keyboard reaches an Android app as ordinary KeyEvents, which go-widgets/android already forwards to the Go process as MsgKey (Android key code plus the rune its key-character map produced), alongside touch and wheel events. A ribbon scrolled from the keyboard therefore works through the existing back-end with no capture-specific code at all. The emulator enumerates a KEYBOARD | ALPHAKEY | DPAD device, so key injection reaches an app there too.


How it is put together

Android hands no MediaProjection, and no drawable surface, to a process that is not the app: both are behind JNI, and JNI needs cgo. So the app is two processes, exactly as go-widgets/android established:

Java host (host/, ~600 lines over three files) owns the projection token, the ImageReader and the mirroring VirtualDisplay. Forwards bytes. Decides nothing.
Go application an ordinary CGO_ENABLED=0 GOOS=android executable. Owns which display, which size, which rate, and when to stop.
Why a separate repository, and a Service rather than an Activity

An APK may hold many components but only one of them may own the drawing surface, and in a go-widgets application that one is go-widgets/android's GwHostActivity. Screen capture also has no business living inside a widget toolkit. So this is its own module, and its host is:

  • a Service — which is mandatory anyway from API 34, as above;
  • a transparent Activity used for nothing but holding the consent dialog, because consent is an activity result and nothing else.

The two hosts coexist as components of one package, each owning what it must, and the single Go process talks to both over two sockets. Neither host knows the other exists.

That composition has one practical wrinkle, and it is solved here rather than by changing go-widgets: the process is spawned by go-widgets' Activity, which knows nothing about capture and sets no socket name for it. Android gives a spawned process no way to ask its own package name either. What it does give it is HOME — which that host sets to /data/user/0/<package>/files — so android.DeriveSocket reads the package out of it and derives the host's socket name. XR_ANDROID_SOCKET overrides it when something does name one.

  MsgStart ─────────────────────► XrHostService ──► MediaProjection
                                        │             │
  MsgConfig ◄─── stride, slots ─────────┤             ▼
  MsgBuffer ◄─── SCM_RIGHTS fd ─────────┤          ImageReader
                                        │             │
  Frame() ◄── mmap(PROT_READ) ◄── SharedMemory ◄── one memcpy
                                        │
  MsgFrame{seq, slot, w, h, stride} ◄───┘
The frame buffer belongs to the HOST, and that is forced

The natural design — the Go side creates a memfd and lends it to the host, the way go-widgets/android lends its framebuffer — does not work in this direction, because the host has to write. An Android app cannot map a descriptor it received read-write. All three routes were tried on the device:

route Android 15's answer
SharedMemory.fromFileDescriptor(pfd) on a Go memfd IllegalArgumentException: FileDescriptor is not a valid ashmem fd
new RandomAccessFile("/proc/self/fd/" + fd, "rw") FileNotFoundException: /proc/self/fd/112: open failed: EACCES (Permission denied)
FileInputStream/FileOutputStream .getChannel().map(READ_WRITE, …) NonWritableChannelException — each of those channels is open one way only

So the host creates the region itself with SharedMemory.create() — ashmem, which dirties no page cache at all, so tens of megabytes a second of pure scratch never reach flash — and lends the descriptor to the application, which maps it PROT_READ. A stray write on the Go side traps instead of silently corrupting a frame under the compositor.

SharedMemory keeps its descriptor to itself, but writeToParcel is public and writes exactly that descriptor, so a Parcel is the supported way back to one.

Three slots, and why not two

Frame() lends; the host keeps producing. With two slots a host that produced two frames while the consumer held one would overwrite the borrow under it. The default and the minimum are therefore three, which is the same reasoning — and the same number — behind go-macos/screencapture's MinQueueDepth. A slot is a whole framebuffer (10.4 MB for a 1080×2400 phone), so MaxQueueDepth is a real memory limit rather than a formality.

One copy per frame, and there is no zero-copy path

The ImageReader plane is gralloc memory. Mapping that needs the graphics HAL, which is behind cgo — the one thing this stack must not have. So there is exactly one copy: direct ByteBuffer to direct ByteBuffer, which is a native memcpy with no Java heap in the middle and no allocation per frame. Measured on a 1080×2400 frame (10.37 MB): 0.325 ms, about 32 GB/s.

Everything after that is free: the Go side maps the same pages and Frame() returns a subslice.


Where captures go

A screen capture is a picture of whoever ran the probe, at work, and this repository is public. So a capture is never written where it could be committed, and a .gitignore does not count: git add -f, a fresh clone, or any tool that does not consult it will publish the file anyway.

The probe writes its PNG to the app's external files directory on the device, which is inside no repository. Pulling it to a workstation is the puller's business, and the puller must send it somewhere durable and outside every git work tree — os.UserConfigDir()-based, with an environment override, and the chosen directory walked up to the filesystem root looking for a .git, failing if it finds one. That is captureDir(t) in go-macos/screencapture, and the refusal is the point:

$ XRKIT_ARTIFACT_DIR=./testdata/artifacts host/pull-artifacts.sh …
REFUSED: …/testdata/artifacts is inside the git work tree at …

Nor does it go to a temporary directory. The artefact exists so that a person can look at it, and t.TempDir() would be gone before anyone could.

Building the APK

No Gradle and no Kotlin: the host is three 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/xrhost.apk
adb install host/out/xrhost.apk
adb shell pm grant org.goxrkit.androidhost android.permission.POST_NOTIFICATIONS
adb shell am start -n org.goxrkit.androidhost/org.goxrkit.android.XrDemoActivity
adb logcat -s xrcapture xr-host

APP_BIN=/path/to/binary PACKAGE=org.example.app host/build.sh packages an application from another module; the flags are the same ones go-widgets/android's script takes, because it is the same script.

arm64 is the only CGO-free Android architecture — android/arm, android/amd64 and android/386 all answer "requires external (cgo) linking, but cgo is not enabled" — and CI asserts both halves of that, so the day Go lifts the restriction is a red build rather than a silent one.

Testing

100.0% statement coverage, gated in CI on both lanes — the Linux transport and the off-Android stub. A stub nobody tests is a stub that panics the day somebody links it.

The transport is //go:build linux, and Android is Linux, so the suite runs against a fake host over a real socket, a real memfd and a real SCM_RIGHTS handover — not a mock:

CGO_ENABLED=0 GOOS=android GOARCH=arm64 go test -c -cover -coverpkg=. -o xr.test .
adb push xr.test /data/local/tmp/ && adb shell /data/local/tmp/xr.test

Two tests are worth naming because they assert things a reading of the code cannot:

  • TestFrameIsBorrowedNotCopied writes through the host's mapping and reads it back out of a Frame the consumer already holds. The whole no-allocation promise rests on that frame not being a copy.
  • TestStrayDescriptorsAreNotLeaked throws eighty misplaced file descriptors at the client and then counts the process's own /proc/self/fd. A leaked descriptor is invisible until the process runs out of them, so it is measured rather than reasoned about.

-race runs on the Linux lane only: the race detector needs cgo, which is the very thing an Android application binary must not have.


What was actually tested against

This section distinguishes three things and never rounds upward.

Reproducing all of it
# 1. an arm64 Android 15 emulator (Apple Silicon or an arm64 Linux host)
sdkmanager --install "platforms;android-35" "build-tools;35.0.0" \
           "system-images;android-35;default;arm64-v8a" "emulator"
avdmanager create avd -n xr35 -k "system-images;android-35;default;arm64-v8a"
emulator -avd xr35 -no-window -no-audio -gpu swiftshader_indirect &
adb wait-for-device

# 2. the unit suite, on the device
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go test -c -cover -coverpkg=. -o xr.test .
adb push xr.test /data/local/tmp/
adb shell 'cd /data/local/tmp && chmod +x xr.test && TMPDIR=/data/local/tmp ./xr.test'

# 3. the end-to-end capture, through a real APK
host/build.sh && adb install -r host/out/xrhost.apk
adb shell pm grant org.goxrkit.androidhost android.permission.POST_NOTIFICATIONS
adb shell appops set org.goxrkit.androidhost PROJECT_MEDIA allow   # skips the
                       # consent dialog; an adb-only shortcut, never an app one
adb shell am start -n org.goxrkit.androidhost/org.goxrkit.android.XrDemoActivity
sleep 3 && adb shell am start -a android.settings.SETTINGS    # something moving
for i in $(seq 1 26); do adb shell input swipe 540 1600 540 900 300; \
                         adb shell input swipe 540 900 540 1600 300; done
adb logcat -s xrcapture xr-host
adb pull /storage/emulated/0/Android/data/org.goxrkit.androidhost/files/android-capture.png

# 4. the virtual-display refusals, and the secondary-display results
adb shell settings put global overlay_display_devices "1920x1080/320"
adb shell pm list permissions -f | grep -A4 ADD_TRUSTED_DISPLAY
javap -classpath "$ANDROID_HOME/platforms/android-35/android.jar" \
      android.hardware.display.DisplayManager | grep VIRTUAL_DISPLAY

# 5. every Q1..Q5 transcript quoted above, from the probe in the APK.
#    A realistic panel first: a bare avdmanager AVD is 320x640 at 160 dpi.
adb shell wm size 1080x2400 && adb shell wm density 420
adb shell am start -n org.goxrkit.androidhost/org.goxrkit.android.XrDisplayProbeActivity
#    … and while it prints "Q4 READY", inject taps on the PHONE's touchscreen:
adb shell input tap 540 1200
adb logcat -s xr-probe
host/pull-artifacts.sh presentation-own-virtual-display.png

# 6. the ceiling. WARNING: this one KILLS system_server and reboots the device.
adb shell am start -n org.goxrkit.androidhost/org.goxrkit.android.XrDisplayProbeActivity \
    --es smallCap 512 --es bigCap 400

The probe takes --es smallCap N and --es bigCap N so the ceiling can be hunted without rebuilding, and defaults both to 64 — comfortably below the number that kills the device, because a probe whose default reboots the machine is a probe nobody runs twice.

Hardware connected and exercised

None. No XR glasses were attached to an Android device for any of this. That is the honest headline and it should be read before anything else here.

Everything above was measured on the Android emulator, API level 35 (Android 15), system-images/android-35/default/arm64-v8a, running on an Apple Silicon Mac — fingerprint Android/sdk_phone64_arm64/emu64a:15/AE3A.240806.019/12368160:userdebug/test-keys, 1080×2400 at 420 dpi. A real arm64 Android system, a real MediaProjection, a real SharedMemory, a real socket, real frames. It is not a real phone, and it is certainly not a phone with glasses on its USB-C port.

Partially observed
  • The ceiling of 304, and the crash. Reproduced twice, at two sizes, on one emulator with a software renderer. The shape of the failure — no refusal, system_server dies of SurfaceControl exhaustion, the count does not move with the display size — is the finding and should carry. The number is this emulator's and must not be hard-coded anywhere.
  • A secondary display. The Presentation and setLaunchDisplayId results come from the emulator's simulated secondary display (settings put global overlay_display_devices "1920x1080/320"), which reports type=OVERLAY, uniqueId="overlay:1", FLAG_PRESENTATION|FLAG_TRUSTED. A display attached over DP Alt Mode is type=EXTERNAL and is likewise public and trusted, and the framework's launch check keys on virtual versus not and on private/trusted rather than on the type — so the result should carry. It was not verified on a real external display, and "should carry" is not "verified".
  • Frame rates. 41–48 fps at 1080×2400 was measured on a software-rendered emulator. A device with a GPU-composited surface should do better, but this repository has not measured one.
Known only from documentation
  • Every published claim about a specific headset — display name, USB identity, the modes it offers — including everything in go-xrkit/xrkit/glasses. There is no Android artifact for any real headset in this repository at all. A macOS display name is not an Android one, and neither is an EDID read on Linux.
  • That VITURE's own SpaceWalker uses this route on a phone over DP Alt Mode.
Send us hardware

We will gladly add and verify a model we can hold. If you want a device supported and verified rather than quoted, send us the hardware and it will be tested against and listed here. Failing that, the next best thing is to plug one in yourself and tell us what adb shell dumpsys display said.

Known gaps

Deliberate, and stated rather than hidden:

  • no real device, and no real glasses — see above;
  • capture of a second display is impossible for an unprivileged app and is reported as ErrNotCapturable rather than attempted. MediaProjection mirrors the default display; anything else needs CAPTURE_VIDEO_OUTPUT, which is signature;
  • Presentation on a display the glasses provide is still not wired in. Wall covers displays the application MAKES. Drawing on a display it was GIVEN — the one the glasses are — is the widget back-end's job, and what this repository contributes there is only the measured fact that the route exists;
  • only two Content kinds exist. Web and Sentinel. MediaCodec, PdfRenderer and a maps view are the obvious next ones and none is written;
  • the limit is 32 by construction, not by measurement on hardware. It is enforced in both the Go API and the Java host, and it is far below the 304 that killed system_server on an emulator. What a real phone does at 32 owned displays has not been measured, and neither has what it does at 304;
  • the trackpad is a finding, not a feature. That the phone's MotionEvents keep arriving while the content is elsewhere, and that the Presentation gets none of them, is measured. The pointer that a ribbon would draw from them does not exist yet;
  • the row stride is measured, not guessed — but there is a fallback. The host waits up to two seconds for its first Image to learn the allocator's real stride. If none arrives (a screen that is off, say) it announces an aligned upper bound instead, and refuses — loudly, with MsgStopped — any later frame that would not fit. That path has not been provoked on a device.

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

Package android captures the Android screen from a pure-Go, CGO-free application, for an XR compositor that redraws every frame.

What Android permits, and what it does not

Everything here is shaped by one measured fact: an ordinary, unprivileged APK may MIRROR the screen and may draw its own content on an attached display, and it may do nothing else. It cannot give itself extra desktops. On Android 15 (API 35), creating a virtual display and launching an activity on it fails with

SecurityException: Permission Denial: starting Intent { ... }
    ... with launchDisplayId=N

for ANY activity, including the caller's own; asking for VIRTUAL_DISPLAY_FLAG_TRUSTED — which is not in the public SDK at all — fails with "Requires ADD_TRUSTED_DISPLAY permission", a signature|role permission; and VirtualDeviceManager.createVirtualDevice fails with "Access denied, requires: android.permission.CREATE_VIRTUAL_DEVICE", which is internal|role. See the README for the whole transcript. So on Android an XR ribbon carries the phone's real screen, and the application's own content, and that is all.

The split, and why there is a Java host

Android hands no drawable surface, and no MediaProjection, to a process that is not the app: both are behind JNI, and JNI needs cgo. So the app is two processes, exactly as in go-widgets/android — a small Java host owning the Android objects, and an ordinary CGO_ENABLED=0 GOOS=android executable owning everything above the pixels. This package is the application half.

The hot path

Stream.Frame hands back a BORROWED view of the most recent frame: the bytes are the shared mapping the host wrote into, not a copy, and the borrow lasts until the next Stream.Frame, Stream.WaitFrame or Stream.Close. In steady state a Frame call performs no allocation at all. Copy out with Frame.CopyTight or Frame.NRGBA to keep it longer.

Stride

A captured frame's rows may be PADDED: Stride is the number of BYTES per row and it is not necessarily Width*4. Always index with Stride, or use Frame.Row. The Android 15 emulator measured here reported stride 4320 for a 1080-wide capture — exactly Width*4, no padding — which is precisely why the API carries the number instead of trusting it.

Frames only arrive when something changes

A MediaProjection capture is change-driven, like ScreenCaptureKit on macOS. Options.FPS is a CEILING, not a rate: with a still screen, this package's own probe took 39 frames in 4.0 s and then nothing at all; with the screen scrolling it took 574 frames in 11.9 s. Do not treat a missing frame as a failure; the second return value of Stream.Frame is the truth about whether anything changed.

Every capture session needs the user to agree through a system dialog, and the agreement dies with the session. Authorized reports whether the host already holds a projection token; RequestAuthorization asks the host to put the dialog in front of the user and blocks until they answer.

Index

Constants

View Source
const (
	// MsgDisplays answers MsgListDisplays with everything the host can see.
	MsgDisplays uint8 = 0x01
	// MsgConfig announces a started capture: the frame geometry, the pixel
	// format and the layout of the shared buffer the frames will appear in.
	MsgConfig uint8 = 0x02
	// MsgFrame says a frame has been written into one slot of the shared
	// buffer. It carries no pixels: the pixels are already there.
	MsgFrame uint8 = 0x03
	// MsgStopped says the capture ended, and why. The user revoking the
	// projection from the system UI arrives here, not as an error on a call.
	MsgStopped uint8 = 0x04
	// MsgConsent answers MsgConsentRequest with the user's decision.
	MsgConsent uint8 = 0x05
	// MsgError reports a failure the host could not answer in-band.
	MsgError uint8 = 0x06
	// MsgBuffer hands the application the shared frame buffer, as an ancillary
	// descriptor, once a capture has been configured. It is the ONE message
	// carrying a descriptor, so it is always written in a single sendmsg.
	//
	// The buffer belongs to the HOST, not to the application, and that is
	// forced rather than chosen: an Android app cannot map a descriptor it
	// received read-write. SharedMemory.fromFileDescriptor rejects a Go memfd
	// outright ("FileDescriptor is not a valid ashmem fd"), and reopening it
	// through /proc/self/fd is refused with EACCES. Only a region the host
	// itself created with SharedMemory.create is writable by the host, so the
	// host creates it and lends it here.
	MsgBuffer uint8 = 0x07

	// MsgListDisplays asks the host what displays exist.
	MsgListDisplays uint8 = 0x81
	// MsgConsentRequest asks the host to put the screen-capture consent dialog
	// in front of the user. Only the host can: consent is an Activity result,
	// and the application owns no Activity.
	MsgConsentRequest uint8 = 0x82
	// MsgStart asks the host to begin capturing.
	MsgStart uint8 = 0x83
	// MsgStop asks the host to end the capture but stay connected.
	MsgStop uint8 = 0x84
	// MsgBye tells the host the application is going away.
	MsgBye uint8 = 0x85
	// MsgOpenDisplay asks the wall host to create ONE virtual display, show a
	// Presentation carrying the named content on it, and stream the pixels
	// Android renders there. The host answers with MsgConfig — whose DisplayID
	// carries the id the platform assigned — and then MsgBuffer, exactly as
	// MsgStart does, so one Stream implementation serves both.
	MsgOpenDisplay uint8 = 0x86
)

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

View Source
const (
	// StopUser is the user revoking the projection — from the cast chip in the
	// status bar, or from the notification the foreground service must show.
	// It is the normal way a capture ends and is NOT a malfunction.
	StopUser uint8 = 0
	// StopSystem is the platform stopping us: a new projection started
	// elsewhere, the device locked, the service was killed.
	StopSystem uint8 = 1
	// StopApp is this application having asked, with MsgStop or MsgBye.
	StopApp uint8 = 2
)

Reasons a capture stopped, carried by StoppedMessage.

View Source
const (
	FlagSupportsProtectedBuffers uint32 = 1 << 0
	FlagSecure                   uint32 = 1 << 1
	FlagPrivate                  uint32 = 1 << 2
	FlagPresentation             uint32 = 1 << 3
	FlagRound                    uint32 = 1 << 4
)

Display flags, mirroring android.view.Display's public FLAG_* constants. The two that matter to an XR application are FlagPresentation, which says the system considers a display suitable for an app's own full-screen content, and FlagPrivate, which says it belongs to one app.

View Source
const (
	// DefaultFPS is the frame-rate ceiling used when Options.FPS is zero.
	DefaultFPS = 60.0
	// DefaultQueueDepth is the slot count used when Options.QueueDepth is zero.
	DefaultQueueDepth = 3
	// MinQueueDepth is the smallest slot count this package accepts.
	MinQueueDepth = 3
	// MaxQueueDepth bounds the slot count. A slot is a whole framebuffer —
	// 10.4 MB for a 1080x2400 phone — so this is a real memory limit, not a
	// formality.
	MaxQueueDepth = 8
	// MaxDimension is the largest frame edge accepted, a sanity bound well
	// above any real display.
	MaxDimension = 32768
)

Defaults applied to the zero value of the corresponding Options field.

View Source
const (
	// DefaultMaxDisplays is the limit [NewWall] applies when it is given 0. A
	// ribbon uses six to eight panels, so this is already generous.
	DefaultMaxDisplays = 8

	// MaxDisplays is the largest limit [NewWall] accepts, however explicitly a
	// caller asks. It is nowhere near the count that killed system_server, and
	// the Java host refuses past it too.
	MaxDisplays = 32

	// DefaultDensityDPI is the density given to an owned display whose spec
	// leaves DensityDPI at zero. It is Android's DENSITY_XHIGH, the density a
	// 1920x1080 panel of the size XR glasses present usually reports.
	DefaultDensityDPI = 320

	// MinDisplayEdge is the smallest edge an owned display may have. A display
	// smaller than this cannot lay a view hierarchy out at all.
	MinDisplayEdge = 16
)

Limits on how many displays one application may create.

View Source
const (
	SentinelBackground  uint32 = 0xFF0000 // red
	SentinelTopLeft     uint32 = 0x00FF00 // green
	SentinelBottomRight uint32 = 0x0000FF // blue
)

The colours Sentinel paints, as packed 0xRRGGBB.

View Source
const DefaultDisplayID = 0

DefaultDisplayID is android.view.Display.DEFAULT_DISPLAY.

View Source
const EnvSocket = "XR_ANDROID_SOCKET"

EnvSocket names the environment variable carrying the abstract socket the XR host listens on. A host that spawns this process sets it.

It is not the only route, and in the composition this package is really for it is not the route taken: the process is spawned by go-widgets/android's Activity, which owns the drawing surface, knows nothing about capture and sets no such variable. DeriveSocket covers that case from HOME.

View Source
const EnvWallSocket = "XR_ANDROID_WALL_SOCKET"

EnvWallSocket names the environment variable carrying the wall host's socket, the counterpart of EnvSocket.

View Source
const MaxPayload = 1 << 16

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

View Source
const SocketSuffix = ".xr"

SocketSuffix is appended to the application's package name to form the abstract socket the host listens on when nothing names one explicitly. It matches XrHostService.SOCKET_SUFFIX.

View Source
const WallSocketSuffix = ".xrwall"

WallSocketSuffix is appended to the package name to get the abstract socket the WALL host listens on, matching XrWallService.SOCKET_SUFFIX.

It is a second socket, and a second service, because the two have nothing in common but a wire format. Capture needs a MediaProjection, a consent dialog and — from API 34 — a mediaProjection foreground service, none of which an owned display needs: it asks for no permission at all. Keeping them apart means creating a ribbon panel cannot put a "recording your screen" chip in the status bar, and that a capture session ending cannot take a panel with it. The capture host also serves exactly one connection, whereas the wall serves one PER DISPLAY.

Variables

View Source
var (
	// ErrUnsupported is reported on every platform that is not Android, and on
	// Android when the process was not started by a host that speaks this
	// protocol. The module still builds and vets everywhere, so an application
	// keeps cross-building on a developer's workstation.
	ErrUnsupported = errors.New("android: screen capture is unavailable " +
		"(not running under a go-xrkit Android host)")

	// ErrPermissionDenied is reported when the user refused the screen-capture
	// consent dialog, or revoked a projection already running. Its message
	// names the remedy.
	ErrPermissionDenied = errors.New("android: screen-capture consent denied — " +
		"Android asks the user for permission once per capture session and the " +
		"answer cannot be remembered; call RequestAuthorization again and accept " +
		"the system dialog")

	// ErrNoDisplay is reported when a capture was asked for and the host
	// listed no display at all.
	ErrNoDisplay = errors.New("android: no capturable display")

	// ErrNotFound is reported when a display ID does not name anything the
	// host can see.
	ErrNotFound = errors.New("android: no such display")

	// ErrNotCapturable is reported for a display that exists but that an
	// unprivileged app may not capture. MediaProjection mirrors the DEFAULT
	// display and nothing else: capturing a second display needs
	// CAPTURE_VIDEO_OUTPUT, which is a signature permission.
	ErrNotCapturable = errors.New("android: only the default display can be captured " +
		"by an unprivileged app (capturing another display needs the signature " +
		"permission CAPTURE_VIDEO_OUTPUT)")

	// ErrClosed is reported by every [Stream] method after [Stream.Close].
	ErrClosed = errors.New("android: stream is closed")

	// ErrNoFrame is reported by [Stream.WaitFrame] when no frame arrived
	// before its context expired. It is NOT a malfunction: a motionless screen
	// legitimately produces no frames.
	ErrNoFrame = errors.New("android: no frame available")

	// ErrInvalidOption is reported by [Options.Validate] and wraps a
	// description of the offending field.
	ErrInvalidOption = errors.New("android: invalid option")

	// ErrShortBuffer is reported by [Frame.CopyTight] when the destination is
	// too small to hold the frame.
	ErrShortBuffer = errors.New("android: destination buffer too short")
)

Sentinel errors. All are stable and may be matched with errors.Is.

View Source
var ErrBadPayload = errors.New("android: malformed message payload")

ErrBadPayload reports a message whose body is structurally impossible — a string longer than the body that holds it, a negative count.

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 ErrTooManyDisplays = errors.New("android: too many virtual displays")

ErrTooManyDisplays is reported by NewWall when the requested limit exceeds MaxDisplays, and by Wall.Open when the wall is already at its limit. Nothing is created in either case.

View Source
var ErrWallClosed = errors.New("android: wall is closed")

ErrWallClosed is reported by Wall.Open after Wall.Close.

Functions

func Authorized

func Authorized() bool

Authorized reports whether the host already holds a projection token, so a capture can start without a dialog. It never prompts.

Unlike a macOS TCC grant this is NOT a lasting permission: Android issues a token per session and forgets it, so this answers "right now", not "ever".

func Available

func Available() bool

Available reports whether this process is running under a host that speaks this protocol. It consults the environment only: it opens nothing and blocks on nothing, so it is safe to call before deciding whether Android capture is this program's platform at all.

func DecodeBuffer

func DecodeBuffer(b []byte) (slots int, slotSize int64, err error)

DecodeBuffer decodes the layout of the shared buffer.

func DecodeConsent

func DecodeConsent(b []byte) (bool, error)

DecodeConsent decodes the user's decision.

func DeriveSocket

func DeriveSocket(env string, envOK bool, home string) string

DeriveSocket works out which abstract socket to dial.

An explicit name wins: a host that spawned this process sets EnvSocket and that is the end of it. But in the composition this package is really for, the process is spawned by a DIFFERENT host — go-widgets/android's Activity, which owns the drawing surface and knows nothing about capture — and never sets it. Android gives such a process no way to ask its own package name either.

What it does give it is HOME, which that host sets to the app's private files directory: "/data/user/0/org.example.app/files". The package name is in there, and the host's socket name follows from it. That is why the two hosts can live in one APK without either one having to know about the other.

It returns "" when neither route yields a name.

func DeriveWallSocket added in v0.2.0

func DeriveWallSocket(env string, envOK bool, home string) string

DeriveWallSocket works out which abstract socket the wall host listens on, exactly as DeriveSocket does for the capture host.

func EncodeBuffer

func EncodeBuffer(slots int, slotSize int64) []byte

EncodeBuffer encodes the layout of the shared buffer accompanying MsgBuffer.

func EncodeConfig

func EncodeConfig(c ConfigMessage) []byte

EncodeConfig encodes a ConfigMessage.

func EncodeConsent

func EncodeConsent(granted bool) []byte

EncodeConsent encodes the user's decision.

func EncodeDisplays

func EncodeDisplays(ds []Display) []byte

EncodeDisplays encodes a display list.

func EncodeError

func EncodeError(e ErrorMessage) []byte

EncodeError encodes an ErrorMessage.

func EncodeFrame

func EncodeFrame(f FrameMsg) []byte

EncodeFrame encodes a FrameMsg.

func EncodeOpenDisplay added in v0.2.0

func EncodeOpenDisplay(m OpenDisplayMessage) []byte

EncodeOpenDisplay encodes an OpenDisplayMessage.

func EncodeStart

func EncodeStart(s StartMessage) []byte

EncodeStart encodes a StartMessage.

func EncodeStopped

func EncodeStopped(s StoppedMessage) []byte

EncodeStopped encodes a StoppedMessage.

func FrameMessage

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.

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 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 RequestAuthorization

func RequestAuthorization(ctx context.Context) (bool, error)

RequestAuthorization asks the host to put Android's screen-capture consent dialog in front of the user, and blocks until they answer or ctx expires.

It is a separate call rather than something CaptureDisplay does implicitly because the dialog takes over the screen: an application wants to choose when that happens, and to say why first.

func SentinelColorAt added in v0.2.0

func SentinelColorAt(w, h, x, y int) (uint32, bool)

SentinelColorAt is the colour a correct Sentinel frame holds at (x, y) on a display of the given size, as 0xRRGGBB. It reports false for a point outside the display, or one close enough to a quadrant edge or to the label that anti-aliasing makes the exact value unsafe to assert on.

Sample with it rather than comparing whole images: the label is rendered by Android's own text stack and will not be byte-identical between releases, while the quadrants are flat colour and are.

func WallAvailable added in v0.2.0

func WallAvailable() bool

WallAvailable reports whether this process is running under a host that serves owned displays. It dials nothing.

func WriteMessage

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

WriteMessage writes one framed message.

Types

type ConfigMessage

type ConfigMessage struct {
	// Width and Height are the frame size in pixels.
	Width, Height int
	// Stride is the number of BYTES per row in the shared buffer. It is the
	// host's ImageReader row stride and is NOT necessarily Width*4.
	Stride int
	// Format is the pixel layout, always [FormatRGBA] today.
	Format PixelFormat
	// Slots is how many frame slots the shared buffer holds.
	Slots int
	// SlotSize is the byte size of one slot, Stride*Height rounded up to the
	// host's alignment.
	SlotSize int64
	// DisplayID names the display being captured.
	DisplayID int
}

ConfigMessage announces a started capture.

func DecodeConfig

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

DecodeConfig decodes a ConfigMessage.

type Content added in v0.2.0

type Content interface {
	// Describe renders the content for logs.
	Describe() string
	// Validate reports whether the content is usable, wrapping
	// [ErrInvalidOption].
	Validate() error
	// contains filtered or unexported methods
}

Content is what Android renders on an owned display.

The seam, and why it is a DECLARATION rather than pixels

The obvious seam would hand the host a framebuffer for it to blit. That is the wrong way round here, and would be a round trip with no product: pixels this process drew, sent to Android, read back unchanged. If the application is drawing, it should composite in Go and skip the display entirely.

The reason to route a ribbon panel through a real Android display is the opposite one — to get at what Android renders and this process CANNOT: a WebView, a MediaCodec surface, a PdfRenderer, a maps view. None of those can be reached from a CGO-free Go process, all of them are ordinary Views, and a Presentation is how a View gets onto a display. So Content NAMES what should render, the Java host builds it, and the pixels come back through the same borrowed-frame path as a screen capture.

That also keeps the rule the rest of this package is built on: the host owns the Android objects and decides nothing. A URL is a decision the application made; a WebView is the host obeying it.

The set is deliberately closed — a Content implemented outside this package would name a Java class the host does not have. Implementations are Web and Sentinel.

type Display

type Display struct {
	// ID is the android.view.Display display id. 0 is the built-in screen.
	ID int
	// Name is what the system calls the display. For the built-in panel this
	// is a device string ("Built-in Screen" on the Android 15 emulator); for a
	// display attached over USB-C DP Alt Mode it comes from the sink's EDID,
	// which is how XR glasses announce their model.
	Name string
	// Width and Height are the display's real size in pixels.
	Width, Height int
	// DensityDPI is the display's density in dots per inch.
	DensityDPI int
	// RefreshRate is the active mode's rate in hertz.
	RefreshRate float64
	// Flags are android.view.Display's FLAG_* bits; see [FlagPresentation].
	Flags uint32
}

Display is a display the host can see.

Width and Height are in PIXELS — Android reports no separate point space, so unlike the macOS sibling there is only one pair of numbers, and DensityDPI carries what a scale factor would carry elsewhere.

func DecodeDisplays

func DecodeDisplays(b []byte) ([]Display, error)

DecodeDisplays decodes a display list.

func DefaultDisplay

func DefaultDisplay(ctx context.Context) (Display, error)

DefaultDisplay returns the built-in screen, the only display an unprivileged app may capture.

func Displays

func Displays(ctx context.Context) ([]Display, error)

Displays returns every display the host can see.

Only the default one can be captured (see ErrNotCapturable), but the others matter all the same: a headset attached over USB-C DP Alt Mode appears here, and its Display.Name is how a catalogue identifies the model.

func (Display) Default

func (d Display) Default() bool

Default reports whether this is the built-in screen, the only display an unprivileged app may capture.

func (Display) Presentation

func (d Display) Presentation() bool

Presentation reports whether the system considers this display suitable for an application's own full-screen content — which is what an XR headset attached over DP Alt Mode is for.

func (Display) String

func (d Display) String() string

String renders the display for logs.

type DisplaySpec added in v0.2.0

type DisplaySpec struct {
	// Width and Height are the display's size in PIXELS. Both are required.
	//
	// Making them small does NOT buy more displays — the count that kills
	// system_server was the same at 640x480 and at 1920x1080 — so choose the
	// size the content wants to be read at.
	Width, Height int

	// DensityDPI is the display's density. Zero means [DefaultDensityDPI].
	// Android lays the view hierarchy out against this, so it decides how
	// large text and controls come out in the ribbon.
	DensityDPI int

	// Content is what Android renders on the display. It is required; see
	// [Content].
	Content Content

	// QueueDepth is how many frame slots the shared buffer holds, exactly as
	// in [Options.QueueDepth]. Zero means [DefaultQueueDepth].
	QueueDepth int
}

DisplaySpec says what an owned display should be.

The zero DisplaySpec is not usable: a display has no natural size to fall back on the way a capture does, so Width, Height and Content must be given.

func (DisplaySpec) String added in v0.2.0

func (s DisplaySpec) String() string

String renders the spec for logs.

func (DisplaySpec) Validate added in v0.2.0

func (s DisplaySpec) Validate() error

Validate reports whether the spec describes a display Android could make, wrapping ErrInvalidOption. It does not consult the system, and it does not apply defaults: a zero DensityDPI or QueueDepth is refused here and filled in by Wall.Open before this is reached.

type ErrorMessage

type ErrorMessage struct {
	// Code is the host's own numeric code, 0 when it has none.
	Code int
	// Op names what failed, e.g. "getMediaProjection".
	Op string
	// Detail is the platform's message, e.g. a SecurityException's text.
	Detail string
}

ErrorMessage is a failure the host could not answer in-band.

func DecodeError

func DecodeError(b []byte) (ErrorMessage, error)

DecodeError decodes an ErrorMessage.

func (ErrorMessage) Error

func (e ErrorMessage) Error() string

Error renders the operation, the code and the platform's message.

type Frame

type Frame struct {
	// Pix is the frame's bytes in [FormatRGBA], Stride bytes per row, Height
	// rows. len(Pix) == Stride*Height.
	Pix []byte
	// Width and Height are the frame's size in pixels.
	Width, Height int
	// Stride is the number of BYTES per row. It may be padded and is NOT
	// necessarily Width*4.
	Stride int
	// Seq counts frames since the stream started; it is 0 before the first
	// frame and strictly increases afterwards.
	Seq uint64
	// At is when the host's ImageReader delivered the frame.
	At time.Time
}

Frame is a BORROWED view of one captured frame.

Pix aliases the shared mapping the host wrote into. It stays valid only until the next Stream.Frame, Stream.WaitFrame or Stream.Close on the stream that produced it. Do not retain it; copy with Frame.CopyTight or Frame.NRGBA if you need it to outlive the borrow.

func (Frame) CopyTight

func (f Frame) CopyTight(dst []byte) (int, error)

CopyTight copies the frame into dst with the row padding removed, so dst holds Width*4*Height bytes of contiguous RGBA. It reports how many bytes it wrote, or ErrShortBuffer if dst is too small. It allocates nothing.

func (Frame) NRGBA

func (f Frame) NRGBA() (*image.NRGBA, error)

NRGBA copies the frame into a freshly allocated image.NRGBA. It is the convenience path for saving a frame to disk; it allocates, so it does not belong in a per-frame loop.

Android's RGBA_8888 is already image.NRGBA's byte order, so unlike the macOS sibling this is a copy with no channel swap — but it is still a copy, because the source is padded and borrowed.

func (Frame) Row

func (f Frame) Row(y int) []byte

Row returns row y of the frame, Width*4 bytes with the padding trimmed off. It does not allocate. It returns nil for an out-of-range y or an invalid frame.

func (Frame) TightLen

func (f Frame) TightLen() int

TightLen is the number of bytes the frame occupies with no row padding, Width*4*Height.

func (Frame) Valid

func (f Frame) Valid() bool

Valid reports whether the frame holds pixels.

type FrameMsg

type FrameMsg struct {
	// Seq counts frames since the capture started, from 1.
	Seq uint64
	// Slot is which slot of the shared buffer holds it.
	Slot int
	// Width, Height and Stride describe THIS frame. They normally repeat the
	// config, but the host may resize a capture in flight — a phone rotating
	// under a capture does exactly that — so every frame carries its own
	// geometry rather than inheriting one that may be stale.
	Width, Height, Stride int
	// AtUnixNano is when the host's ImageReader delivered it.
	AtUnixNano int64
}

FrameMsg says a frame has been written into a slot of the shared buffer.

func DecodeFrame

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

DecodeFrame decodes a FrameMsg.

func (FrameMsg) At

func (f FrameMsg) At() time.Time

At renders the delivery instant. A zero AtUnixNano yields the zero Time rather than the epoch, so "the host did not say" stays distinguishable.

type OpenDisplayMessage added in v0.2.0

type OpenDisplayMessage struct {
	// Width and Height are the display's size in pixels.
	Width, Height int
	// DensityDPI is the density Android lays the view hierarchy out against.
	DensityDPI int
	// Slots is how many frame slots the shared buffer holds.
	Slots int
	// ContentKind names the View the host should build; see [Content].
	ContentKind uint32
	// ContentPayload is that content's one string argument — a URL for a
	// WebView, a label for the sentinel.
	ContentPayload string
}

OpenDisplayMessage asks the wall host for one owned display.

func DecodeOpenDisplay added in v0.2.0

func DecodeOpenDisplay(b []byte) (OpenDisplayMessage, error)

DecodeOpenDisplay decodes an OpenDisplayMessage.

type Options

type Options struct {
	// Width and Height are the requested frame size in PIXELS. Zero means the
	// display's native size.
	Width, Height int

	// FPS is the CEILING on the frame rate, not a guarantee: a MediaProjection
	// only produces a frame when the content changed. Zero means [DefaultFPS].
	FPS float64

	// QueueDepth is how many frame slots the shared buffer holds. Zero means
	// [DefaultQueueDepth]. It must leave room for the frame lent out and the
	// one being written, so values below [MinQueueDepth] are rejected — with
	// two slots a host that produced two frames while the consumer held one
	// would overwrite the borrow.
	QueueDepth int
}

Options configures a capture stream.

The zero Options is usable: it captures the default display at its native pixel size, at up to 60 frames per second.

func (Options) Validate

func (o Options) Validate() error

Validate reports whether the options are self-consistent, wrapping ErrInvalidOption. It does not consult the system.

type OwnedDisplay added in v0.2.0

type OwnedDisplay struct {
	*Stream
	// contains filtered or unexported fields
}

OwnedDisplay is one display this application created, and the feed of pixels Android rendered on it.

It embeds Stream, so a ribbon consumes it exactly as it consumes a screen capture — Stream.Frame lends borrowed pixels with the stride carried and says whether they are fresh, Stream.WaitFrame blocks for the next one. That is the point: the ribbon does not care whether a panel is the mirrored phone or a display we made.

func (*OwnedDisplay) Close added in v0.2.0

func (d *OwnedDisplay) Close() error

Close releases the display and its feed, and gives the slot back to the wall. It is idempotent.

func (*OwnedDisplay) ID added in v0.2.0

func (d *OwnedDisplay) ID() int

ID is the android.view.Display id the platform gave this display. It is only meaningful for logs and for matching against `adb shell dumpsys display`; no other application may do anything with it.

func (*OwnedDisplay) Spec added in v0.2.0

func (d *OwnedDisplay) Spec() DisplaySpec

Spec is the specification this display was opened with, with its zero fields filled in.

func (*OwnedDisplay) String added in v0.2.0

func (d *OwnedDisplay) String() string

String renders the display for logs.

type PixelFormat

type PixelFormat uint32

PixelFormat names the layout of a captured frame, using Android's own android.graphics.PixelFormat constants so the value on the wire is the one the host read off the ImageReader.

const FormatRGBA PixelFormat = 1 // android.graphics.PixelFormat.RGBA_8888

FormatRGBA is 32-bit RGBA, android.graphics.PixelFormat.RGBA_8888. It is the only format this package streams, and it is what ImageReader produces for a MediaProjection mirror. It is also byte-for-byte what a go-widgets painter writes, so a captured frame composites with no conversion.

This differs from the sibling packages on other platforms: macOS ScreenCaptureKit hands back BGRA. Frame.NRGBA hides the difference, and anything compositing raw bytes must ask Stream.Format.

func (PixelFormat) BytesPerPixel

func (f PixelFormat) BytesPerPixel() int

BytesPerPixel is the size of one pixel in this format, 0 for a format this package does not stream.

func (PixelFormat) String

func (f PixelFormat) String() string

String renders the format for logs.

type Sentinel added in v0.2.0

type Sentinel struct {
	// Label is drawn across the middle in white. An empty label draws none.
	Label string
}

Sentinel renders quadrants of exactly known colour with a label across them: green top-left, blue bottom-right, red elsewhere, white text.

It is this package's SELF-TEST content, and it is exported because a consumer needs it too. A feed that "works" and delivers a black buffer is the classic silent failure of this whole mechanism, and the only way to catch it is to draw something whose pixels are known in advance and then SAMPLE them rather than look at them. SentinelColorAt says what a correct frame must hold at a given point, and that is what this package's live proof asserts on.

func (Sentinel) Describe added in v0.2.0

func (s Sentinel) Describe() string

Describe renders the content for logs.

func (Sentinel) Validate added in v0.2.0

func (s Sentinel) Validate() error

Validate reports whether the label will fit a message.

type StartMessage

type StartMessage struct {
	// DisplayID is the display to capture. Only the default display can be
	// captured by an unprivileged app; see [ErrNotCapturable].
	DisplayID int
	// Width and Height are the requested frame size in pixels; zero means the
	// display's native size.
	Width, Height int
	// MilliFPS is the frame-rate ceiling in thousandths of a frame per second,
	// so 60 fps is 60000 and a rate below one frame a second is still exact.
	MilliFPS uint32
	// Slots is how many frame slots the shared buffer holds.
	Slots int
}

StartMessage asks the host to begin capturing.

func DecodeStart

func DecodeStart(b []byte) (StartMessage, error)

DecodeStart decodes a StartMessage.

type Stats

type Stats struct {
	// Frames is the number of frames the host delivered.
	Frames uint64
	// Superseded is the number of delivered frames replaced by a newer one
	// before the consumer ever asked for them. A large value next to Frames
	// means the consumer is slower than the capture.
	Superseded uint64
	// Last is when the most recent frame arrived.
	Last time.Time
	// Interval is the gap between the two most recent frames.
	Interval time.Duration
}

Stats reports what a stream has seen since it started.

func (Stats) FPS

func (s Stats) FPS() float64

FPS is the instantaneous rate implied by Stats.Interval, 0 when fewer than two frames have arrived.

type StoppedMessage

type StoppedMessage struct {
	// Reason is one of [StopUser], [StopSystem] or [StopApp].
	Reason uint8
	// Detail is the host's own wording, which may be empty.
	Detail string
}

StoppedMessage says a capture ended and why.

func DecodeStopped

func DecodeStopped(b []byte) (StoppedMessage, error)

DecodeStopped decodes a StoppedMessage.

func (StoppedMessage) String

func (s StoppedMessage) String() string

String renders the reason and the detail for a log or an error.

type Stream

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

Stream is a running capture.

func CaptureDisplay

func CaptureDisplay(ctx context.Context, d Display, o Options) (*Stream, error)

CaptureDisplay starts streaming a display.

The user must already have consented — see RequestAuthorization — and only the default display can be captured. The returned stream must be closed.

func (*Stream) Close

func (st *Stream) Close() error

Close ends the capture and releases the shared mapping. It is idempotent, and every borrowed frame is invalid afterwards.

func (*Stream) Err

func (st *Stream) Err() error

Err reports why the SYSTEM stopped the capture, nil while it is running. The user revoking the projection arrives here wrapping ErrPermissionDenied.

func (*Stream) Format

func (st *Stream) Format() PixelFormat

Format returns the pixel layout of every frame this stream produces.

func (*Stream) Frame

func (st *Stream) Frame() (Frame, bool)

Frame returns the most recent frame and whether it is newer than the one the previous call returned.

The bytes are BORROWED from the shared mapping and stay valid until the next Frame, WaitFrame or Close. In steady state this allocates nothing.

func (*Stream) Options

func (st *Stream) Options() Options

Options returns the options actually in force, with every zero field resolved.

func (*Stream) Size

func (st *Stream) Size() (int, int)

Size returns the capture's frame size in pixels.

func (*Stream) Stats

func (st *Stream) Stats() Stats

Stats reports what the stream has seen since it started.

func (*Stream) String

func (st *Stream) String() string

String renders the stream for logs.

func (*Stream) WaitFrame

func (st *Stream) WaitFrame(ctx context.Context) (Frame, error)

WaitFrame blocks until a frame newer than the last one returned arrives, or ctx expires. It reports ErrNoFrame on expiry, which on a motionless screen is the ordinary answer rather than a malfunction.

type Wall added in v0.2.0

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

A Wall is a set of displays this application created and owns, and the limit on how many of them may exist at once.

Why an application may have these at all

An ordinary APK may not manufacture desktops for OTHER people's applications: launching an activity onto a display the app created fails with a SecurityException even for the app's own activity, and the flag that would lift it, VIRTUAL_DISPLAY_FLAG_TRUSTED, needs a signature permission. That half of the refusal is real and is not going anywhere.

It may, however, create a virtual display and show a [Presentation] on it — a Dialog attached to a Display, which is not an activity start and so does not meet the check that refuses the other thing. Measured on Android 15 (API 35, arm64):

Q1 ANSWER: Presentation.show() SUCCEEDED on the app's OWN virtual display 3
Q2 frame 640x480 ... black=0/307200 topLeft=#FF00FF00 bottomRight=#FF0000FF

No permission, no consent dialog and no MediaProjection are involved, which is why a Wall needs none of RequestAuthorization's machinery. So an XR ribbon on Android carries the mirrored phone — see CaptureDisplay — AND as many screens of Android-rendered content as the application asks for.

THE LIMIT IS THE SAFETY, AND IT IS NOT A FORMALITY

On the emulator this package was measured against, creating the 304th virtual display did not fail: it killed system_server with android.view.Surface$OutOfResourcesException: NO_MEMORY out of SurfaceControl.nativeCreate, and the device soft-rebooted. An unprivileged APK with no permissions at all did that to the machine it was running on.

READ THIS BEFORE RAISING THE LIMIT: the ceiling was the SAME COUNT at 640x480 and at 1920x1080 — nine times the pixels, same number, with the app's Java heap at 14 MiB of 192. It is SurfaceControl handles, not graphics memory. So MAKING THE SCREENS SMALLER DOES NOT HELP, which is exactly the mitigation somebody reaches for. 304 is one emulator's measurement and is deliberately not a constant here; a real device's number is unknown and could be lower.

A Wall therefore refuses past its limit with ErrTooManyDisplays instead of asking the platform and hoping. DefaultMaxDisplays is 8, which is more than a ribbon uses; MaxDisplays is 32, which is the most this package will let a caller ask for however explicit they are about wanting it. The Java host enforces the same 32 independently, so a second process cannot get past it either.

A Wall is safe for concurrent use.

func NewWall added in v0.2.0

func NewWall(max int) (*Wall, error)

NewWall returns a wall that will hold at most max displays at once. A max of 0 means DefaultMaxDisplays; a negative max, or one above MaxDisplays, is refused with ErrTooManyDisplays and no wall is returned.

NewWall talks to nothing and can fail for no other reason, so a caller may build one before knowing whether a host is there.

func (*Wall) Close added in v0.2.0

func (w *Wall) Close() error

Close releases every display the wall still holds and refuses further Wall.Open calls. It is idempotent, and reports the first failure a display reported while still closing the rest.

func (*Wall) Len added in v0.2.0

func (w *Wall) Len() int

Len is how many displays the wall holds open right now.

func (*Wall) Max added in v0.2.0

func (w *Wall) Max() int

Max is the limit this wall was built with.

func (*Wall) Open added in v0.2.0

func (w *Wall) Open(ctx context.Context, spec DisplaySpec) (*OwnedDisplay, error)

Open creates one display, shows a Presentation carrying spec.Content on it, and returns a feed of the pixels Android rendered there.

It refuses with ErrTooManyDisplays when the wall is already full, and it refuses BEFORE asking the host, so a full wall costs no round trip and creates nothing. It refuses with ErrWallClosed after Wall.Close, and wraps ErrInvalidOption for a spec that cannot describe a display.

type Web added in v0.2.0

type Web struct {
	// URL is what the WebView loads. http, https and data URLs are accepted.
	URL string
}

Web renders a WebView on the display.

It is the content that justifies the whole mechanism: a CGO-free Go process cannot host a web engine, and a ribbon of web panels is a real product that falls straight out of a Presentation on an owned display.

The host's WebView has JavaScript enabled and no file access. Reaching a http(s) URL needs android.permission.INTERNET in the APK, which is protectionLevel normal; a data: URL needs nothing and is what this package's own tests use.

func (Web) Describe added in v0.2.0

func (w Web) Describe() string

Describe renders the content for logs.

func (Web) Validate added in v0.2.0

func (w Web) Validate() error

Validate reports whether the URL is one the host will load.

Directories

Path Synopsis
cmd
xrcapture command
Command xrcapture is the on-device proof for github.com/go-xrkit/android: an ordinary CGO_ENABLED=0 GOOS=android binary that asks the host for consent, captures the screen, and reports what it actually got.
Command xrcapture is the on-device proof for github.com/go-xrkit/android: an ordinary CGO_ENABLED=0 GOOS=android binary that asks the host for consent, captures the screen, and reports what it actually got.
xrwall command
Command xrwall proves, on a real device, that this application can create displays of its own, render Android content on them and read the pixels back — and that the limit that keeps it from rebooting the phone actually refuses.
Command xrwall proves, on a real device, that this application can create displays of its own, render Android content on them and read the pixels back — and that the limit that keeps it from rebooting the phone actually refuses.

Jump to

Keyboard shortcuts

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