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.
Consent ¶
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
- Variables
- func Authorized() bool
- func Available() bool
- func DecodeBuffer(b []byte) (slots int, slotSize int64, err error)
- func DecodeConsent(b []byte) (bool, error)
- func DeriveSocket(env string, envOK bool, home string) string
- func EncodeBuffer(slots int, slotSize int64) []byte
- func EncodeConfig(c ConfigMessage) []byte
- func EncodeConsent(granted bool) []byte
- func EncodeDisplays(ds []Display) []byte
- func EncodeError(e ErrorMessage) []byte
- func EncodeFrame(f FrameMsg) []byte
- func EncodeStart(s StartMessage) []byte
- func EncodeStopped(s StoppedMessage) []byte
- func FrameMessage(typ uint8, body []byte) []byte
- func ReadMessage(r io.Reader) (typ uint8, body []byte, err error)
- func RequestAuthorization(ctx context.Context) (bool, error)
- func WriteMessage(w io.Writer, typ uint8, body []byte) error
- type ConfigMessage
- type Display
- type ErrorMessage
- type Frame
- type FrameMsg
- type Options
- type PixelFormat
- type StartMessage
- type Stats
- type StoppedMessage
- type Stream
- func (st *Stream) Close() error
- func (st *Stream) Err() error
- func (st *Stream) Format() PixelFormat
- func (st *Stream) Frame() (Frame, bool)
- func (st *Stream) Options() Options
- func (st *Stream) Size() (int, int)
- func (st *Stream) Stats() Stats
- func (st *Stream) String() string
- func (st *Stream) WaitFrame(ctx context.Context) (Frame, error)
Constants ¶
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 )
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.
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.
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.
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.
const DefaultDisplayID = 0
DefaultDisplayID is android.view.Display.DEFAULT_DISPLAY.
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.
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.
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.
Variables ¶
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.
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.
var ErrShortPayload = errors.New("android: truncated message payload")
ErrShortPayload reports a message whose body is too short for its type.
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 ¶
DecodeBuffer decodes the layout of the shared buffer.
func DecodeConsent ¶
DecodeConsent decodes the user's decision.
func DeriveSocket ¶
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 EncodeBuffer ¶
EncodeBuffer encodes the layout of the shared buffer accompanying MsgBuffer.
func EncodeConsent ¶
EncodeConsent encodes the user's decision.
func EncodeDisplays ¶
EncodeDisplays encodes a display list.
func EncodeStopped ¶
func EncodeStopped(s StoppedMessage) []byte
EncodeStopped encodes a StoppedMessage.
func FrameMessage ¶
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 ¶
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 ¶
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.
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 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 ¶
DecodeDisplays decodes a display list.
func DefaultDisplay ¶
DefaultDisplay returns the built-in screen, the only display an unprivileged app may capture.
func Displays ¶
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 ¶
Default reports whether this is the built-in screen, the only display an unprivileged app may capture.
func (Display) Presentation ¶
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.
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 ¶
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 ¶
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 ¶
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.
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.
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 ¶
Validate reports whether the options are self-consistent, wrapping ErrInvalidOption. It does not consult the system.
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 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 ¶
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 ¶
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 ¶
Close ends the capture and releases the shared mapping. It is idempotent, and every borrowed frame is invalid afterwards.
func (*Stream) Err ¶
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 ¶
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 ¶
Options returns the options actually in force, with every zero field resolved.
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. |