How the specification is implemented, and the decisions the spec left open. Protocol details are in protocol.md, the VM engine in engine.md.
| Process | Bundle | Role |
|---|---|---|
Avo |
Avo.app (com.avodroid.emulator) |
Control Panel. Registers the daemon with SMAppService, handles .apk/.xapk/.apkm/.apks opens and drops. |
AvoDaemon |
Avo.app/Contents/Library/Helpers/AvoDaemon.app (com.avodroid.daemon) |
Runtime Daemon (LaunchAgent, Mach service com.avodroid.daemon). Also hosts the menu bar item and posts notifications. |
avo-vmm |
Avo.app/Contents/MacOS/avo-vmm |
Engine VM, a child of the daemon (see engine.md). |
<App> |
~/Applications/Android Apps/<Label>.app |
App Shim, one per Android app; loads AvoWindowHost.framework from Avo.app. |
avoctl |
Avo.app/Contents/MacOS/avoctl |
Command line for scripts and CI. |
The daemon is an accessory app rather than a bare executable for two reasons: notifications must be posted by a bundle with an identity (the user sees "Avo"), and clicks on them are delivered to the posting process, which must be running — the daemon always is. The spec lists the Menu Bar Item as its own component; it lives in the same process because its state is the daemon's state and it should stay visible while Android runs in the background.
All Swift code shared between targets is one dynamic framework,
AvoHostKit.framework (every module of the AvoKit package). Linking the
package products separately produced two copies of the same classes in one
process, which breaks NSXPC class checks.
What Avo shares with Avo Hub is a package of its own,
avo_kit, a submodule at
vendor/avo_kit in both projects: AvoKeymap (profiles and the engine),
AvoKeymapUI (the key mapping a window runs: its controller, the editor and
its panels, the hints over the game and GamepadHub), AvoHazeUI (the haze
in small: QuickHaze, the Quick* controls of an app window's panels,
HazeMenu, and HazeAppearance, the System / Light / Dark choice), AvoInput (the Input channel's records) and AvoScreen (screen
samples and motion). The AvoKit package depends on it by path; its
AvoShared module re-exports the UI modules, and AvoProtocol and AvoKit
re-export AvoInput and AvoScreen, so they are linked into
AvoHostKit.framework once and Avo's code imports them as before. A window
gives the key mapping its game view (KeymapDisplay) and where to keep
profiles and Game Mode (KeymapStore; KeymapStore.avo is Avo's), in
KeymapHost.swift. The kit's text is in its own catalog
(Bundle.avoSharedKit, avo_kit_AvoKitResources.bundle in Avo.app); a
change there is committed in avo_kit, then the submodule moves to it.
The daemon vends one Mach service. Each connection declares a role in
hello (controlPanel, shim(package), engine) and every later call is
checked against it: shims may only open, drive and close displays for their
own package; only the Control Panel (and avoctl) may change settings or
install. Identity checks (spec: "validasi code signature pemanggil"):
- Release builds (signed with a Developer ID team): the listener's code signing requirement admits Avo's team plus the code directory hash of every shim the daemon created. A shim's connection is then pinned to its own hash.
- Development builds (ad hoc): the caller must be signed and live inside the Avo.app bundle the daemon runs from; shims are still pinned by hash.
App Shims are re-signed ad hoc after their Info.plist and icon are written (editing a signed bundle breaks its signature). Their hashes are kept in the app registry. A shim that is not in the registry is rejected — verified with a hand-made shim against the running daemon.
The daemon is a separate process, so it does not inherit the access macOS
gives the Control Panel (or a shim) to a file the user double-clicked, dropped
or picked from Downloads/Documents; reading the path itself triggers a privacy
prompt for the daemon. Clients therefore open the file and pass the
descriptor over XPC (install(_:file:), shareFiles(_:files:names:)); the
daemon reads the package through that descriptor.
- The Control Panel inspects the file locally (
PackageArchive) and shows name, version, size, notable permissions and blocking problems: native code withoutarm64-v8a(Apple Silicon cannot run 32-bit ARM or x86) or aminSdkVersionabove the image's. - On confirmation it sends the open file to the daemon, which re-inspects it,
starts Android if needed, and streams the selected APKs through
pm install-create / install-write / install-commitover ADB. ABI splits for other ABIs are skipped. Streams useChunkPipe, a bounded queue between the ZIP inflater and the ADB writer, so a multi-gigabyte XAPK never sits in memory. - XAPK OBB files go to
/sdcard/Android/obb/<package>/with ADB sync. - Android's
PACKAGE_ADDEDreaches the daemon asAppInstalled; that event (not the installer) creates the shim, so apps installed from inside Android get shims too.
Formats are detected from content, not the extension: AndroidManifest.xml at
the root (APK), manifest.json (XAPK), info.json (APKM), or APKs (APKS; for
bundletool sets only splits/ is used). Labels and icons are resolved from
resources.arsc, including sparse and compact (Android 14+) tables; icons in
split bundles are looked up across density splits.
ShimBuildercopies the template bundle, writesCFBundleIdentifier(com.avodroid.shim.<package>,_→-), the label, version andAvoAndroidPackage, swaps the bundle into place atomically (renamex_np(RENAME_SWAP)), signs it ad hoc and registers it with LaunchServices. Same label for two apps: the second becomesLabel (package).app. A label change renames the bundle.- Icons: the Bridge Agent renders adaptive icon layers at 432 px (108 dp);
MacIconRenderermaps the 72 dp safe zone onto Apple's 824 pt continuous-corner squircle on a 1024 canvas with the standard shadow, and writes every.icnssize with ImageIO. Opaque square legacy icons are masked to the squircle; others sit on a white plate. - Desktop alias (on by default) and Dock tile (off by default) follow the Control Panel options and are removed on uninstall. Aliases never replace a real file.
- Dock badges (Settings › System › Notifications, on by default): the daemon
keeps the notifications Android shows (
activeNotifications, the same ones the Notifications list takes: no ongoing, foreground-service or group summary ones, none without text) and sends each app's App ShimsnotificationCountwhen it changes and when they connect. The shim setsNSApp.dockTile.badgeLabel. A shim runs only while the app has a window, so an app that is not open shows no badge. - Notification buttons: an Android notification's actions come with it
(
Notification.actions, NotificationAction) and become a Notification Center category per set of buttons (UserNotificationPresenter, at most 64 kept): a button that takes text (a free-form RemoteInput, Reply) is a text field with Send, the others plain buttons. Pressing one sendsPerformNotificationAction; Android fires the button's PendingIntent as its shade does, with the text as the RemoteInput's result (SOURCE_FREE_FORM_INPUT). The app's update of an answered notification comes back quietly, as in Android. - Now Playing: what Android plays (the first playing media session, or else
the most recent one with a title) is in Control Center and on the media
keys and AirPods (
MacNowPlaying, MPNowPlayingInfoCenter and MPRemoteCommandCenter, in the daemon): title, artist, album, artwork, duration and position, and play, pause, next, previous and scrubbing when the app allows them. The commands go to that session itself (MediaCommand), not to whichever Android's own media keys would pick. Avo takes the commands only while something plays in Android, so with nothing playing the media keys stay with the Mac's apps.
Each shim window asks the daemon for a display sized from the window's
content and the app's graphics settings (see "Resolution and scaling"
below); at the default interface size 1 dp equals 1 pt, so Android apps
look the same size as Mac UI. Resizes are debounced to the end of a live
resize. Minimising or fully covering a window tells the guest to
detach the display's surface (the app keeps running, but stops rendering),
except while the window is being recorded, so the movie does not freeze.
Window frames are remembered per app. When the Android task finishes, the
window closes. Closing an app's last window (Close, ⌘W, Quit, or the shim
going away) ends the app like a force stop: the shim quits, and the daemon
force-stops the package before its display is unplugged
(DaemonCore.closeDisplay). Unplugged first, the app's task would move to
Android's own screen, where the task watch (checkDefaultDisplay) takes a
known app's visible task for a new start and opens its window again.
flutter run and Android Studio force-stop the app, install it and start it
again over adb, usually before the watch closed the window the stop left
empty: that window takes the new task (moveIntoEndedWindow), since its
shim would otherwise quit with it right after being asked for a new one. An
app adb starts right after installing it gets its window once the package
poll has found it.
Frames: the engine renders each display into a small swapchain of IOSurfaces,
hands them to the daemon once (engineSetSurfaces) and then only announces
which surface to show (enginePresent). The daemon forwards both to the shim
that owns the display; the shim wraps the IOSurfaces as Metal textures and
scales them into a CAMetalLayer (see "Resolution and scaling"). No frame
is copied or encoded on the CPU, except for screenshots and recordings.
avo-vmm, for now, writes frames to shared memory instead (triple buffered;
with the GPU renderer the GPU copies them there from gfxstream, on a thread
of the engine's own), and the daemon's
FrameBridge copies them into those IOSurfaces. A copy the engine wrote over
meanwhile is dropped, and a daemon that falls behind skips presents, never a
swapchain. A window nobody sees (minimized, covered, on another Space, and
not recorded: the shim's setDisplayVisible) gets no copies after its first
frame, which at 1080×2400 and 120 Hz saves over 1 GB/s; shown again, it gets
the newest frame at once.
Frames are presented as they arrive, at whatever rate Android draws, up to
120 Hz (engine.md, "Refresh rate"). While Android draws above 60 Hz, the
window's display view runs a CADisplayLink (FrameRateHint) whose
preferred frame rate range is Android's rate, at most its screen's
(FrameRates.presentPacing), so a ProMotion or adaptive screen does not
settle at 60 Hz. When Android draws faster than the screen refreshes (120
on a 75 Hz monitor), the link's ticks draw the newest frame once a refresh
instead (MetalRenderer.refreshed): frames replace each other rather than
queueing up behind the screen with their latency, and nothing draws faster
than the screen shows. The link pauses for hidden windows and static
screens.
Screenshots and screen recordings come from File › Take Screenshot (⌘⇧S) and File › Record Screen (⌘⇧R; again to stop), or the title bar's Device menu. Both take only what Android draws, at the display's full pixel size: no title bar, key mapping hints or recording indicator.
- A screenshot copies the frame on screen into a PNG in
~/Pictures/Avo. - A recording has the GPU draw each frame a second time, into the recorder's
own pixel buffers, and writes H.264 to a
.movin~/Movies/Avo. Each frame keeps the time it was presented, so the frame rate varies with what Android draws; pictures larger than 4096 × 2304 are scaled down. The movie is written in 2 s fragments, so a file that is cut off (a crash, a full disk) still plays up to its last fragment. The movie keeps its size when Android's display is re-plugged at another size of the same orientation (a resize, a graphics setting), fitted with black bars; it stops on a rotation and when the window closes. A red dot and timer in the title bar show it is running; in full screen, where the title bar hides, a small badge at the top right does. - Android's sound goes into the same movie as AAC (48 kHz stereo,
160 kbit/s) while Record Android's Sound is on (the Device and File
menus; on by default, for every app, in the Window Host's defaults). The
engine copies every period the Mac has played into a ring in shared memory
(
audio/playback.ringin the instance,virtio/sound/tap.rs) with the host time it played at; its stickyaudio playback <epoch> <path>event goes throughFrameBridgeto the daemon, which tells every App Shim (DaemonEvent.audioTap, also when one connects). The recording window maps the ring read-only (AudioTapReader) and reads it every 40 ms (RecordingAudio):AudioTimelineputs each block at its host time on the frames' clock, right after the one before it when they follow each other within 30 ms, and pads silence up to 0.25 s before now, so a movie of a silent app has a silent track and the writer never waits for sound. A stop waits that long for the last of it. It is all of Android's sound, not only this app's (Android mixes its apps into one output). An engine without the ring gives a video-only movie. With a sound track the newest frame is written again after a second without a new one, and the session ends before the tracks close, so the picture lasts as long as the sound. - A notice says where the file went, with Show in Finder; while the key mapping is in charge of clicks (Game Mode) the notice has no button, so it never takes a click meant for the game.
- Files go to Pictures and Movies, not the Desktop: a shim that writes to Desktop, Documents or Downloads gets a privacy prompt for every app. ⌘⇧S and ⌘⇧R stay with the window; ⌘S still reaches the app as Ctrl+S.
Each shim has one View menu: Translate Screen (⌘⇧T) and Translate Into
first, then the graphics items for this app (profile, follow
window resolution, render scale or resolution, aspect ratio, scaling
filter, performance HUD, Use Default Graphics Settings, full screen), then
Keep on Top and Picture in Picture.
GraphicsMenu is that menu's delegate; a menu edit saves a sparse
per-app override through setAppGraphics. Translate Screen reads the frame
on screen and translates it on the Mac, and draws plates over the text in a
view above DisplayView: clicks pass through, and nothing is drawn into
frames, so screenshots and recordings stay clean. It follows the screen: ten
times a second it compares the frame on screen with the one the plates were
read from (ScreenWatch, a 16-pixel grid of gray cells), drops at once
every plate over something that changed, and reads the screen again once it
holds still; text and screens read before are not read or translated again.
Through a scroll the plates move with their text instead (ScreenFollow):
each look measures how far a strip of the frame (rows of about 4 pixels)
slid since the last one, the way the mouse wheel's scroll watch does, and a
plate shows at its new place only where the strip there shows what was
under it.
The reading and the translating are one engine's for every app, the
daemon's (ScreenTextEngine). The shim sends the frame it shows
(readScreenText, its pixels beside the request) and gets the text back in
blocks with their colors and languages (Vision, ScreenTextLayout), then
sends the text without a translation yet (translateTexts, Apple's
Translation framework, which macOS 26 runs without a window for languages
on the Mac). macOS translates about five texts a second however they are
sent, so the engine keeps every app's translations while the daemon runs (in
memory only: they are the screen's text), and a window asks for the known
ones of a new screen at once (knownTranslations) before anything new is
translated. Vision builds its text models for each program that reads text
(about 25 s, again after a macOS update; an update of Avo keeps them) and
loads them in a moment at that program's next start; a model another program
built is built again over it, so App Shims, each a program of its own, could
not share them. The daemon builds and loads them once: at its start (Settings
› General › Translate Screen, translationAtStartup, on by default) or when
Translate Screen first turns on in any app (wakeScreenText, which also
loads the language pair translated last: macOS lets go of a pair's model a
minute after its last use). A pair macOS must download first, and every pair
on macOS 15, is translated in the window instead, by a SwiftUI translation
session, which can show the system's download sheet.
Keep on Top floats every window of the app over other apps' windows
(window.level = .floating; not in full screen), remembered per app in the
shim's defaults (WindowModeSettings). Picture in Picture moves the window's
content into a small borderless panel (PictureInPictureWindow) that floats
over every window, on every Space and over full-screen apps, and orders the
window out. Android's display keeps its size and the picture is scaled down
in the panel's shape, so the app does not lay out again; clicks, typing and
the gamepad still reach it, and the panel does not activate the app when
clicked. Its size starts at about 6% of the screen and stays within limits
(PictureInPicture in AvoKit); it sits in a corner and snaps to the nearest
one after a drag; corner and size are remembered per app. A capsule at the
top, shown while the pointer is over the panel, moves it and has Back to
Window and Close (a double-click goes back too); the Dock icon, the menu item
and Android's screen lock bring the window back. Whether Android sees the
display follows the panel then, not the hidden window. Chosen in full screen,
it opens once full screen has ended. Its ✕ quits the app, as the red light
does, and says so ("Quit PUBG MOBILE").
Closing a game's window ends the game in Android, and one wrong click must
not end a match: the red light, Close Window (⌘W) and Picture in Picture's ✕
ask first (windowShouldClose, askToQuit) while Android runs a game (the
Games preset or a key mapping): Quit, Run in Background or Cancel, with
Don't Ask Again. That is every app's setting (QuitSettings in the Window
Host's defaults), also Settings › Graphics › App windows › Ask before
quitting a game. Quitting the app (⌘Q) and closes Android asks for never ask.
The window's own three lights have a fourth after them, blue: Run in
Background (BackgroundLightButton, also Window › Run in Background). It is
the system's own red light turned blue: a new close widget
(NSWindow.standardWindowButton(_:for:)) draws each look for a moment
inside the button, as it is and pressed, whenever the window comes to the
front or goes to the back or the appearance changes, and the button shows
that picture with every coloured pixel's hue made macOS's blue. So it has
the lights' fill, shine, pressed look and back-window gray, in their row
(the title bar's sides are as tall as the title bar, their buttons on the
lights' middle) and 23 points after the green one, as they are apart. A
fresh widget each time: one that has drawn a look may still draw it a
moment after it changed. Its glyph shows while the pointer is over the
row. Full screen ends first, or its Space would stay behind, black and
empty. Every window of the app goes and the shim turns into an accessory app,
so its Dock icon and menu bar go too, while the app runs on in Android: its
displays stay open (unseen, so Android draws nothing for them and neither
stops nor pauses) and say they are in the background
(setDisplayInBackground), so the menu bar and the Library say In the
Background, not Minimized. Opening the app again (the Library, the menu bar item, a
notification, a tile kept in the Dock; applicationShouldHandleReopen,
applicationDidBecomeActive) brings the windows back as they were. The red
light still quits the app, and Android force-stops it. Back (Android's) comes
after the blue light, then the app's name (TitlebarTitle): AppKit's own
title is hidden, because it stays centred between the title bar's sides and
was cut to "F…" while there was room by Back. The name takes the title's
font and colour (which dims by itself in a window in the back), and what
Android calls the screen it shows follows when it says more than the name.
On the other side the title bar has menus of the menu bar's actions: the game
controller's (Game Mode, key mapping, hints, scrolling), Translate Screen's,
and the Device menu for the phone itself: Shake (⌃⌘Z, also in Game Controls: the
engine's shake swings Android's accelerometer for a second; the window
gives a little shake too, unless Reduce Motion is on), Rotate, Location…,
Fingerprint, the screenshot and recording items, Keep on Top, Picture in
Picture (not while the key mapping editor is open) and Phone Style. The
fingerprint also has a button of its own at the front, shown only while
this app, the lock screen or Settings asks for a finger; the trailing side
keeps room for it. A recording's red dot and time come first. The trailing
side is as wide as its buttons, and the leading side, with the name, takes
the rest (fitTitlebar, again on every resize).
That is the Standard app bar style (AppBarStyle). Settings › Graphics ›
App windows › App bar style (VMConfig.appBarStyle, applied at once) sets
it for every app, and an app may have its own (WindowModeSettings.appBarStyle
in its App Shim's defaults), as a location may be every app's or one app's.
It is one click away, never in a submenu: Phone Style, checked, in the
Device menu and the View menu, and an App bar style card in Quick Settings.
An app's own style is kept only while it differs from the Control Panel's,
so choosing the Control Panel's one follows the Control Panel again. The
other style:
- Phone (from TheGemDev's pull request #6,
WindowChrome.swift): the window shows the app's screen alone with a phone's rounded corners, and the Standard title bar grows out above it as the pointer leaves the screen through its top, as in iPhone Mirroring; moving back into the app or away shrinks it after half a second, and it stays while Quick Settings or one of its menus is open. The window keeps its size throughout, so the frame never re-plugs Android's display: every size comes from the screen in the window (screenSize, the chrome'sscreenInsets), and a locked shape is kept bywindowWillResize, AppKit's aspect lock being the frame's. Full screen and Picture in Picture have their own looks, and the recording badge shows over the game while the title bar is away.
All Settings for the app (at the bottom of Quick Settings) opens its page
in the Library through avo-control-panel://app/<package>.
The title bar's last button opens Quick Settings (QuickSettings.swift), a
transient popover in the Library's haze look with only the settings whose
effect shows at once: this app's graphics (preset, screen size with its
resolution or render scale and aspect ratio, orientation, sharp upscaling
(MetalFX) with sharpness, smooth edges), Android's frame rate cap as Max FPS
(for every app), the performance HUD, Game Mode and the key hints when the
app has a key mapping, the scroll mode, and Translate Screen with Translate
Into. Each card puts a line between its settings (QuickRows; the ones that
belong together, like a toggle and its slider, stay in one), and what a
setting does is behind its (?), not under it. Graphics save through setAppGraphics, which a shim may call only for
the package it said hello as; the frame rate cap saves through
setFrameRateLimit, which changes nothing else (a cap that needs another
display rate says Android runs at it after a restart). Everything that
needs a restart stays in the Control Panel.
Input from a window, in order of precedence:
- The keymap engine, when the app has a profile, Game Mode is on (⌘G) and the input is bound. In Game Mode a left click that no control takes also goes through the engine, as one more finger in its touch set, so clicking a game button does not break the touches that keys or the right button hold.
- Defaults: a click is a touch; trackpad scrolling becomes a one-finger drag (momentum events are dropped so Android flings natively); the mouse wheel goes by the scroll mode (below); pinch becomes two fingers around the cursor; Esc and a two-finger swipe left are Back; Cmd+C/V/X/A/Z map to Android's Ctrl shortcuts; editing keys (Return, Delete, arrows, …) are key events and everything printable goes through the macOS input method and arrives as committed text, so every keyboard layout and IME types correctly.
The guest focuses each display separately (config_perDisplayFocusEnabled),
so typing goes to the app in the window you are typing in. With the GPU
engine Android focuses one display at a time; the window that becomes the
Mac's key window takes it (FocusDisplay), so its app has the focus a
phone's one app has (games pause their sound without it).
Many Android views ignore ACTION_SCROLL (ViewPager, many Compose, Flutter,
React Native and custom views, games), while touch drags alone would lose the
wheel's precision where views support it. Game Controls › Scrolling (also in the
title bar's game controller menu) picks the scroll mode for the app, and the
default for every app: the app's choice is kept in its App Shim's defaults
(AvoScrollMode), the default in the Window Host's defaults
(AvoScrollModeDefault, which every shim reads); choosing the default's mode
for an app follows the default again (ScrollModeSettings).
- Automatic (default): a burst of notches (closer than 0.35 s) goes out
as
AXIS_VSCROLL/AXIS_HSCROLLwhile the window watches a band of the frame around the pointer (ScrollRegion, read in place from the frame's IOSurface on the render queue and scaled to gray cells; compared off the main thread). If the content slides that way within 0.22 s (ScreenSample.motion: only changed cells count, matched both ways so content that vanished is no slide), the app scrolls with the wheel. Otherwise the same lines are replayed as a swipe, and if the content follows that finger the app gets swipes from then on (WheelRoute). Scroll events are tried again after 30 s (doubling up to 5 min while the app still ignores them) or, no sooner than 3 s, when more than half of the screen changed since the last swipe left it (another screen). Where neither moved anything (the end of a list, a picture) nothing is learned, and that place and direction get scroll events only for 8 s. - Mouse Wheel: scroll events only, the trackpad's too (pixels converted to lines of Android's 64 dp, the rest carried to the next event, macOS's momentum included).
- Swipe: swipes only; the trackpad drags a finger as before.
The wheel's finger (WheelSwipe) moves one continuous drag per burst from
the pointer: 160 dp per line up and down (about three list items), 66% of
the width per line sideways (past a pager's turning point), plus Android's
8 dp touch slop. It moves at once (no tap or long press), eases to where the
notches take it, rests 90 ms and lifts, so Android sees no velocity and does
not fling; a fast spin (6 lines in 0.2 s) lifts while moving and flings. It
stays inside the display, goes down where the first notch's way fits, and a
burst that goes further rests, lifts and goes down again. The window watches
the content under it (SwipeWatch): a finger whose content did not follow
within 0.18 s lifts with ACTION_CANCEL, so a swipe over a picture or a big
button never clicks it, and goes no further. Shift turns the wheel sideways.
The finger never overlaps another touch: not while a click, drag, trackpad
gesture or pinch holds the touches, and not while the key mapping is in
charge (Game Mode with a profile, or the mouse locked for aiming): the wheel
then goes to the key mapping first and the rest as scroll events, as before.
A click, a trackpad gesture, a pinch, a display change or focus leaving the
window lifts it with ACTION_CANCEL.
Checked on Android 17 (AOSP Cuttlefish) by sending the shim's Input records
to the Bridge Agent's InputInjector: RecyclerView and Compose LazyColumn
and HorizontalPager scroll with scroll events (128 px per notch at 2×; the
pager turns a page), a list that ignores the wheel switches to swipes after
the first burst, and the end of a list and a big button stay unswiped (no
click). ViewPager2 (and other RecyclerView pagers) slides with scroll events
along its axis without snapping to a page, as it does with a mouse on
Android; Swipe mode turns its pages when a burst's drag passes the middle.
Each app resolves its graphics settings from one of three profiles in
VMConfig, Apps (appGraphics), Games (gameGraphics) and Phone
(phoneGraphics: the device profile's screen at its density, only when the
user chooses it for an app, which then opens in its own orientation), plus a
sparse per-app override (graphicsOverrides) that holds only the fields the
user changed.
An app is a game when the user chose so, it has a usable key mapping
(KeymapProfile.usable), or it says it is one: its manifest's
android:appCategory="game" or the older android:isGame, which the helper's
appinfo reads from Android (AppInfo.game, kept as AppRecord.isGame; an
APK read on the Mac or a bare listing leaves what Android said). App Shims
learn it from the configuration snapshot's games. Such a game keeps the
Games profile's performance settings but opens in the orientation its
activities ask for (AppInfo.orientation: the launcher activity's
screenOrientation, else the one its activities agree on; kept as
AppRecord.orientation, gameOrientations in the snapshot), so a portrait
game is not turned; one that lets the screen turn takes the profile's. The shared math is in AvoKit's DisplaySizing,
so the window, touches and the renderer always agree.
Android's screen (GraphicsSettings.mode, Screen size: Window, Fixed or Phone) is sized one of three ways:
- Window (
followWindow, the Apps default): Android's size is the window's pixels × the render scale (50–100 %), widths a multiple of 8. Below 100 % MetalFX enlarges the picture back to the window. Its own aspect ratio (followAspectRatio) is Match window (the window resizes freely) or a ratio that locks the window's shape; in full screen Android then renders the largest part of the screen in that shape, with bars. - Fixed (
fixed, the Games default, 720p 16:9): a short side of 360–2160 px (presets 540p to 4K) and an aspect ratio (aspectRatio). With a ratio the window is locked to that shape; with Match window the ratio comes from the window. - Phone (
deviceProfile): the screen of the device profile chosen under Privacy & Security (DeviceProfile.display), its width × height at its dpi, so apps lay out as on that phone; widths that are not a multiple of 8 lose the rest (1220 → 1216). The window is locked to the phone's shape, and the interface size does not apply. Avo's own identity has no screen: the mode is disabled in the editor, and a saved one follows the window (effectiveMode). The Window Host keeps the profiles from itsConfigurationSnapshot, so a new device profile changes open windows in this mode at once, like any graphics change (the identity itself still waits for a restart). - Orientation (
orientation, Apps portrait, Games landscape; older files take Display'sdefaultOrientationfor Apps): the shape of an app's first window (defaultWindowSize, turned to it) and of a locked shape. A locked window opens in it, a free one as it was left; changing it turns open windows, and apps that ask for an orientation still turn theirs. - Smooth edges (
antiAliasing, off by default): Fast is FXAA in the renderer, High and Ultra supersampling 2× and 4× (below, under the renderer). - Interface size (75–150 %) sets the density in follow and fixed mode, so changing the resolution or render scale changes sharpness only, never the layout, up to Android's highest density, 640 dpi: fixed resolutions above 1600 px reach it and lay out a little larger (540 dp at 4K), still short of a tablet's 600. Nothing else caps 4K: the engine takes scanouts up to 8192 px per side (a DisplayID EDID extension above 4095) and frame buffers of any size (3 × 41.5 MB for 4800 × 2160). With the Software renderer the editor says when Android renders more than 3.2 million pixels, since the CPU draws each of them.
Android's own screen (display 0), which no window shows, is sized at boot
by the same math (VMConfig.androidScreen, the engine's --display and
--density): with "Emulate this phone's hardware" it is the phone's screen
at its dpi, else what a new app window gets from the Apps profile on the
default window size at the main display's backing scale, upright and
without supersampling. Android sizes resources from it and apps can read it
(Resources.getSystem()); it changes at the next start.
Files from before the modes keep their meaning: followsWindow is still
read (true is follow, false fixed, mode wins when both are there) but no
longer written, and a ratio stored in aspectRatio still applies to fixed
mode only.
Changing Android's size gives its display the new size in place when Avo's
composer runs (GuestComposer, docs/engine.md): the app keeps its display
and only lays out again. Otherwise it re-plugs the display (HostAgent unplugs
it, plugs it back at the new size and brings the app back). Either way it happens only when
needed: in follow mode at the end of a resize and on a backing-scale change;
with Match window when the window's shape changed by more than 1 %; with a
fixed ratio or the phone's screen only on rotation and setting changes. Never during a live resize
or a full-screen transition, and a hidden window waits until it shows.
Bursts (a window turned back and forth) become one re-plug at a time
toward the newest size (DisplayResizing). HostAgent plugs the scanout
back only once Android has let go of the old display: nothing in
SurfaceFlinger's --display-id list names its HWC display any more, and
DisplayManager no longer lists it (waitForDisplayRemoved). The composer
forgets an unplugged display at once, so its EDID (port=) leaves that
list a frame or more before SurfaceFlinger drops the display at its next
commit. A plug in that gap reached SurfaceFlinger before the commit, which
then deleted the new display's HWComposer state, and the next frame crashed
SurfaceFlinger (a null pointer in HWComposer::executeCommands) and every
app with it. For the same reason a closed window's scanout goes to a new
window only once Android has let go of it (releasing); a window opened
meanwhile takes another scanout.
While Android changes size, the renderer holds a copy of the last frame
(holdCurrentFrame) until the first frame of the new size after the
daemon's reply (or at the reply, when that frame came first), at most 4 s,
so the window never flashes black; the held frame then fades out over the
new one in 0.2 s. Shims hear
configurationChanged, and an app's own override changes only through
setAppGraphics.
The picture is placed by presentationRect: 1:1 when the window is at most
a few pixels larger (the edge pixels repeat into the gap), filling the
window when the shapes differ by 2 % or less, aspect-fitted otherwise, over
a backdrop instead of black bars: the frame shrunk to 48 pixels, blurred
there, dimmed and stretched over the window (avo_backdrop; recordings keep
black bars). The renderer (MetalRenderer) then picks a path with
scalingPath:
- 1:1: a copy shader.
- Enlarging: MetalFX Spatial (
MTLFXSpatialScaler, perceptual mode) reads a private copy of the frame and writes the presentation size, at most ×4; beyond that a bilinear pass does the rest. Scalers are built per input and output size on a background queue (SpatialUpscalerCache), only for a drawable size that held for 150 ms (one pre-warmed on entering full screen, or still cached, is used at once); the current one and one previous are kept, and hiding the window or critical memory pressure frees them. Bilinear (Smooth) and Nearest (Pixel art) are the other upscaling choices. - Shrinking: an averaging filter of 2–4 × 2–4 samples per pixel.
- Sharpening: optional AMD FidelityFX RCAS (MIT) on 1:1 pictures (after 1:1, bilinear or MetalFX at the full size), off while resizing.
- Anti-aliasing (
antiAliasing): FXAA is a pass on Android's frame at its own size, before any of the above (avo_fxaa: where the corners' luma differs enough to be an edge, a blend along it; flat areas are left as they are), so MetalFX gets the smoothed frame it works best with. Supersampling 2× and 4× is DisplaySizing's instead: Android's size and density × √2 or × 2 (supersampled, after the mode's own request; the height and density follow the width reached, so the layout stays the same), up to the 8192-px display and 1280 dpi, past Android's own 640. The renderer then shrinks the frame to the window, and Android lays out the same either way. Recordings and screenshots keep Android's frame.
When MetalFX does not apply the picture is drawn bilinearly, and the
performance HUD (⌥⌘P) says why: preparing, resizing, failed (remembered
until the swapchain changes) or low memory. Its "Display scaling" line
shows when macOS itself scales the screen: the panel's native pixel width
(the mode flagged kDisplayModeNativeFlag) ÷ (screen points × backing
scale). Temporal upscaling and frame
interpolation need depth and motion vectors, which Android frames lack.
Known quirk: a user key mapping that binds Option sees Option when ⌥⌘P is
pressed in Game Mode (bundled profiles use F8).
KeymapEngine is a pure state machine: input and timestamps in, complete
multitouch events out (every event carries all active pointers, as
MotionEvent requires). Controls: tap, hold, D-pad (keys or left stick),
mouse aim, ADS, swipe, repeated tap, macros, anchor taps, mode switches and
keys that lock or free the mouse, and for MOBAs move by mouse and smart cast.
FPS mode: the first click captures the pointer
(CGAssociateMouseAndMouseCursorPosition) and raw,
unaccelerated deltas come from every mouse GameController sees (GCMouse;
not only the current one, which is the mouse moved last); for a mouse it
does not see (some wireless receivers) the window's own mouse events stand
in, counted only while no raw motion comes; the camera touch swipes from its
point (the area's centre unless moved) across the whole of its area, then
lifts at the edge, touches down at its point again and carries the rest of
the movement over, so the camera turns without limit (a tall area lets a
look up or down travel further per swipe); an anchor tap's finger stays in
the circle around its point (radius, a fraction of the window's height, like
a joystick's), or goes anywhere on the screen without one; ADS switches to its own sensitivity; Option toggles a
cursor layer for inventories and maps; up to 16 simultaneous touches. Profile
validation enforces the spec's hard limits: in FPS mode, repeated taps and
macros are rejected and swipes may not touch the aim area (no automatic rapid
fire or recoil compensation).
Landing like a finger. A press lands somewhere on its button's face, not
on one pixel: a Gaussian spread around the control's point (σ 40 % of the
face's radius, clipped at 80 %; every face is Control.defaultSize of the
display height across), drawn once per gesture. That covers taps, holds,
ADS, anchor taps (dragged from where they landed, their reach still
measured from the point), each repeated tap, macro steps and wheel notches.
The camera touch lands on a fresh spot within its area at every touch-down
(σ 30 % of the area's shorter half-side, clipped at 80 %), and stays down
while it is still where it touched down and cannot go further that way, so
a point on the area's edge does not lift and touch down on every mouse
movement. D-pads, MOBA joysticks, smart casts, swipes and scroll bars still
touch down exactly, where their math expects. KeymapEngine(touchSpread:)
scales all of it: 1 by default, 0 (exact points) in the tests.
Running. A D-pad control may have a key held to run (run: its
trigger and a scale of 1 to 4): while it is held the finger goes that many
times further from the centre, for games that walk with the joystick
partway and run with it all the way, or lock a sprint past its edge. The key
counts among the joystick's triggers, so pressing or letting go of it moves
the finger out or back at once (gliding), a controller button works too, and
resetting the controller mapping takes a button's off.
Modes. A profile may have modes (modes: an id, a name, and
mouse_look, what the mouse does on entering: locked to the eye, free, or as
it was), for the different sets of buttons a game shows: on foot and in a
vehicle, an inventory. A control acts in every mode unless it names the ones
it acts in (modes), and only the controls of the current mode answer their
keys, show as hints and touch the screen. The engine starts in the first mode
and stays in its mode through a reset (Game Mode off and on). A control's
switch action ({"switch_mode": id}, below) enters its mode when its key
or controller button goes down; on entering, the fingers of controls that do
not act there lift (taps and holds still lift with their keys), and a
joystick of the new mode takes keys that are already held, so W held while
getting in drives on.
Actions. Every control but a joystick and the eye may list actions,
done in their order after its own touch: {"switch_mode": id}, and
{"mouse": "lock" | "free" | "toggle"} (with the eye: lock the mouse to it,
free it, or the other of the two), so F presses the game's Enter button
and switches to the vehicle, and Tab opens the inventory and frees the
mouse. A hotkey touches nothing and only does its actions (it needs at
least one), and a click_area (area, a rectangle like the eye's) does
them when it is clicked while the mouse is free, the click still touching
the game there unless pass_through is false (back to combat when the
inventory's Close button is clicked). Before actions, Avo saved
switch_mode and mouse_look controls of their own; they load as the
actions of the control on exactly their keys in exactly their modes, else
as hotkeys, and are saved so.
Scroll bars and click zones. A scroll_bar (from, to, rest,
step, an optional reset key) drags a slider of the game, a map's zoom,
with the wheel: each notch puts a finger on the thumb where Avo last left it
(or at rest, a fraction of the track from from), glides it a step
towards to for a notch up (back for one down), never past an end, and lifts
it 0.2 s after the last notch. A new mode starts every scroll bar from
rest, and its reset key drags the thumb back there. Any control with a key
of its own may also have a click_zone (area, and a shape: rectangle,
rounded or ellipse): a left click inside it with the mouse free presses
the control as its key would, held while the button is down, its actions run
once, and the click itself never reaches the game, so the game's button is
touched once.
Shared keys. A key, mouse button or controller button may drive any number of controls: F can press the game's Enter button and a hotkey can switch the mode on it too. On a press, the controls that touch the screen go first and the actions after, so the game's button is pressed in the mode it belongs to and lifts with its key. The eye's lock key may press controls too (Tab: the game's inventory and a free cursor). Within a joystick a key is one direction only.
MOBA controls aim from the profile's hero point (where the camera keeps the character; the centre unless the player drags the hero marker in the editor) towards the visible cursor: move by mouse pushes the joystick that way while its button is held (typically the right button), and smart cast drags a skill that way while its key is held and casts on release, further the further the cursor is from the hero. Their targets are read once per display frame and glide like the D-pad. They follow the free cursor, so with the mouse locked in FPS mode they rest.
The editor (KeymapEditorView.swift, in AvoKeymapUI) is drawn over the game. Its panel
(KeymapEditorPanel.swift, in the Quick Settings' haze) stands beside the
window, in a window of its own (KeymapSidePanel in KeymapOverlay.swift, a
child window with rounded corners), so the game keeps its whole size while
controls are placed on it. It goes on the right when the screen has room
there, else on the left, else the window moves over a little as the editor
opens (and back as it closes, unless it was moved meanwhile), and it changes
sides when the window is moved until it leaves the screen. It takes the
keyboard only for a mode's name (becomesKeyOnlyIfNeeded), so keys stay
with the editor; drags from it to the game go by way of the screen. In full
screen, or on a screen too narrow even then, it takes the right of the
window instead and the game's picture moves over and gets smaller,
Android's display staying the same (the window keeps its size, so nothing
re-plugs). Cancel (and ⇧⌘K again) asks before throwing changes away.
Picture in Picture waits until the editor closes. The panel has a Controls
tab and a Controller tab, and on top what to do now:
- Mapping a button: click the game's own button, then press its key (or a controller button; right-click for a mouse button or the wheel). A tap is made there, in the mode being edited, and the next click goes on to the next button. A click on the waiting tap cancels it; Esc is a key like any other (the panel's ✕ deselects).
- The other kinds: the panel's controls (tap, repeat, anchor, joystick, swipe; eye, aim down sights, free and lock the mouse; move by mouse and smart cast; switch mode) are clicked, for one where that kind usually is, or dragged onto the game where one goes. A second joystick takes the arrow keys; the eye (one per layout) acts in every mode.
- The selected control: its key and controller button (waiting for a key while it has none), a mouse-button menu, remove; its type among tap, repeat, anchor and aim (the wheel only taps, and the eye allows no repeated taps); its settings (a repeat's interval, an anchor's finger reach, in a circle of some size or the whole screen, a joystick's keys, size, run key and running reach, a swipe's duration, the eye's sensitivity, aiming sensitivity, vertical look and swipe area, aiming held or toggled, a smart cast's drag distance and cursor range, a switch's mode, a mouse key's action); and, with modes, the modes it works in. A line separates each setting (or the ones that belong together), and what each does is behind its (?).
- On the game: drag a control to move it, ↘ for its reach, × to remove it; the eye's area is dragged while the eye is selected, so a click in it otherwise maps the game's button under it.
- The panel also has the layout's modes, whether and how clearly the keys show on the game, the editor's opacity, Templates, import, export and removing every control, a line between each part.
The editor's Templates menu inserts a starting layout for an FPS, MOBA, racing game or platformer, either next to the existing controls (keys already in use stay with the player's controls) or replacing them; the positions are guesses at a typical HUD, to drag onto the game's own buttons, and a template never changes the hero point. Controls of a kind this version does not know are skipped when a profile loads (the editor says so, and saving leaves them out), so a profile from a newer Avo still works.
GamepadHub (AvoKeymapUI) is the one reader of the Mac's controllers in a
shim process. GameController keeps one handler per element, so the hub alone
sets each extended gamepad's valueChangedHandler, reads the whole controller
on every change (GamepadReading) and hands the difference
(GamepadReading.inputs(since:)) to its listeners: GamepadForwarder
(Android's own gamepad, below), the key window's KeymapController, and the
key mapping editor with its controller panel. A controller's buttons reach a
game one of two ways:
- As Android's gamepad (the engine's virtio Xbox pad), for games with controller support. This is the default.
- As touches, when Game Mode is on and the profile binds the controller
(
KeymapProfile.usesGamepad). While the key mapping editor is open the editor holds the controller too. In both cases Android has no gamepad, so the game does not switch to controller prompts.
In a profile, a controller button is a pad: trigger, and a stick drives a
D-pad control (joystick) or the eye (stick). A D-pad control may also follow
the controller's d-pad buttons (dpad_buttons). A control answers one key and
one controller button, so binding a key keeps the controller button and the
other way round (Control.setTrigger). Like a key, a button may drive several
controls, including a mode switch or a mouse lock. A stick or the d-pad drives
one control per mode: binding it takes it off the controls that share a mode
with the new one, so one stick can walk on foot and drive in a vehicle.
Stick behaviour comes from "gamepad"'s left_stick and right_stick:
- Dead zone: radial. Nothing happens inside it, then the stick's travel
rises evenly from its edge to the rim (
StickSettings.deflection). - Sensitivity: multiplies the lean. A joystick goes no further than its rim; the eye turns at 1400 counts/s × sensitivity × the eye's own sensitivity.
- Releasing the eye: an eye driven by a stick lifts its finger once the stick has rested 0.1 s. FPS mode's locked mouse keeps that finger down.
vibration gives a 50 ms CoreHaptics pulse (GCController.haptics) when a
control takes a controller button's press. Android's own vibration is not
passed to the controller: the engine's virtio gamepad has no force feedback
and the guest's vibrator HAL is not bridged to the Mac.
presets are the game's saved controller layouts:
- What a preset holds: each control's controller buttons, stick and d-pad by control id, plus the stick settings and vibration.
- Applying one: first takes the whole controller off the layout. Bindings for controls that are gone or changed kind are left out.
- Ready-made layouts:
KeymapTemplate.gamepadPreset(for:)builds one for shooters, MOBAs, racing games and platformers. It goes by each control's role, guessed from its id or key (Space jumps, R reloads, the left mouse button fires), and the remaining controls get the buttons that are left, easiest to reach first.
The editor's controller panel (ControllerPanel.swift, the panel's Controller
tab) draws the connected controller's kind from GamepadDiagram and
GamepadLayout:
- Diagram: Xbox, PlayStation or Nintendo legends with SF Symbols' controller glyphs. Without a controller connected, the player picks which kind to show.
- Live state: buttons light up as they are pressed, and the sticks show their position and dead zone.
- Dropping a part onto the game: on a control that it can drive, it
binds there. Anywhere else, a menu offers the controls that fit
(
GamepadPlacement: tap, repeat tap, swipe, anchor tap, ADS for a button; joystick or eye for a stick, plus the stick's press; joystick for the d-pad).KeymapProfile.placeadds and binds the control, or binds the layout's one eye. - Binding the selected control: press the part on the controller, push a stick past halfway, or click the part in the panel.
- Sticks tab: sets dead zones and sensitivity, with a live preview, and vibration on controllers that have motors.
- Presets tab: applies a ready-made layout, saves, applies and deletes
presets, and resets. Reset (
removeGamepadMapping) takes the controller off every control and removes the controls that only the controller worked.
The editor shows one mode at a time, from the mode tabs on its panel (the flag marks the one the game starts in, the number its own controls). + Mode adds an empty mode and its name is typed in its tab; the layout's first also makes "Default", which takes the controls the layout had (the eye stays in every mode). New controls, controller parts dropped on the game and templates go into the mode shown, and the controller panel lists only its bindings; a control acting in every mode is marked ∞ on the game. The tab of the mode shown (clicked again, or right-clicked) has its menu: rename, duplicate (its own controls copied, the others acting in the copy too), start the game in it, the mouse on entering it, and delete it with its own controls and the switches to it (the layout's only mode: the layout is one set of controls again, and they all stay). The panel's Switch adds a switch to the other mode, to one chosen in a menu, or with no other mode to a new one; Free and Lock need the eye. Buttons have no size of their own: every tap-like bubble is the same size, and ↘ appears only on what has a reach (joysticks, move by mouse, smart cast, anchor taps).
There is no limit on the number of controls. Only Android's 16 multitouch slots bound how many fingers are down at once.
Every new field is optional. Profiles from before them load unchanged and
are saved without them. An older Avo ignores gamepad and dpad_buttons,
and its keys still work; it also ignores a joystick's run, which then
walks. An anchor's rectangular area from before build 64 loads as a circle
half as tall as the rectangle. An older Avo skips
the controls it does not know (hotkey, click_area) and ignores actions
and modes, so there every control acts at once and only touches, and it
refuses a layout that binds a key or button to more than one control.
Profiles are JSON with positions normalized to the window, stored per package
in ~/Library/Application Support/Avo/keymaps/, with community starters in
profiles/keymaps/.
VMConfig holds every Control Panel setting with validation (RAM 2–16 GB
within what the Mac can spare, cores, Android's storage size, FPS cap
30 or 60–120, graphics settings, window size) and the spec's restart table:
Performance, renderer, OpenGL ES version, device profile and GMS need a
restart; the default
window size applies to an app's first window. Graphics' "OpenGL ES" picks
the highest version apps get on a GPU start — 3.2 by default (every image
Avo offers has the ANGLE for it), or a lower one to test an app against a
weaker phone's GPU (docs/engine.md, "GPU"). The FPS cap (60 by default)
picks the refresh rate every Android display has for a boot (FrameRates:
the cap above 60, else 60; avo-vmm --refresh-rate), and within it
Android's render rate (HostAgent: peak_refresh_rate, at once). Settings,
an app's page and Quick Settings offer 30, 60, 90 and 120 whatever the
screens show, the own rate of each screen of the Mac between 60 and 120
(NSScreen's maximum rate: a 75 Hz monitor's 75, so Android runs in step
with it), and the saved cap as it is. Under a cap that no screen shows
evenly (NSScreen's maximum rate and adaptive range), each says what the
screens make of it (FrameRates.screenNote: "This Mac’s screen shows up
to 75 Hz. Android runs at 120 fps; the window shows 75 of them…"). A cap
that needs another display rate than Android started at (60 → 120 Hz)
applies after a restart: setFrameRateLimit and the
configuration say restartRequired, and the banner shows. The Control Panel
applies changes as you make them and shows a "Restart Android" banner when
needed. Its Settings window is laid out like System Settings (a searchable
sidebar of panes, back and forward; Graphics holds rendering, resolution and
scaling, app windows and the performance HUD) and dressed like the Library: the same
haze, and every form's sections as cards (HazeFormStyle, with the haze's
switches, menus and segmented controls), a line between each two rows as in
System Settings; a row's note, caption or problem (hazeRowNote) stays
right under it. The app page's Graphics card has the same lines (HazeRows).
The haze has two looks, dark (as Avo has always looked, a game launcher's
near black) and light (the same haze on a soft green paper, deep green ink,
Avo's green deepened to moss for buttons and checks). Every token in
HazeTheme.swift and QuickHaze is a dynamic colour with both values
(Color(haze:light:), an NSColor with a dynamic provider), so a view
takes the look of its appearance as macOS's own colours do; what the dark
look draws freely (a hero's gradient, its glows, an icon's palette) gets
its light counterpart from HazeTone, worked out in OKLab so it keeps its
hue. Settings › General › Appearance chooses System (the default, the Mac's
look as it changes), Light or Dark (HazeAppearanceSetting.avo, in the
Window Host's defaults). The Control Panel and every App Shim give it to
NSApp.appearance, and a change reaches running shims at once through a
distributed notification that carries the choice. The daemon gives it only
to the menu bar item's panel and the widgets: its own appearance stays the
Mac's, which Android's dark theme follows (MacSystemState). What draws
over a game (the performance HUD, notices, translations, the capture
flash and the key hints) stays dark in both looks. A haze menu wears the
appearance of what it opens from.
Android's storage size is chosen before Android creates its storage on a new
data disk (DataDiskStore), sized so that Android's Settings, which rounds
data plus system up to a phone size, shows exactly the chosen size. After that
it stays: /data is f2fs under metadata encryption (dm-default-key) with
inline-crypt file encryption, f2fs cannot grow while mounted, /data cannot
be unmounted at shutdown (incremental-fs mounts hold it), and an offline
resize.f2fs grow relocates file blocks through dm-default-key, which corrupts
file-encrypted data. So the daemon refuses a size change once the disk was
written, and Settings › Storage offers Change Size, which erases Android's data
first. Android's own figures (statfs of /data and /system) are kept in
data-disk.json next to the data disk for the pane.
Graphics › Renderer (rendererMode: Automatic, GPU (Metal), Software) picks
avo-vmm's gfxstream GPU path or software rendering at the next start; the
GPU renderer ships in Avo.app (docs/engine.md, "GPU"). A failed GPU start
falls back to software on its own and stays there until "Try GPU Again"
(retryGPU), a Renderer change, or a new avo-vmm, emulator or macOS;
VMStatus.renderer says which renderer runs and why, for the pane's status
line and banner. "Clear GPU cache" (clearGPUCache, with gpuCacheStatus for
its size; Control Panel only) empties the engine's Metal shader cache in
~/Library/Caches/Avo/gpu-cache (docs/engine.md, "Shader cache").
Android can pause: avo-vmm stops its vCPUs and its clock stands still
(engine.md, "Pause"), it keeps its place and uses no CPU, and it goes on
where it was. VMState.suspending and .suspended are those states, with
VMStatus.pause (since when, and whether Avo did it on its own); every
.running check treats a paused Android as not running, which is the safe
default.
- Waking is using. VMManager's channels (
controlSession,inputChannel,adbConnection,adbSocket,rescueShell) andensureRunningwake a paused Android before they hand it out, and so do what VMMEngine hands out itself (fingerprints, Google apps, the unlock, throughPausableEngine.onUse). So opening an app, a notification click, avoctl,adbthrough ADB support and an install all resume it within a moment; a resume is no new start (no app listing). Pauses and wakes run one after the other. Reading the status, polling the running apps (only while Android runs) and copying on the Mac never wake it: the clipboard waits and reaches Android when it goes on. HostAgent's watches skip their turns while paused, and after a resume HostAgent puts Android's clock right (clockResumed). Stopping a paused Android resumes it first, for its clean power off. - Pause when idle (Settings › Performance;
VMConfig.autoPauseMinutes: Never, 5, 15 or 30 minutes, 15 by default; applies at once). Every 30 s the daemon looks (AutoPause.swift): Android must be running with no Avo window open (no App Shim display and no Control Panel), nothing under way (an app install, Google apps, a storage move, an ADB support connection, sound playing or recording as the engine reports them, a GPS route playing) and the engine using under 0.3 of a core (a download, an app updating), for the chosen time since the last of those and since anything last used Android (VMManager.lastUse). The pause is dropped when Android was used between the decision and the pause. While paused, nothing in Android runs, so its notifications and background work wait too; the setting's help says so. - The menu bar pauses and resumes by hand (
pauseFromMenuBar,resumeFromMenuBar).
A click on the item opens a small panel (MenuBarPanel.swift) in the
Library's haze: the daemon target compiles HazeTheme.swift and the
typefaces from the Avo target, so that file must stay free of types only
the Control Panel has. It shows Android's state (running, paused with why,
stopped), avo-vmm's CPU and memory (proc_pidinfo of the engine, every two
seconds while the panel is open, never otherwise), the apps that run (open,
minimized, in the background) and the ones used last (AppUsage), and
Start, Pause, Resume and Stop. A right click (or Control-click) opens a
menu with every app, in the haze too (HazeMenu.swift, below). The menu
also has:
- Check for Updates…: opens the Control Panel with
avo-control-panel://check-for-updates. - Restart Android to Finish Updating Avo: shown while an update waits for Android to stop.
Like Activity Monitor, for Android (SystemStatusView.swift; the daemon's
side is SystemMonitor.swift and GuestActivity.swift). Four cards with
their last minute: Android (its state and CPU over all its cores), the
engine on the Mac (avo-vmm's CPU in percent of one core and its memory, as
in the menu bar), Android's memory (MemTotal - MemAvailable) and the GPU.
Under them, Android's apps (their processes added up) or all its
processes, with CPU (percent of one core since the last sample), resident
memory, state and FPS, sortable and searchable. An app's line opens its
page with a double click, has Open, its page and Force Stop under the
pointer, and the app's menu on a right click.
- Only while it shows. The Control Panel calls
systemActivity(watch: true)while the view is on screen in a window that is not covered or minimized, and false otherwise; the daemon samples every 2 s while any Control Panel watches (DaemonEvent.systemActivity), never otherwise, and stops when the last one says false or disconnects. - No shell call of its own. Android's part rides along with HostAgent's
2-second look (
lookAround):ps -A -o PID,PPID,UID,TIME+,RSS,NAMEand onegrepover/proc/stat,/proc/meminfo, every/proc/<pid>/oom_score_adjand the app cgroups'cgroup.freeze, as the shell user (about 13 ms of Android's CPU and 35 KB for 140 processes; 0.6% of one vCPU). The daemon's ownps -A -o NAMEpoll for the running apps waits meanwhile and takes its answer from these samples. A paused Android is never asked: the look skips its turns, and the view says Android is paused. - State comes from
oom_score_adj(ActivityManager's levels: < 0 Android's own, 0 foreground, < 300 visible or perceptible, < 900 background, cached above),cgroup.freeze(frozen), and an Avo window showing the app (window open). Processes gone before the grep (psitself) are left out. - GPU. The card shows how busy the Mac's GPU is (IOAccelerator's
PerformanceStatistics› "Device Utilization %") and avo-vmm's share of its time (theAppUsage›accumulatedGPUTimeof its AGXDeviceUserClients, nanoseconds, what Activity Monitor's % GPU uses); both from IOKit without entitlements, about 1 ms. GPU time per Android app is not measured, and the view says so: every app's graphics run in the engine's one gfxstream renderer, and their commands reach it through shared-memory rings that the virtio-gpu queue never sees. Measured in the test VM: the guest names each gfxstream context after its process, but per context the fences go almost all to SurfaceFlinger (composition), and SUBMIT_3D counts follow frames and wake-ups, not work (glbench: the same 580 submits/s at 2.7 times the load); gfxstream's host threads carry no names. Per-app GPU time would need timestamps per guest context inside gfxstream or MoltenVK. - FPS is the frames avo-vmm published for an app's windows (the shared
frames' sequence per scanout,
FrameBridge.publishedFrames), on its main process.
Every app tile, card and row in the Library (Home's hero, cards and tiles,
the Library section's All, Games and Apps tabs, search results, the
sidebar's running apps) answers the same way (AppTileInteraction.swift):
- Click: opens the app in its window.
- Press and hold (0.45 s): opens its page. The tile gives a little at once; past a click's length a ring of Avo green runs around it, and the page opens when it closes (with a haptic tick). Leaving the tile first cancels.
- Right click or Control-click: the app's menu in the haze
(
HazeMenu.swiftinAvoHazeUI, a borderless panel at the pointer that turns to fit the screen): Open, App/Game Page, Force Stop, Show in Finder, Clear Data… and Uninstall… (in the haze's red), confirmed as on the page. The keyboard moves through it (arrows, Return or Space, Escape, typing an item's first letters); a click elsewhere, another app or scrolling closes it. One local event watch sends right clicks to the tile under the pointer. - Keyboard and VoiceOver: with Full Keyboard Access, Return or Space opens, ⌘I opens the page and Control-Return the menu; VoiceOver's default action opens, with the page and the menu (Show Menu) as actions.
Every menu Avo pops up is such a haze menu (HazeMenu.swift, public in
AvoHazeUI, which the Control Panel, the daemon and the Window Host link
through AvoHostKit): the menus of app tiles and pages, the Control Panel's choices
(HazePicker: a check mark on the choice, sections, options that cannot be
chosen), an app window's title bar menus and Quick Settings' choices, the
key mapping editor's panel, mode and context menus, and the menu bar item's
menu. AppKit code still builds an NSMenu: HazeMenuPresenter.popUp(_:…)
has its delegate and validation update it as macOS would before showing
it, and an item sends its action to its target (one without is found when
the menu opens). Every action item of a menu's top level has an
illustration (an SF Symbol: NSMenuItem.symbol(_:), or the entry's
symbol); a submenu's rows show none, only real pictures such as apps'
icons, and keep the column so their titles stay in line. A submenu opens in
place, under its item (→ and ← with the keyboard): the panel takes its new
size at once, so the rows spring open inside the menu (slid out from under
their item), and a menu that must move to stay on its screen glides there;
closing, it shrinks once the rows have gone. A menu taller than its screen
scrolls. While a menu shows it has
the keyboard, and the window that had it gets it back; a transient popover
it opens from (Quick Settings) stays open meanwhile. Only the menu bar's
menus and a text field's own context menu are still macOS's.
Avo updates itself with Sparkle 2. docs/releasing.md has the feed, the
key, the release steps and the code-signing rules.
AppUpdater (Control Panel) owns Sparkle's standard updater:
- When it starts: only in a Developer ID build whose Info.plist has an
https
SUFeedURLand an Ed25519SUPublicEDKey(AppUpdate.readiness). The feed is the latest GitHub release'sappcast.xml; Sparkle checks it once a day by itself. - Where it shows: Avo › Check for Updates…, the menu bar item, and Settings › About › Software Update (automatic checks, last check, Check for Updates…).
- Trust: an update is trusted by its EdDSA signature (in the feed), or
by the running app's Developer ID on the app it brings
(
docs/releasing.md).
Sparkle swaps Avo.app at the same path. Android is never taken away for
that without the player's say:
- Install and Relaunch: while Android runs, it first asks whether to stop Android, install anyway, or wait for Avo to quit.
- The old daemon (
AppUpdateWatcher) watchesAvo.app. Once its build on disk changes, Android has held nothing for 3 seconds, and the core says nothing is under way (DaemonCore.claimRestartForUpdate: no start, restart, reboot, checkpoint restore, Google apps work, storage move, install or root step), the daemon takes its menu bar item away and exits withAppUpdate.daemonRestartExitStatus(75); from that claim on it starts no Android. launchd'sKeepAlivethen starts the new build's daemon. Until Android stops, the old daemon keeps it running. - The new Control Panel compares the daemon's hello (
daemonVersion: version, build, start of the code hash) with the daemon in its own bundle. An older daemon restarts at once if Android is stopped. If Android runs, the restart waits for it to stop, and Settings › About offers Restart Android meanwhile (ControlPanelModel.checkDaemonBuild).DaemonAgent.installnever stops a running daemon. A changed agent plist waits forDaemonAgent.reloadthe same way. - App Shims made from another build's template are made again
(
ShortcutManager.madeForAnotherAvocomparesCFBundleVersion). - From
dev.avotocom.avodroid(0.3.0 Preview 2): every helper's identifier, the agent's label (com.avodroid.daemon.agent) and the Mach service moved to Avo's domain (AvoIdentifiers.prefix;formerPrefixis the old one). The first Control Panel of the new build copies the helpers' and App Shims' preferences to their new domains (IdentifierMigration; Avo Hub'sdev.avo.hub…are not Avo's). Only an old agent whose daemon runs Android's engine (avo-vmm) is kept: the Control Panel talks to it under the former Mach service and installs nothing beside it, and once Android stopsDaemonAgent.reloadboots it out, removes its plist and loads the new one. Any other old agent goes at once: launchd holds a background item to the signature it first saw, so an ad hoc build's job cannot even start a Developer ID daemon ("Unable to get updated LWCR", the daemon killed for a launch constraint violation). When the old agent is gone,IdentifierMigration.finishcopies what the old daemon wrote since, then removes the old preference domains, its HTTP storage and its runtime folder: nothing of Avo's staysdev.avo. A new daemon that launchd started under the old job serves the job's former Mach service (XPC_SERVICE_NAME). App Shims with adev.avo.shim.identifier are made again. macOS may ask once more for what the helpers had been allowed (camera, microphone, Bluetooth, location, notifications), which it keeps by identifier. In Android, Avo's helpers moved too (com.avodroid.links,.widgets, theapp_processhelpers' classes,com.avodroid.feature.MAC). HostAgent uninstallsdev.avo.linksonce this build's is in, passing on the browser role if it held it, anddev.avo.widgets(each widget panel gets its widget again from the new host when it opens; one that asks for settings asks once more).IdentityFeatureremovesdev.avo.feature.mac.xmlwhere it writes the new file.
Every release must raise CFBundleVersion (CURRENT_PROJECT_VERSION).
Sparkle needs it, and so do the daemon's and the shims' build checks.
Help › Report a Problem… (ReportProblem.swift, DiagnosticsReport in
AvoKit) asks what happened and saves a zip where the user chooses, then
offers Show in Finder. Avo sends nothing; the user attaches it. The zip has
a README listing what is in it and:
report.json: Avo's, the daemon's, macOS's and Android's versions, the Mac's model, memory and cores; Avo's settings; Android's status, renderer (VMStatus.renderer), ADB support, storage (DataDiskStatus,StorageLocationStatus) and GPU cache; the installed apps' packages and versions (optional); what could not be collected.daemon.log:log show --last 3hof Avo's subsystem (com.avodroid, anddev.avoof the builds before 0.3.0 Preview 2), whose private values the unified log already hides.avo-vmm.log(andavo-vmm-gpu-failed.log), the end ofkernel.logand, optionally, oflogcat.log; small state files (gpu-fallback.json,data-disk.json,android-data.json,avo-image.json).
Never in it: Android's disks or any app's files, adbkey or the Mac's own
ADB keys, fingerprints.json, the clipboard or notifications Avo keeps.
Every file passes DiagnosticsRedactor: the home folder becomes ~ and the
user's short name <user>; email addresses, Account {name=…}, bearer
tokens, token/password/secret/cookie/authorization/API key values,
Android, GSF and advertising ids, IMEI and IMSI, GNSS fixes and links'
query strings are replaced. Each log is cut to its end (at most a few MB).
Avo turns Android's lock screen off at every start (lock_settings set-disabled), which does nothing once the user gave Android a pattern, PIN
or password (in Android's Settings, one of the listed apps). Android then
starts with user 0's credential-encrypted storage closed: only direct-boot
apps resolve or start, and its lock screen covers every display, app windows
included.
- Apps never disappear for it: ListApps asks with
--query-flags 0xC0000(direct-boot aware and unaware) and lists every installed package, launchable or not, and the daemon removes an app only when its package is gone (never on an empty listing). - HostAgent finds the lock after
sys.boot_completed(storage:sys.user.0.ce_available,am get-started-user-state; lock screen:dumpsys trust'sdeviceLocked; the kind:dumpsys lock_settings) and reports it asVMStatus.screenLock, watching it every 2 s from then on. The Control Panel opens an unlock sheet by itself (Cancel leaves a banner with Unlock…); App Shim windows show a lock in place of the app and open their display once Android is unlocked, whichever window or panel unlocked it. unlockAndroidpasses the credential tocmd lock_settings verify, which opens the storage but leaves the lock screen up; HostAgent then enters it on the bouncer on Android's own screen (display 0), as a person would:input textfor a PIN or password,input motioneventthrough the pattern's dots, and only whenuiautomator dumpshows that bouncer. Unlocked, it describes the apps again, catches up on packages and the daemon syncs the apps (shims and icons come back).- The credential is validated (pattern: 4–9 different dots 1–9; PIN: digits),
single-quoted for the shell, and never logged or stored;
verify's output (which repeats a wrong credential) is only read for its outcome. After four wrong tries Android refuses attempts for about a minute.
Settings › Fingerprints keeps the user's fingers in Avo (vm/fingerprints.json,
next to Android's data disk): they are added, renamed and removed whether
Android runs or not. Android's own Settings app adds one to Android, as on a
phone, when its sensor is touched with it. That sensor is the virtual one of
AOSP's userdebug images (com.android.hardware.biometrics.fingerprint.virtual,
driven by system properties), so BiometricPrompt and the lock screen see
enrolled fingerprints. The daemon's FingerprintCenter runs the guest steps
through HostAgent (GuestFingerprints has the commands and their parsing):
- Adding to Android: while Settings enrolls (its scheduler's current
operation is
FingerprintEnrollClient), a touch with finger N setsvendor.fingerprint.virtual.next_enrollment N:100,100,100:trueright beforecmd fingerprint fingerdown. The sensor enrolls the finger under its own id and Android names it "Finger 1"; the helper (guest/fingerprint, a dex jar run as root with app_process) callsIFingerprintService.renameto give it the finger's name.- Only Avo's fingers, once each: an unregistered finger or one Android already has is refused.
- A finger whose id Android or its sensor still holds from before takes a new id first.
fingerdownneeds the Secure settingbiometric_fingerprint_virtual_enabled=1. From the next boot Android then uses the sensor'svirtualinstance, which reads the same properties.
- Touching:
setprop vendor.fingerprint.virtual.enrollment_hit <id>(0: a finger the sensor does not know) andcmd fingerprint fingerdown.- A finger Avo has but Android does not is not recognized, as a stranger's is.
- The outcome comes from the counts
dumpsys fingerprintprints (accept, reject, lockout). - What asks for a finger is the scheduler's current operation:
FingerprintAuthenticationClientwith the requesting package (com.android.systemuifor the lock screen), or Settings enrolling. - An app's BiometricPrompt shows on display 0, which no window shows, so the daemon polls every 2 s while Avo has fingers, and the window's fingerprint button turns blue. Cancel Fingerprint Request sends Back to display 0.
- Following Android: at every start and refresh, Avo's fingers are merged
with Android's list (
GuestFingerprints.merge).- A finger Android lacks is not
enrolled. That happens when it was never added, removed in Settings, or removed along with the screen lock or by Reset Android. The finger itself stays in Avo. - A fingerprint Avo has no finger for becomes one: added by an earlier Avo, which mirrored Android's list.
androidNames(Android's names at the last look) says which side renamed one. A name Android changed comes over; any other difference goes to Android, since Android cannot change while stopped.- Removing a finger in Avo removes it from Android through
IFingerprintService.remove, which also takes it out of the sensor. The removal waits behind a running authentication (Android's scheduler runs one operation at a time), so it stays queued inremovinguntil nothing asks for a finger.
- A finger Android lacks is not
- Screen lock: Android takes fingerprints only with a screen lock, and its
Settings asks for one first. After a boot it wants the credential first
(StrongAuth). Avo turns on phone-like lockout: 5 wrong fingers lock it for
30 s; 20 in a row lock it until the credential is entered, which
cmd lock_settings verifyalso does. - No hardware token: the virtual sensor signs no hardware auth token, so keys bound to biometric authentication (CryptoObject) stay locked even after a recognized finger.
Android apps' home screen widgets show on the Mac in floating panels, live: they update and take clicks as on a phone.
- In Android: Avo Widgets (
guest/widgets,com.avodroid.widgets), built like Avo Links and installed by HostAgent at each start when Android's copy differs.appwidget grantbind(as root) lets it bind any app's widget without asking; QUERY_ALL_PACKAGES lets it see every app's.WidgetsProvider:content read --uri content://com.avodroid.widgets/providerslists the widgets as JSON (provider, labels, size in dp, how it resizes: which ways and its resize limits, a preview PNG);content call … --method delete --arg SLOTlets go of a removed panel's widget. Only the shell and root may ask.WidgetActivity: one widget filling a display of its own, started withavo-widget://SLOT?provider=…(GuestWidgets.startCommand, a task per panel). A slot keeps its Android widget id in the app's preferences, so a panel shows the same widget after a restart. One sharedAppWidgetHostlistens while any activity shows a widget. It ends with its display; a re-plug (a resize without Avo's composer) starts it again on the new display (HostAgent.bringBack). It is translucent and starts with NO_ANIMATION: no splash or opening animation that the panel would show as a flash.
- The list: the daemon keeps
widgets.json(PlacedWidget: slot, provider, size, how it resizes) and sendsDaemonEvent.widgetsto Control Panels and the panels. Each Android start (and each listing) refreshes how placed widgets resize; lists saved before had none. - The panels (
WidgetPanels, in AvoWindowHost) run in the daemon's own process, which is there whenever Android is. They are an in-process client (DaemonCore.registerInProcess, roleshim(com.avodroid.widgets)), so they need no XPC connection or shim hash. Each opens a display withOpenDisplayRequest.widget;openDisplayturns it into the activity's launch. The renderer draws them withavo_keyed: Android's empty, pure black display around the widget becomes transparent, so a panel has the widget's own shape. The panel sits just above the desktop's icons, or on top. It is the widget alone, so it goes as high as any window; its bar (move, keep on top, remove) is a child panel of its own that shows on hover over the widget's top edge, or under it at the top of the screen. The placeholder stays until a frame that is not all black (MetalRenderer.whenContentShown): Android's display is empty until the widget draws. - Taps: widget views are made with
MainDisplayContext, which starts a tapped widget's PendingIntent on Android's own screen (setLaunchDisplayId(0)), where an app Avo knows gets its window at the next look. Anything that still starts on a widget's display (an app the widget starts itself) is moved there by the look every 2 s; an app Avo has no window for just leaves the widget's display. - Settings: a widget that asks for its settings first opens in a
larger panel (
AndroidWidgets.setupSize). The task watch reports each widget display's top activity (displayContentChanged); once the widget is back on top, the panel shrinks to the widget's size (a re-plug). - Size: a new panel takes whole launcher cells (80 × 100 dp, as a 4 × 5
home screen), as many as the widget wants or its minimum takes; widgets
often draw past their minimum, and what they leave empty is transparent.
Dragging an edge resizes the panel as the widget allows
(
PlacedWidget.panelSizeLimits): a widget that resizes lays out again for the new size (a re-plug at the end of the drag), within its resize limits and along the ways it resizes only; one that does not (many games') zooms in its own shape, 0.5–2.5 × (its display gets more pixels at a higher density, the same layout in dp). The panel keeps its last frame until Android has the new size. - Limits: each panel takes one of the 15 scanouts, and an open panel keeps Android from pausing when idle. Pure black inside a widget shows through.
Android's GNSS HAL reads real fixes from avo-vmm (location on the control
socket, gnss.rs), so apps see GPS, not a mock provider. What it reads is the
daemon's LocationCenter (AvoDaemonCore), which has one of three sources:
- This Mac (the default): CoreLocation in the daemon (
MacLocation), while Android runs and Settings › Privacy & Security shares the Mac's location. Its fixes go through the center, which passes them on only in this mode. - A place: a click on the map, an Apple Maps search result or typed coordinates; speed 0, accuracy 5 m.
- A route: points in order, drawn (stops clicked on the map, by road
with Apple Maps directions or straight) or from a GPX, KML or KMZ file
(
LocationRoute.parse: a GPX track, route or waypoints; a KML gx:Track, LineStrings or Points; times kept when every point has one, in order; more than 20,000 points thinned). It plays at a pace (walk 5 km/h, run 10, cycle 18, drive 50, or as recorded), ×1 to ×20, once or in a loop (RouteTracker, pure: great-circle interpolation, the bearing from the current place to the segment's end, speed 0 while a recording stands still). Four times a second the center moves it on and sends the fix with its speed and bearing; the HAL reads the newest about once a second. It moves only while Android runs and is not paused (a suspending clock, so the Mac's sleep does not count, and never more than 2 s at a time): stopped or paused, the route waits where it is. A playing route keeps Pause When Idle from pausing (AutoPauseReason.location); starting or resuming one wakes a paused Android.
The center is the daemon's, so a route plays on with every window closed. avo-vmm keeps the last fix and sends it again after every start.
Every app's location and an app's own. There is a location for every
app, and an app may have one of its own: the panel's All Apps / This App
(LocationRequest.ownLocation, .followAllApps; Settings › Devices ›
Location lists the apps with their own and gives each back to every app's,
.removeApp). An app's own starts where every app's is and changes on its
own, a route included, each route moving on its own clock. Android has one
GPS, so what it reports is the own location of the app whose window is in
front (focusDisplay → LocationCenter.appFocused), else every app's; the
app's last window closing gives Android every app's location again. Each
panel hears its app's location (location(watch:) is answered per client),
the Control Panel every app's with the list. Places, every app's and the
apps' own, are kept across starts in location.json next to the
configuration; a route is not (the place before it is kept).
The Location panel (an app window's Game Controls › Location…, ⌥⌘L, or Quick
Settings › Location; LocationPanel.swift) is one window per App Shim:
a MapKit map with Android's puck (an arrow while it moves, turned with the
map), the Mac, the route and the stops being drawn; a search field (Apple
Maps suggestions near the map); and a sidebar in the Quick Settings haze
with where Android is, the playing route (progress to seek, pause, resume,
start over, its pace while it plays), Place or Route, and Use My Mac's
Location. The panel never asks macOS where the Mac is (no location prompt
for an App Shim); the daemon says. XPC: location(watch:) returns
LocationStatus with the route's points and, while watch, sends that
client DaemonEvent.location at every change and once a second while a
route plays (without the points; a new route id makes the panel ask again);
changeLocation(LocationRequest) changes it. Both are for App Shims and
Control Panels.
Each of Android's cameras (VMConfig.backCamera, frontCamera) is
Emulated, None or one of this Mac's cameras (CameraSource; the picker
lists built-in, plugged-in and Continuity cameras, AVCaptureDevice's
discovery, and keeps a chosen camera that is unplugged). While both are
Emulated, Android has the image's emulated cameras. Otherwise avo-vmm gets
--cameras and gives Android two virtio-media cameras read by the image's
external camera HAL (engine.md, "Cameras"), and the daemon feeds them
(CameraFeed, AvoDaemonCore): only while Android streams one, the source
chosen for it runs, the Mac's camera (AVFoundation, cropped to Android's
size, at most its rate) or a scene drawn on the Mac, as JPEG pictures on a
control connection of their own. macOS asks for camera access in Avo's name
the first time Android uses a Mac camera; without it Android sees a picture
that says how to allow it. Every boot with these cameras the host agent
patches the HAL so they face back and front (GuestCamera).
Which cameras Android has (and so which HAL) is fixed for a start
(cameraFlags): changing it needs a restart. Another source for a camera
Android has (one Mac camera for another, or the drawn scene) applies at
once (DeviceSettingsEngine).
Before each boot the daemon writes a small read-only disk, avo_config
(BootConfig, JSON padded to 4 KiB): the daemon's ADB public key (adbd stays
authenticated even though only the daemon can reach it), the device profile's
properties, root and GMS choices, locale, time zone and default density.
avo_init applies it early in boot (guest/device/avo).
Android images are downloaded at first setup, versioned separately, and live
in ~/Library/Application Support/Avo/images/<version>/ with a
manifest.json (SHA-256 and size per file) and an Ed25519 signature checked
against the key in the daemon's Info.plist (AvoImagePublicKey) before every
boot. scripts/make-image-manifest.py produces both. That is the image format
of the legacy crosvm contract (engine.md); avo-vmm boots AOSP Cuttlefish
builds (below).
Avo.app ships no Android. The Setup Assistant (first launch, no usable image,
or Help › Setup Assistant…) and Settings › System › Android version list,
download and import AOSP Cuttlefish images; the Control Panel does this
itself (AndroidImageManager), since a download needs no Android and the
daemon never reads files the user picks. Everything is in AvoPackages
(AndroidBuildAPI, AndroidImageInstaller, AndroidImageStore).
- Catalog.
AndroidBuildSourceholds the endpoint (Android Build API v4), the public API key Cuttlefish'scvdships, the release branches (aosp-android-latest-release) and the target (aosp_cf_arm64_only_phone-userdebug); Control Panel defaults (AvoBuildAPIEndpoint,…Key,…Branches,…Target,…MinimumSDK) override them. Of a branch's successful builds (newest first) Avo offers the newest of each Android version at or above API 36 (spec: AOSP 16+). A build's version comes from itsbuild.propartifact; within a branch versions only go up, so Avo reads evenly spaced builds, then only between two that differ, a few at a time, and caches every answer (~/Library/Caches/Avo/images/builds.json; the last listing,offers.json, shows at once next time). The image zip's size and MD5 come from its artifact entry. - Download. Signed Cloud Storage links last minutes: one is asked for
before each request, and an expired one (403) is renewed. The zip's
directory (its last 64 KiB, a range request) says how big it unpacks,
for the disk space check (download, unpacked files and Android's working
copy of super.img).
RangeDownloadercontinues a partial file withRangeandIf-Range: <ETag>(a changed file starts over), so Pause, Resume and a quit in the middle lose nothing; the partial file and its ETag are in~/Library/Caches/Avo/images/downloads. Then the zip's MD5 must match the API's, and only the image files are unpacked (ZIP CRC checked), hashed with SHA-256 as they are written. - Import. "Use an Image on This Mac…" takes an image zip, a folder with
the
.imgfiles, orfetch-cuttlefish.sh's build folder. The AVB properties ofvbmeta.img/vbmeta_system.img(build fingerprint, security patch) say which build it is; arm64 Cuttlefish userdebug at Android 16+ only, with the right headers (boot, vendor_boot, AVB, sparse super). Folders are cloned (APFS: no extra space), zips unpacked. - Manifest.
images/android-<version>-<build>/manifest.json, schema 2,"format": "cuttlefish","trust": "local": these are Google's builds, with no Avo signature, so the trust is what Avo checked when it installed them (the API's MD5 over HTTPS, or the user's own files), recorded as the size and SHA-256 of every file, with the release, build, fingerprint and origin. Images are staged inimages/.staging-…and renamed into place. - Choosing.
images/selection.jsonnames the image Android starts with and the previous one (kept for going back). The daemon'sCuttlefishImage.prepareStart(VMManager'sloadImage, at every start) takes, in order: theAvoCuttlefishImagesdeveloper setting, the selection,images/cuttlefish, else the newest image on the Mac (alsofetch-cuttlefish.sh's downloads, used where they are); none is "Android not installed". An installed image is checked against its manifest (the identities of files already checked are kept in.verified.json, so an unchanged image is not read again). - Switching. avo-vmm keeps writable copies of super and vbmeta in
the data folder's
cuttlefish/(vm/cuttlefish, or--diskswhen Android's data is elsewhere; made once). When a start uses another image than they came from (vm/cuttlefish/avo-image.json), they go and avo-vmm copies the new image's; the data disk and the instance's metadata disk (the key to Android's encrypted data) and misc stay, so apps and data survive an upgrade. A new data disk starts without the old metadata, misc and frp disks (DataDiskStore.ensure), and every start clears frp: Avo has no factory reset protection, and a kept frp disk that does not match the data (erased, restored, copied) keeps FRP active, which this Android's system_server answers by stopping at every boot ("FRP is active"). Android's own factory reset (Settings › System › Reset options) writes recovery's--wipe_datato misc and reboots; there is no recovery, so avo-vmm exits 76 instead (EXIT_FACTORY_RESET) and the daemon erases the data as Reset Android does and starts Android again. A recovery request left on misc is dropped before every boot: only one made during the boot counts, so a reset an older avo-vmm ignored never wipes at a later reboot. Any other reboot from inside Android (its Restart,adb reboot) ends avo-vmm too when the daemon runs it (--exit-on-reboot, exit 77, EXIT_GUEST_REBOOT): the daemon starts Android again through its usual start, so the new boot gets its displays, location, shared folder, audio policy and layers like every boot. avo-vmm run by hand still reboots in place.avbctl disable-verity(Google apps) set flags in the vbmeta copy: for another build of the same Android version the new copy gets them too and Google apps' overlay mounts again; for another version verified boot is back on and the overlay, which would not fit, stays unused.android-data.json(next to the data disk) keeps the newest Android that used the data disk: an older API level (or the same with an older security patch, which Keystore refuses) is offered only together with Reset Android. - Changing in the Control Panel. Each Android version keeps its own
data, and a change of version never erases any. Another build of the same
Android keeps the data in use. Another Android version (an upgrade or an
older one) asks which data it uses (
AndroidDataSheet): the data that version used before (places the Control Panel remembers from the daemon's storage status,AndroidDataMemory, by API level), Android data in another folder (use existing), new data in an empty folder (start fresh), or, for an upgrade only, the current data taken along. The other choices are the storage changes above, so the data left behind stays where it is: in Avo's own storage it becomes the kept copy, elsewhere its folder stays as it is. The image is chosen before the data changes, so no start pairs the previous Android with the new data (an older Android's data would be upgraded), and a storage change that fails puts the previous image back. A version that must be downloaded changes its data once the download is in. Google apps for another version stop working (Settings › Google services goes back to None, and Settings › Google Apps asks for Reset Android before Google apps for the new version:google-apps.json, docs/engine.md). The choice applies at the next start; the section offers Restart Android while the running Android uses another image.
~/Library/Application Support/Avo/ config.json, apps.json, icons/, keymaps/, adbkey, images/,
vm/cuttlefish/ (data disks, console and logcat logs, frames/, audio/)
~/Library/Caches/Avo/images/ partial image downloads, the image catalog's caches
~/Library/Caches/Avo/gapps*/ Google apps packages being installed, the last MindTheGapps release lists
~/Library/Caches/Avo/gpu-cache/ GPU shader and pipeline caches
~/Applications/Android Apps/ App Shims
~/Library/Logs/Avo/ avo-vmm.log (the engine's log, recreated at every start)
$TMPDIR/com.avodroid/ vsock.sock, control.sock, console.sock (mode 0700)
Android's data can live outside Avo's own storage, for example in a folder on
an external drive taken from Mac to Mac. AvoPaths.data is the data folder:
vm/, unless data-location.json (Application Support, written by the
daemon only) names another. That record keeps the folder's path, a bookmark,
the volume's name and UUID, the path inside the volume and the folder's id,
so a renamed or re-mounted volume is found again (StorageLocations.resolve).
Every holder of an AvoPaths sees a change at once (the path is read from
the record, cached by the file's identity).
- What travels (
AvoPaths.portableDataItems, in a folder namedAvo Android Data):userdata.img;cuttlefish/with metadata (the key to the encrypted data), misc, frp, super, vbmeta andavo-image.json;android-data.json,data-disk.json,fingerprints.json,google-apps-commit.json; andavo-data.json(AndroidDataManifest: format version, id, the newest Android and build that used it, disk size, whether Android created its storage and has Google apps, created and last-used dates, the last Mac, Avo's version; stamped at every start). Everything else is the Mac's: settings, the apps list and shims, key mappings, images, the ADB key,identity.prop,avo_config.img, the GPU fallback, and avo-vmm's frames, logs and lock (--instance vm/cuttlefish; the disks go to--disks <data>/cuttlefish). - Changing (
changeStorageLocation, Android stopped first): move copies into.avo-movingbeside the destination (an APFS clone on one volume, else a copy that skips holes, F_NOCACHE, F_FULLFSYNC), reads every copied byte back and compares SHA-256 digests of the nonzero 1 MiB blocks, renames the items into place with the data disk last, switches, and only then removes the old copy unless it is kept; use existing adopts a folder Avo made (carried from another Mac); start fresh makes an empty folder with a new manifest. Starts wait meanwhile (StorageProblem.changing). - Checkpoints on a move (
CheckpointsMove): they come along as clones when the data stays on its volume, and as verified copies (each clone's blocks again) on another APFS volume when the free space holds them beside the data; each checkpoint folder is renamed into place whole, before the data disk. Off APFS (where none could be restored) or without room they stay with the old copy: the sheet says how many and why, keeps the old copy by default, and a move that keeps none deletes them only withleavesCheckpoints(the daemon refuses it otherwise, before anything changes). Replacing or removing the copy in Avo's own storage says how many checkpoints go with it. - Left behind. A move notes the folder it copies into
(
data-moves.jsonin Application Support) until its copy is in place or gone. As the daemon starts, once a restore cut short is finished and while no move runs, the staging folders that work left go (.avo-moving, which on a drive without sparse files is as big as the data disk, with the folder the move made when it is empty;.avo-restoring), also those in Avo's own storage and the data's folder from before moves were noted. A noted drive that isn't connected is looked at again at the next start, for a month. - Checks (
inspect): FAT, network volumes, read-only volumes, cloud-synced folders and Avo's own folder are refused; exFAT and Mac OS Extended (no sparse files) and other formats warn; free space is checked against what a move copies. Data from a newer Android than every image on the Mac, in a newer format, or without its metadata disk is refused;CuttlefishImage.prepareStartnever starts an older Android on newer data. - A drive that is gone. Before every start (
VMManager's preflight) the folder must be found: else the start fails with "Android's storage is on “…”, which isn't connected", nothing is created where it was, and the Library and Settings › Storage offer Choose Another… and Use Avo's Own Storage (the copy kept there, or a fresh start). DiskArbitration (DiskArbitrationWatcher,VolumeGuard) refuses an unmount or eject of the data's volume while Android uses it or a move copies there; Android powers off cleanly and the user ejects again. A volume that goes without asking stops Android at once. - Room for Android's writes. On a full volume avo-vmm holds Android's
writes instead of failing them (engine.md, "A full Mac disk") and says
disk full on|off; the daemon (DiskSpaceEngine,watchStorageSpace) puts that inStorageLocationStatus.space, and while Android runs it looks at the data's volume once a minute and says so below 2 GB free (StorageSpace.lowBytes). The Library's banner and Settings › Storage show it, with System Settings › Storage for the Mac's disk or the drive in Finder; a stopped Android shows nothing. - Reset Android erases the data where it is, then Avo's side of it (the apps and shims, Google apps, the fingers in Android). It is refused while the data can't be reached (Avo would forget every app while the data stays on the drive) or its place changes (the data disk a move copies would come back at the new place), before and again after Android stops. The data disk goes first, and a reset that can't remove it changes nothing; the snapshots, checkpoints and records that stay are named in its error.
- macOS's permission. The daemon (and avo-vmm, which it starts) reads
and writes the folder; on a removable volume macOS asks once whether
"Avo" may (
NSRemovableVolumesUsageDescriptionin Avo.app's and the daemon's Info.plist). The Control Panel explains it just before the daemon first looks at a drive. Don't Allow, or the permission turned off later, isStorageProblem.notPermitted(also when avo-vmm is the one refused), with a button to System Settings › Privacy & Security › Files & Folders; the panes look again when Avo becomes active. - A new Mac. The Setup Assistant's Welcome offers Use Android Data from
Another Mac…: the daemon looks at the folder, the Mac switches to it
before Android's first start (so no fresh data is made in Avo's own
storage), and the Android step chooses a version that can use the data
(
rowFitting: the same Android version, else the oldest newer one), downloading it when the Mac has none (allowNewerAndroidlets Setup adopt data whose Android is not on the Mac yet).avoctl storage …does the same from the command line.
Restore points of Android's apps and data, beside the data wherever it is
(CheckpointStore, DaemonCheckpoints.swift):
<data>/checkpoints/<id>/checkpoint.json Checkpoint: reason, date, the Android that used the data,
and the settings that belong to it (root, Google apps,
device profile and its hardware); written last
<data>/checkpoints/<id>/userdata.img … AvoPaths.portableDataItems, without avo-data.json
- Clones only. Every item is an APFS clone (
clonefile): a checkpoint is made in a moment and shares its blocks with the data until Android changes them. The list shows what only each one takes (CheckpointStatus.ownBytes, the blocks no clone shares, getattrlist'sATTR_CMNEXT_PRIVATESIZE): what deleting it frees. There is no copy fallback (a copy of the data disk takes minutes and as much space again): with the data on a drive that cannot clone, the pane says so (CheckpointStatus.problem) and Avo makes none. - While Android runs.
makeCheckpointhas Androidsyncover adb (at most 20 s; without it the checkpoint is as after a power loss), thenVMManager.holdingStillstops its vCPUs for the clone and lets them go on, the guest clock adjusted as after a pause; the status does not change. A stopped or paused Android needs no hold. avo-vmm handles every virtio-blk request on the vCPU that notified it, and its retrier of writes that wait for room takes no try during a pause, so no write is half done while the vCPUs are stopped; each item is fsynced before it is cloned. - Automatic checkpoints come before a change Android cannot take back:
root turned on and another device profile or its hardware
(
updateConfiguration), Google apps installed or removed (after the checks, before the download), the first start of a newer Android than the data was last used with (bootVM, fromandroid-data.jsonand the image that starts, which would upgrade the data for good), and a restore. None when Android has no data yet or the drive cannot clone; a failure is logged and the change goes ahead. The newest five automatic ones stay (CheckpointStatus.automaticLimit); the user's own stay until deleted. - Restore (
restoreCheckpoint): refused for data from a newer Android than the image that starts. Android stops; its data now becomes arestorecheckpoint (so the restore can be undone); the checkpoint's items are cloned into.avo-restoringbeside the data, then replace it with the data disk last, and items the checkpoint did not have (Google apps installed after it) go. The checkpoint stays. The settings it kept come back, the data disk's size follows what Android said of it, fingers reload, and Android starts again when it ran; the apps list follows at that start (syncApps). A restore that fails before it changed anything starts a running Android again. - A restore cut short (Avo quit, the Mac stopped, the drive went) could
pair one side's metadata disk, the key, with the other's data disk, which
Android then cannot decrypt.
checkpoint-restore.jsonbeside the data (RestoreJournal: the checkpoint and the one made before) is written, fully synced, once the clones are made and before the first rename, and removed once the settings followed. The daemon finishes it as it starts, and every start does first (prepareCheckpointsForStart, before the settings the boot reads): the checkpoint is restored again (it is whole), else the one before undoes it. Until one works,prepareForStartrefuses every start, a move refuses to carry the data, no checkpoint is made of it and neither checkpoint can be deleted; restoring a checkpoint again or Reset Android puts it right. - What erases them. Reset Android and Android's own factory reset take
the checkpoints with the data. Moving the data takes them along where they
can be restored ("Checkpoints on a move" above); those that can't stay with
a kept old copy, or go with one the user lets go.
avoctl checkpointslists, creates, restores and deletes them. - Quick boot (a snapshot of the running VM's memory and devices) is not part of checkpoints; see engine.md, "Quick boot".
A folder on the Mac that Android sees as “Avo Shared” in its storage
(/sdcard/Avo Shared), both ways and without copies. Off by default;
VMConfig.shareFolder and sharedFolderPath, which take effect when Android
next starts (the engine's devices are fixed for a start).
Why virtio-fs. The guest kernel (GKI 6.12) has CONFIG_VIRTIO_FS=y,
CONFIG_FUSE_FS=y, and neither 9p's virtio transport nor CONFIG_9P_FS.
avo-vmm therefore serves the folder itself as a virtio-fs device with a small
FUSE server (engine.md, "Shared folder"). The alternative, syncing copies over
adb driven by FSEvents and inotify, would keep every file twice, lag behind,
and need conflict rules; with virtio-fs there is one copy, Android's writes
are on the Mac when the call returns, and the Mac's appear in Android within
the one-second cache time (names and attributes; the page cache is dropped
at every open and whenever the size or modification time changes). There are
no conflicts to resolve: both sides change the same files, as two programs on
one Mac would. File sizes are the Mac volume's (one read or write moves up to
1 MiB; about 0.8 GB/s read and 0.3 GB/s written on the test VM for 32 MB).
In Android (SharedFolderBridge, run by HostAgent after every start):
- It waits for user 0's credential-encrypted storage
(
sys.user.0.ce_available; a screen lock not entered yet keeps it locked), then mounts the device (tagavo_shared) at/mnt/avo_sharedwithcontext=u:object_r:media_rw_data_file:s0(Android's own media storage label; the images run SELinux permissive in any case) and binds it over/data/media/0/Avo Shared, the lower file system MediaProvider serves as/sdcard./datais a shared mount and the apps' namespaces are its slaves, so MediaProvider (and its FUSE passthrough path) sees the mount at once. A non-empty "Avo Shared" Android already had is kept as "Avo Shared (Android)". With sharing off, an empty leftover folder goes. - Permissions: the server presents every file as media_rw's (1023),
directories 0770 and files 0660, so MediaProvider (in that group) and root
reach everything and the kernel's
default_permissionscheck passes; chmod and chown from Android change nothing. What Android creates belongs to the Mac user (0644, 0755). Scoped storage is MediaProvider's as for any other folder: file pickers (DocumentsUI) and apps with media or all-files access see the files. - The media index: files Android writes are indexed by MediaProvider
as usual. The Mac's changes reach it through FSEvents on the folder:
after a second's quiet the bridge asks MediaProvider to look at each
changed folder again (
content call --uri content://media/ --method scan_file --arg <folder>, which also drops what was renamed or removed; more than eight folders, or dropped events, scan the whole folder), and once after mounting for what changed while Android was off. A paused Android hears of them when it goes on. - Unmounting is not done: MediaProvider keeps the folder open, so it stays until Android stops.
- Limits: a guest reboot from inside Android drops the mount until the next start; symlinks are shown as symlinks and resolved in Android, never on the Mac (so one pointing elsewhere on the Mac leads nowhere); macOS's own files (.DS_Store, "._" AppleDouble files, .Trashes, …) are left out of listings; APFS is usually case-insensitive, so "a.txt" and "A.txt" are one file, as on the Mac.
Permission on the Mac. The daemon looks at the chosen folder first
(inspectSharedFolder, SharedFolderInspector), which is what makes macOS
ask in Avo's name about Documents, Desktop, Downloads or a drive; the Control
Panel says so before it asks, as for Android's data. avo-vmm, the daemon's
child, then has the same access. Problems are SharedFolderProblem:
missing folder or volume (Android starts without it; avo-vmm leaves the
device out), not a folder, not permitted (with a button to Files & Folders),
or Avo's own folder or Android's data, which cannot be shared. The pane
shows what the running Android does with it (SharedFolderStatus.mount:
waiting for the screen lock, mounted, unavailable this start, failed).
swift test in host/AvoKit runs the host's unit and integration tests
(about 850), including daemon integration
tests against a fake engine, a fake Bridge Agent speaking the real Control
protocol, a fake input sink and a fake adbd that verifies real RSA signatures:
boot → app sync → shims → displays → input routing → XAPK install with OBB →
uninstall → notifications → configuration rules → crash handling → version
mismatch. The Bridge Agent build runs a JVM test that decodes Input records
written by the Swift encoder (proto/testdata/input-v1.bin).