Zinc Next on Android: first run on a Nexus 7 2012

2026-10-16, ZN-618 to ZN-622 (D59, zinc-next-mobile-spike.md, zinc-next-mobile-core.md). The device: Nexus 7 2012 (grouper), LineageOS 14.1 = Android 7.1.2 (API 25), Tegra 3 (4 x Cortex-A9, armeabi-v7a with NEON, OpenGL ES 2.0 only), 1 GB of RAM, 1280 x 800 at 213 dpi, over USB (adb). Every number below was measured on it; nothing ran on an emulator.

How an app is made

zinc export examples/rn-showcase --target android -o dist/rn-showcase-android   # SDL's own Gradle project, changed as listed in src/cli_core.cpp
cd dist/rn-showcase-android
./gradlew assembleDebug -Pzinc.abis=armeabi-v7a     # all three ABIs without -P: armeabi-v7a, arm64-v8a, x86_64
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n dev.zinc.rn_showcase/.Rn_showcaseActivity --es zinc.env "ZINC_PERF_HUD=1 ZINC_PROFILE=1"

Needs: the Android SDK (ANDROID_HOME), NDK r29 (29.0.14206865), a JDK 17 to 21 (Android Studio’s jbr); the wrapper fetches Gradle 8.14.3 (pinned by its sha256) and the Android Gradle Plugin 8.13.2, and AGP fetched the SDK’s CMake 3.22.1 by itself on the first build.

  • The project is SDL 3.4.16’s android-project/ (vendored whole in third_party/SDL3), changed the way SDL’s build-scripts/create-android-project.py changes it, plus a short list of Zinc changes (ZN-621 notes): an SDL upgrade is a re-copy.
  • libmain.so = the program’s AOT C++ (ZN_AOT_CPP_ONLY=1 zinc build --profile android), the shell src/app/shell_sdl.cpp (SDL3 main callbacks over include/zn/app.h), the engine library zn_app with SDL3 inside, and the program’s plugins compiled from their sources.
  • minSdk 24: libuv calls pthread_barrier_*, in bionic since API 24 (SDL3 alone needs 21). posix_spawn (API 28) is declared weak with the NDK’s __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__: an app never spawns, the linker drops uv_spawn.
  • The native library is stored uncompressed and 16 KB aligned in the APK and mapped from it (extractNativeLibs=false, LOAD alignment 0x4000).
  • An app has no environment: a debuggable build takes Zinc variables from the launch (--es zinc.env "...") and from zinc.json "dev": {"perfHud": true} (baked by the export); a release build takes neither. zinc.json targets.android fullscreen and kiosk are baked for every build (ZN_APP_ENV, applied first): fullscreen is SDL’s immersive sticky mode (no status bar nor navigation buttons, the window takes the whole screen); kiosk adds the screen kept on and the back button kept by the app.
  • The screen sleeps as for any phone app: SDL keeps it on for every app by default (FLAG_KEEP_SCREEN_ON, seen on every Zinc app before), so the shell sets SDL_HINT_VIDEO_ALLOW_SCREENSAVER unless the app is a kiosk. A program keeps it on with navigator.wakeLock (zinc:sys wakelock). Why: an app is a phone app or a web page, not a game; a screen that never sleeps drains a battery, and the program that needs it on says so.

What changed in hal_sdl.cpp (criterion 3 of ZN-372, Android half)

ChangeWhy
Events come first from a queue that zn_app_event fills (zn_hal_sdl_event, 256 events, a mutex), then from SDL_PollEventWith SDL’s main callbacks the shell takes the events off SDL’s queue; lifecycle events arrive from Android’s UI thread
The live-resize event watcher redraws only on the drawing thread (SDL_IsMainThread)Android delivers size changes from its UI thread: the redraw there made the GL context current on a second thread and crashed the Tegra 3 driver in eglMakeCurrent at the first rotation
fill on Android and a zoom from the display scale (whole numbers only: 1.33 stays 1)The window is the screen; the program follows it (rotation works)
The simulator’s picture turn (sim_turn) compiled only with ZN_HAL_GLThe file did not compile without the GL window (a bug of every non-GL build)
ZN_HAL_GL on Android with an OpenGL ES 2 context, GLR_CORE 0 (the replay’s GLES 2 path of plugins/display-gl), #version 100 shaders, no VAO, a 16-bit depth buffer; off by default (ZINC_RENDERER=gl)The GLES 2 replay works on Tegra 3 but is slower than the software raster there (below)

Also, outside the HAL: sys_host.cpp reads /proc/loadavg below API 29 (no getloadavg in bionic before); Yoga links liblog.

The heap

ZRT_HEAP_BYTES (512 MiB, one region allocated at start) cannot be had on this device: a 32-bit process with about 400 MB free. The shell sets ZINC_HEAP before zn_app_init: the android profile’s heap (128 MiB, src/frontend/profile.cpp, which zinc export bakes as ZN_APP_HEAP_MB), capped at a quarter of SDL_GetSystemRAM(); ZINC_HEAP from the launch overrides it. The profile’s heap is also the program’s mimalloc budget. Why 128 MiB: rn-showcase and breakout run within it, it leaves the system and the GPU driver the rest of a 1 GB device, and the region is only reserved address space until it is touched. Revisit with zinc.json targets.android heap needs once a program needs more.

Measured on the Nexus 7 (debug APK, armeabi-v7a, engine at -O2)

hellorn-showcasebreakout
APK (debug, one ABI)2.46 MB6.12 MB5.10 MB
APK (release, unsigned)5.99 MB (libmain.so 5.9 MB: text 5.8 MB, stripped)
Start, am start -W TotalTime (warm)600 to 634 ms460 to 640 ms454 to 582 ms
Start after dropping the page cache741 ms
Software raster through SDL_Renderer (ZN-622; ZINC_PRESENT=texture since ZN-743): frame60.0 fps, 16.66 ms; work p50 1.38 p99 4.3 ms; present p50 15.0 p99 17.4 to 19.9 ms (300 to 700 frames)59.9 fps, 16.68 ms; work p50 0.26 p99 1.5 ms; present p50 16.2 p99 22.7 ms
GLES 2 replay (ZINC_RENDERER=gl): frame13.7 fps, 72.9 ms; work p50 1.98 p99 23.4 ms; present p50 89 p99 158 mspresent p50 16.3 p99 32.8 ms
Outputlogcat (tag zinc) equals zinc run examples/hello, 29 lines
  • The owner, on the device: everything works, responsive layout and rotation, touch; “fluidité perceptible mais c’est ok”.
  • Scrolling the rn-showcase feed by hand: 25 fps, frames of about 40 ms (raster about 23 ms, present about 13 ms): a full-screen raster of 1280 x 800 on the A9 cores is the cost; idle frames are at the display’s 60 Hz.
  • The GLES 2 replay draws correct pixels (screenshot) but replays the whole frame (damage 100%) and Tegra 3 takes about 90 ms for it; the software raster redraws only the damaged rows. Android keeps the software raster by default (hal_sdl.cpp); a damage-aware replay is the next step.
  • hero does not export yet: it uses zinc:video, whose plugin needs FFmpeg (pkg libavformat), which an app cannot link yet; zinc:video has no android entry, so the export refuses it with that reason (ZN-628: the plan for MediaCodec and AVFoundation backends is in its notes).

Zero-copy present (ZN-743)

Where the present of the software raster went (ZN-622’s path: the raster into fb, SDL_UpdateTexture, the SDL_Renderer’s GLES 2 quad, SDL_RenderPresent), measured with ZINC_PRESENT_STATS=1 (per step, p50 and p99 over 120 frames, in logcat) and a trace of a scripted fling (ZINC_INPUT, which a window takes now too: ten flings of 400 px, rn-showcase, the frames that rasterized more than 2 ms):

Step (texture path)at restwhile scrolling
raster of the damaged rows0 (no damage)25 to 26 ms
SDL_UpdateTexture of those rows09.4 to 9.8 ms (breakout, 1 to 3 rows: 14.5 ms, it waits for the GPU still reading the texture)
SDL_RenderClear + SDL_RenderTexture0.02 ms0.02 ms
SDL_RenderPresent (eglSwapBuffers, vsync)14 to 15 ms (the display’s period)1.6 to 1.8 ms

Now the raster writes the damaged box straight into the window’s own buffer (hal_sdl.cpp anw_present): ANativeWindow_lock with the box as the dirty bounds (Android copies the rest from the previous buffer), HalFrame.render_box draws every pixel of the box it returns, unlockAndPost; no SDL_Renderer, no texture, no GL context; SurfaceFlinger composes the buffer. The buffer is BGRA_8888 (HAL format 5), the raster’s own byte order, so nothing is converted; ZINC_PRESENT=565 packs into RGB_565 from fb (no dither: the card gradients band visibly), and ZINC_PRESENT=texture keeps the old path. Frames without damage post nothing and are paced at 60 Hz by a sleep; a post waits in the buffer queue.

rn-showcase, scripted fling (moving frames)interval p50 / p99raster p50present without the raster p50 / p99
texture (before)40.6 / 60.7 ms (24.6 fps)26.0 ms12.8 / 18.2 ms
window buffer (now, the default)31.3 / 53.7 ms (31.9 fps)25.6 ms4.2 / 20.2 ms

At rest both are at 60 fps (present p50 14.5 and 14.8 ms: the wait for the display); breakout is at 60 fps on both (present p50 16.27 and 16.37 ms, p99 26.6 and 26.2). The present lost 8.6 ms of its 12.8; the raster of the scrolled area (26 ms) is what remains, ZN-744’s subject.

Pixels: ZINC_PRESENT_VERIFY=1 compares the whole window buffer with a full redraw at every post: 0 stale pixels in 480 posts of the fling (partial boxes: the panel, the scroll indicator, the feed). A screencap cannot prove equality on this device: Tegra 3 composes at mediump precision and resamples (the texture path differs from a desktop raster of the same frame on 28,925 edge pixels, max 32 levels; the window buffer on 20,077, max 20: the compositor’s own share).

The buffer’s byte order (ZN-750). HAL format 5 (BGRA_8888, the raster’s own order) is not one of the NDK’s window formats. The API 37 arm64 emulator (AVD Pixel_10) accepts it, answers 5 from ANativeWindow_getFormat and from the lock, and composes the buffer as RGBA: red and blue swapped on screen (zinc-next-android/rn-showcase-api37-bgra.png, the hero’s orange drawn (42, 73, 232)). No query tells such a device from one that honours the format, so from API 26 the window asks for RGBX_8888 and each band stripe is drawn into the thread’s own cached rows, then written once into the buffer with red and blue swapped (rn-showcase-api37-rgbx.png: (232, 73, 42); 0 stale pixels in 60 posts of six drags with ZINC_PRESENT_VERIFY=1). Below API 26 it keeps BGRA_8888, drawn in place: on the Nexus 7 the swapped path costs 5 ms of raster a scrolled frame (fling raster p50 13.2 against 8.3 ms, 433 to 474 late frames of 720 against 324 to 380, three runs each), though its pixels are right there too (0 stale pixels in 300 posts of the fling). ZINC_PRESENT_BGRA=1 or 0 forces one. The Nexus 7’s screencap at rest is identical to the one before the change (0 differing pixels below the status bar). The API 26 line is a guess between the two devices measured (API 25 honours the format, API 37 does not): a device of API 26 to 36 that honours it pays the swap.

A GPU layer under the software frame (ZN-746)

zinc:gfx runtime shaders (shaderProgram, drawShader, shaderAvailable) are drawn by the GPU without the GL replay of the whole frame: while frames draw a shader the window is a GL window (since 2026-10-17 only then: “The music remote’s lag” below); each frame GL draws the frame’s SHADER commands (at 1 / scale of the screen when the program allows it), then the software frame over them, its damaged rows rendered with holes where the shaders are (twice, holes black and white: alpha and premultiplied colour) and uploaded. A change of the shaders’ uniforms alone is no damage: an animated shader under a still UI costs the CPU nothing. The music app’s lava face: 4.0 fps with its PNG round trip and four taps, 22 fps with one smooth stretch of a raw picture on the CPU, 61 fps as a shader at a quarter of the resolution (24 fps at full resolution: Tegra 3 needs about 40 ms to shade and blend 1280 x 704). ZINC_GPU_LAYERS=0 keeps the CPU window, ZINC_SHADER_SCALE overrides the scale.

A shader follows setTransform (owner’s report, 2026-10-16: the lava face appeared whole while its slide came in): drawShader records the box where the matrix puts it (a turn or a skew: its upright bounding box), so the hole in the software frame and the GPU layer’s quad move with the carousel, and what is drawn over the shader (the slide’s fade, the clock) and the clips around it shape the hole as before. Recorded on the Nexus 7, a swipe from the player to the clock slide, before (music-lava-slide-before.png: the field already covers the screen while the time slides in) and after (music-lava-slide-after.png). The lava face at rest, perf panel on: 76 late frames of 290 before, 67 after (interval p99 35.7 and 33.2 ms); the swipe itself costs 160 to 250 ms a frame on both (the whole screen rendered twice for the holes and uploaded: 3.6 MB a frame at about 100 MB/s).

The GLES 2 replay: a scroll on the GPU, one capability record (ZN-741, ZN-744)

ZINC_RENDERER=gl (not the default yet). rn-showcase (the shell of 2026-10-16 19:09), its Components screen flung ten times by ZINC_INPUT, perf panel on, moving frames 170 to 890:

StepLate frames (> 16.7 ms apart) of 720interval p99
Software raster (ZN-743’s direct path), three runs228, 222, 26329.4, 26.8, 28.3 ms
GLES 2 replay of the damage box (before)96 (fewer frames: a scroll frame replayed the whole area, 40 to 56 ms; the input is counted in frames)56.8 ms
+ a scroll moves the texture’s pixels on the GPU (copy and draw back), only the rectangles left replayed38432.2 ms
+ lean programs on Tegra 3 (tuning table), no window clear on an immediate GPU384 → 386 (flat)32 ms
+ the scrolled area kept turned in the texture (no pixel moves, the window quad drawn in pieces), three runs225, 228, 28532.1, 31.6, 32.5 ms
same, perf panel off (software raster off-panel: 270, p99 31.5)22129.5 ms

Where the time goes (ZINC_GL_FINISH=1: every step waits for the GPU): the window quad 4.2 ms (5.4 with the clear an immediate GPU does not need), the move 3.9 to 5.2 ms (gone with the turned rows), the replay of the rectangles 10 to 13 ms: a pass costs Tegra 3’s CPU 1 to 3 ms before any pixel and the GPU about 70 ns a pixel of a strip (a 1280x50 strip of cards and text: 4.5 ms; the software raster draws it in about 1 ms on the four A9 cores). At rest both paths are at 60 Hz (1 late frame of 230); in tab switches GLES 2 has 23 late of 450, the software raster 20 (the screens’ own effects and layout, 30 to 118 ms, dominate both). So the replay is as fast as the software raster at rest and in a tab switch and within a few milliseconds of it in a fling: Android keeps the software raster by default.

Tried and dropped: one pass over the union of the rectangles with the depth buffer as a mask (GPU 7 to 19 ms: the GPU shades every quad before the test); rotating vertex buffers (no change: the driver did not wait on them); the software raster under a GL window that only moves and presents (glTexSubImage2D on Tegra 3: 26 to 30 ms a frame into the presented texture, 7 to 10 ms into staging textures, about 100 MB/s).

The capability record (runtime/gl_caps.h): ZINC_GL_INFO=1 prints it on every provider (the SDL3 window on desktops and Android, display-gl’s KMS/EGL and webOS SDL 1.2). Nexus 7: OpenGL ES 2.0, NVIDIA Tegra 3 (family Tegra 3), fragment highp no, mediump 13 bits, surface depth 16 stencil 8, max texture 2048, no npot, VAO, instancing, 32-bit indices, packed depth-stencil nor depth24, framebuffer fetch (NV), derivatives, ETC1, S3TC, half floats; EGL swap-with-damage but no buffer age nor partial update nor a preserved swap; tuning: immediate (not a tiler), lean programs.

The first interaction (owner’s report: the first touch is slow)

Cold launch, the panel and a trace (ZINC_TRACE), a tap on Settings at 3 s, a tap on Stats about 2 s later; worst frame (and the gap between frame starts) at each tap, from the trace:

RunStartTap 1 worst frameTap 2 worst frame
Warm (default CPU settings: autosmp min_cpus 1, intelliactive)482 ms42.0 ms (input 11.0, layout 9.5, raster 5.6, present 18.3)51.9 ms (input 11.6, layout 10.1, raster 15.4, present 29.1)
Page cache dropped, launched 45 s later741 ms41.8 ms (input 11.7, layout 10.3, raster 5.8, present 16.8)55.8 ms (input 11.2, layout 13.6, raster 17.1, present 28.5)
4 cores online, performance governor506 ms42.4 ms51.4 ms

In the engine, the first tap costs what the second does (a screen switch: about 11 ms of input handling and 10 ms of layout, every time), and CPU hotplug changes nothing. One more finding: any su through Magisk (it starts a Java root server) made every other frame’s present take about 105 ms for the whole run that followed (18 s), with or without a tap; the runs above use no root command during the measurement. Not measured: the latency from the touch to the first frame that answers it (outside the engine’s frames) and page faults of the mapped libmain.so; a trace with simpleperf or the kernel’s page-fault counters would.

Screenshots: zinc-next-android/rn-showcase-first-pixels.png, rn-showcase-panel-cpu.png, rn-showcase-panel-gles2.png, breakout-panel-cpu.png; hello’s logcat: zinc-next-android/hello-logcat.txt.

CPU cores (ZN-750)

The owner saw one core online most of the time in a running app. What decides it on this ROM: Tegra’s cpuquiet with its balanced governor (autosmp is built in but off: /sys/module/autosmp/parameters/enabled is N, so its min_cpus does nothing). balanced brings a core up when the clock is above idle_top_freq and the average count of runnable threads exceeds the cores online, takes one down below idle_bottom_freq or when they are idle; init.grouper.rc sets 60 ms delays and a 102 MHz floor, and setting any cpuquiet governor puts the kernel’s defaults back (up 1 s, down 2 s, a 513 MHz floor): the state the tablet was in on 2026-10-16, after an earlier session’s measurement. Any app launch brings all four cores up for about 1.5 s (the system’s boost, whatever the governor). The cores were held with PM QoS (/dev/min_online_cpus and max_online_cpus kept open by a root shell, set 8 s before the run, no root during it); the cores and cpu0’s clock were sampled every 0.5 s on the tablet during each run (/sys/devices/system/cpu/online, scaling_cur_freq, readable by the shell user). rn-showcase: ten scripted flings, moving frames 170 to 890; music: six carousel swipes (player, clock face 0, weather, rings and back), moving frames 110 to 500; perf panel on; debug APKs of 2026-10-17.

CoresFling: late frames of 720 (runs)Fling interval p50 / p99Fling raster p50Swipes: late of 390Cores seen, cpu0 clock
1 held47422.2 / 38.3 ms15.3 ms327 (interval p50 106 ms)1; 1300 MHz
2 held (max 2)44318.8 / 41.0 ms10.2 ms2032
at least 2318, 39716.8-18.2 / 36-38 ms7.3-8.7 ms2-4
at least 335217.4 / 33.8 ms8.6 ms3-4
4 held240, 270, 295, 29716.7-16.8 / 30-36 ms7.3-8.4 ms184, 185, 1894; p50 860 MHz (fling), 1200-1300 (swipes)
the ROM’s balanced (a normal run)303, 305, 321, 335, 338, 362, 365, 36616.9-17.6 / 28-35 ms8.0-9.2 ms175, 184, 189, 1992: 22-32%, 3: 22-45%, 4: 30-52% of the samples; p50 860-1000 MHz (513-1300)
kernel defaults (after a governor switch)314, 349, 45317.0-18.9 / 33-38 ms8.0-10.6 ms453: 1 core 22%, 2 cores 60%

A fling needs the four cores: the raster’s bands (D126) run on whatever is online, and with one core the raster doubles. The music swipes are bound elsewhere (the frames that move the whole carousel take 80 to 110 ms of raster with any count from two up). The ROM’s balanced governor keeps two to four online and loses 15 to 25% more frames than four held; at least two or three is no better than the ROM.

What the engine does now, for every Android app: it asks for steady clocks while frames move (Window.setSustainedPerformanceMode, from the first frame with damage to a second after the last; dropped while paused), and zinc:webos setPerformance (device(‘performance’)) asks for it too. PowerManager.isSustainedPerformanceModeSupported() answers false on the Nexus 7 (and on the API 37 emulator), so nothing is asked there: logcat says sustained performance mode supported: false, then sustained performance mode on (not supported here: nothing asked) at each fling’s start and off a second after it; device(‘performance’) answers “0” there, as webOS’s helper does when it is absent.

Tried and dropped: keeping the raster’s workers runnable between frames (spinning on sched_yield for 30 ms after a band, so cpuquiet counts four runnable threads). It works on the hotplug (four cores online 75 to 85% of the time, clock p50 1300 MHz, raster p50 5.8 to 6.2 ms from 8.0 to 9.2) and loses on the frames: 365 to 427 late against 314 to 366 in alternated runs, interval p50 17.5 to 18.4 ms against 17.0 to 17.6 (the spinning threads take the main thread’s time for paint and layout and the memory bandwidth). Not kept.

For this development tablet, docs/reports/hardware/nexus7-tune.sh apply now keeps the four cores online (cpuquiet’s userspace governor, which takes no core down; all four written online, again at each boot from its init file); revert puts balanced back with init.grouper.rc’s values; status prints the cores online and the governor. Before and after apply, the same APK: flings 321 and 365 late frames (p99 29.7, 33.7 ms) before, 281, 302 and 255 after (p99 31.6, 30.6, 30.3 ms; raster p99 13.9 from 15.3 to 18.9 ms); swipes 184 before, 184 and 184 after. The cost: no core ever sleeps nor the low-power companion core runs, which a tablet on its charger can afford.

Not done here

Lifecycle events to the program, context loss, safe areas, the back button and IME (ZN-625, ZN-626), signing on the Nexus 7 (ZN-629 proved it on the emulator, below), x86_64 builds (the export lists them; armeabi-v7a and arm64-v8a were built).

Music remote on Android (examples/music, 2026-10-16)

The TouchPad’s Sonos and Spotify wall remote (examples/music, zinc:sonos, zinc:spotify, zinc:socket UDP, zinc:fs, zinc:webos, AsyncStorage, gsap) on the same Nexus 7, with no change to the program:

zinc export examples/music --target android -o dist/music-android
cd dist/music-android && ./gradlew assembleDebug -Pzinc.abis=armeabi-v7a     # APK 16.0 MB (debug, one ABI)
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n dev.zinc.music/.MusicActivity                          # start 526 to 613 ms
On the Nexus 7
Sonos discovery (SSDP M-SEARCH from a UDP socket on port 37451, unicast answers)Works with no MulticastLock: the group, what plays, the cover, the progress, live (music-player.png)
Transport, volumePause and play on the speakers (“En pause”, then “En lecture” with the progress going on); the volume slider moved the group from 9 to 13 and back, the room sliders follow (music-volume.png)
HTTPS (radio logos from upload.wikimedia.org and others, radio-browser.info)Works once the engine reads Android’s root store (below) (music-radios.png)
The screen"android": { "kiosk": true } in its zinc.json: the whole 1280 x 800 screen, no status bar nor navigation buttons (music-kiosk.png; dumpsys window: frame [0,0][1280,800], fl=#1810580 = FULLSCREEN and KEEP_SCREEN_ON, vsysui=0x1706 = immersive sticky, fullscreen, navigation hidden); the back button stays in the app, Home leaves. Without kiosk (--es zinc.env ZINC_KIOSK=0): fl=#1810900, no KEEP_SCREEN_ON, the 1280 x 704 window between the bars
The wall remote’s dimming (zinc:webos setBacklight)After the delay (10 s for the check, then back to 120) the window’s brightness goes to 0 (dumpsys window: sbrt=0.0) over a black picture with the flags above kept, and the next touch gives the system’s back (no sbrt); the screen stays on (Display Power: state=ON)
SpotifyNot connected: no spotify.json on the tablet (below)

What it took, each where every program benefits:

  • The plugins’ requirements. socket, sonos, spotify and osc required process (meaning “an OS”), which the android profile has not; none spawns a child process. They require net now (spotify net and fs).
  • Root certificates. An app has no /etc/ssl, and /proc/self/exe is app_process, so no HTTPS request could be verified. src/host/tls.cpp reads /system/etc/security/cacerts (135 PEM files on this tablet) and the user’s added roots beside it.
  • zinc:webos on a phone. Unchanged: off webOS its helper requests are device() queries of zinc:sys, which the Android host now answers for the two that a phone has: wakelock (SDL’s screen saver off or on, as navigator.wakeLock wants) and backlight (the window’s screenBrightness, 0 or -1 for the system’s, set on the UI thread by the exported activity through SDL_SendAndroidMessage, ZN_APP_ANDROID_BRIGHTNESS). performance and port answer false as on a desktop: a phone has neither a governor stuck at 192 MHz nor a firewall for an app’s own sockets, and the program does not look at the answers. isWebOS() is false (no libpdl.so), so the TouchPad paths (screenTimeout, the power activity) are not taken.

The files beside the program (config.json, radios.json, spotify.json, read with zinc:fs by relative paths) are in the app’s files directory, the shell’s working directory (/data/data/dev.zinc.music/files, src/app/shell_sdl.cpp). The program writes config.json there itself at the first start. To bring one from a computer to a debuggable APK:

adb push spotify.json /data/local/tmp/ && adb shell run-as dev.zinc.music cp /data/local/tmp/spotify.json files/ && adb shell rm /data/local/tmp/spotify.json

Spotify was not tried: the login is a PKCE refresh token that Spotify rotates at each refresh, so a copy of the TouchPad’s spotify.json would log one of the two devices out at the first refresh. The tablet needs its own login (python3 plugins/spotify/login.py <CLIENT_ID> spotify.json on the Mac, then the commands above).

Seen on the way: adb shell input tap (the press and the release within a few milliseconds) did not reach the program’s buttons, while a press held 120 ms (input swipe x y x y 120) did; a finger works.

The music remote’s lag (2026-10-17)

The owner, on the Nexus 7 (tuned: four cores online): “the app is laggy”. Measured with SurfaceFlinger’s present times of the app’s surface (dumpsys SurfaceFlinger --latency, any build, no root), release APKs of the same program and zinc.json, scripted swipes (input swipe):

Frame interval while it moves (p50, max)Before (main of 2026-10-17)After
Carousel, weather and rings slides (no shader)183 ms, 233 (5.5 fps)50 ms, 83 (20 to 30 fps); vsyncs dropped 254 in 12 s, now 128 in 10 s
Carousel into and out of the lava face183 ms, 31767 to 83 ms, 100
Carousel, lava face and player183 ms, 21767 ms, 100
Radios grid scrolled67 ms, 133, hitches of 467 to 65033 ms, 33 (no hitch)
Lava face at rest60 fps60 fps

Why it lagged:

  • The whole app ran on the GL window. The lava face makes its shader at load, and a shader made before the first frame gave the window to GL for the whole run (ZN-746), so every damaged frame of every page was rendered twice (holes black, then white), converted and uploaded (1280 x 800 x 4 bytes: 30 ms for the conversion to RGBA on one core, 11 ms for glTexSubImage2D): the zero-copy path of ZN-743 was never taken. ZINC_PRESENT_STATS printed nothing there (the GL steps were not marked).
  • A stretched picture cost 200 ms of CPU a frame. The player’s backdrop is the cover drawn into a 12 x 12 picture and stretched over the screen with a tint: bilinear sampling per pixel, with a division, a floor and twelve conversions between VFP and core registers per pixel, about 107 ns a pixel on an A9 (77% of the raster’s CPU time in a swipe, -DZRT_RASTER_PROFILE by command kind).

What changed (engine only; the program is unchanged):

  • The GPU layer only while a shader is drawn (hal_sdl.cpp layers_follow, window_kind). The window is the CPU’s (the raster in the window’s buffer) and becomes a GL window at the first frame that draws a SHADER, then the CPU’s again once frames without one rest (no damage), or after 60 of them: the change back shows nothing new for about 380 ms (the switch 30 ms, the first CPU frame 70 to 110 ms, the compositor the rest), which a still screen hides. The GL context, its programs and textures are kept (SDL’s EGL library held with SDL_GL_LoadLibrary); EGL cannot connect to a buffer queue the CPU holds and no public API lets it go, so the window calls the ANativeWindow’s own perform(NATIVE_WINDOW_API_DISCONNECT, NATIVE_WINDOW_API_CPU) (AOSP system/window.h, the interface the GPU drivers are built against; if it fails, the program keeps its CPU picture: shaderAvailable() turns false). A window made again keeps the fullscreen flag: one made without it takes the immersive mode away, and asking again waits 500 ms (SDLActivity.sendCommand).
  • The GPU layer renders once. The damaged rows are rendered with holes of alpha byte 255 (the raster’s own pixels leave it at 0); only the rectangles that commands paint over a shader (the time, the card, the status bar: about a fifth of the screen, up to 16 rectangles, close ones joined) are rendered again with white holes for their alpha; the rows go to the texture as they are (the present shader takes B, G, R and 1 - A: no conversion; the upload of the screen takes 11 to 12 ms, into one texture or two in turn alike). GL steps are marked for ZINC_PRESENT_STATS (raster, upload, draw, swap).
  • Bilinear images, separated (raster.cpp LinRows): the horizontal blend of a source row is computed once per row and kept while the pixel rows below sample the same two source rows, and the columns’ taps once per command; the same floats in the same order, so no pixel changes (the desktop frames are byte-identical, image_* tests). On ARM an opaque picture painted whole takes NEON, one channel of four pixels per register (bit-exact against the scalar form, 80,000 random pixels on the device): 107 ns a pixel became 53; the raster of a swipe frame 66 ms (p50) became 32.
  • A release APK for one’s own device: zinc export <dir> --target android --apk --release [--abis armeabi-v7a] runs assembleRelease (not debuggable, the engine built RelWithDebInfo and stripped), signed with the upload key when one is named, else with the debug keystore, so adb install -r replaces a debug build and keeps the app’s data. A release build reads only ZINC_PRESENT_STATS and ZINC_GL_STATS, from zinc.env in the app’s external files directory (adb push zinc.env /sdcard/Android/data/<id>/files/; a release refuses run-as).
zinc export examples/music --target android -o dist/music-android --apk --release --abis armeabi-v7a   # 16.2 MB
adb install -r dist/music-android/app/build/outputs/apk/release/app-release.apk

Left (backlog/later.md): the carousel at 20 to 30 fps is the raster of the whole screen each frame (32 ms p50 on four A9 cores: rounded rectangles blended over the screen, the glyph sweep); a slide kept as a layer and moved by the compositor would make it 60.

Plugins on Android (ZN-628, 2026-10-17)

A plugin reaches an app through its targets.android entry of plugin.json (docs/plugins.md “Phones”): C++ compiled into libmain.so, Java and Kotlin compiled by Gradle, Gradle dependencies (AndroidX: android.useAndroidX), manifest permissions and fragments, minSdk, a lifecycle subscriber that ZincActivity calls, and class: a Java or Kotlin class behind the plugin’s spec through the JNI bridge zinc native-gen --android writes. zinc:system has an Android backend (notifications, the share sheet, opening URLs, the document picker, deep links). Proven on an emulator (API 37, arm64, the AVD Pixel_10): tests/data/mobile-sqlite (zinc:sqlite, a database file kept between starts), tests/data/mobile-plugin (a Kotlin class, callbacks from another thread, the lifecycle subscriber), tests/data/mobile-services (the share sheet, a URL opened in Chrome, Android 13’s notification prompt then a notification, a tap’s intent, the document picker, deep links warm and cold). Not run on the Nexus 7: another agent’s app was in front during the session. Seen on the emulator: the software frame’s red and blue are swapped (sky-600 buttons draw brown; iOS draws them blue), the window buffer’s format there is not the Nexus 7’s BGRA (hal_sdl.cpp anw_present, later.md).

Packages and signing (ZN-629, 2026-10-17)

zinc export examples/hello --target android -o /tmp/hello-apk --apk --abis arm64-v8a      # assembleDebug, zipalign -c -P 16, the ELF check
ZINC_ANDROID_KEYSTORE=upload.jks ZINC_ANDROID_KEY_ALIAS=upload ZINC_ANDROID_STORE_PASSWORD=... \
  zinc export examples/hello --target android -o /tmp/hello-aab --aab                      # bundleRelease signed with the upload key, the ELF check
zinc export examples/hello --target android --aab --dry-run                                 # every command, none run
  • The upload key is named at build time: app/build.gradle has signingConfigs.upload reading -Pzinc.keystore, -Pzinc.keyAlias (or ZINC_ANDROID_KEYSTORE, ZINC_ANDROID_KEY_ALIAS) and the passwords from ZINC_ANDROID_STORE_PASSWORD, ZINC_ANDROID_KEY_PASSWORD; the release build type takes it when a keystore is named, else the debug keystore (Play refuses a debug-signed bundle; the export says so). zinc.json signing.android holds a path and an alias, and refuses a password. Play App Signing re-signs what users install.
  • The 16 KB check: zinc unzips lib/*.so (base/lib/*.so of a bundle) and reads each ELF’s program headers; a 64-bit library with a LOAD segment aligned below 16 KB fails the export, a 32-bit one is reported (Play’s rule is for 64-bit ABIs). An APK also goes through zipalign -c -P 16 4 of the newest build-tools (the libraries stored at 16 KB boundaries, mapped from the APK).
  • When JAVA_HOME is not set, Android Studio’s JDK is used (Gradle 8.14 does not run on the JDK 26 of Homebrew).

Proven on the API 37 arm64 emulator (AVD Pixel_10, headless): --apk --abis arm64-v8a for hello in 58 s (warm ccache), libmain.so LOAD aligned to 0x4000 (zinc’s check and llvm-readelf -lW agree), zipalign passed; installed and started, its logcat equals zinc run (29 lines). --aab with a throwaway upload key in /tmp in 55 s: keytool -printcert -jarfile app-release.aab names that key; bundletool 1.18.3 build-apks --connected-device then install-apks put the release (not debuggable) on the emulator, and its logcat equals zinc run too. No password reached the project or the output. tests/t0/export_packages.sh checks the dry-run outputs with every Android and Apple tool a trap.

Video (ZN-751, 2026-10-17)

zinc:video has an Android backend (plugins/video/native/video.android.cpp, docs/plugins/video.md “Phones”): AMediaExtractor and the device’s MediaCodec decoder rendering into an AImageReader of YUV_420_888 (the strides and crop the image gives; ByteBuffers of planar or semi-planar YUV when a decoder refuses the reader), converted to the player’s image on its decode thread. examples/hero exports unchanged; its clip is not in the repository, so it goes into the app’s files directory of a debuggable build:

zinc export examples/hero --target android -o /tmp/hero-apk --apk --abis arm64-v8a
adb install -r /tmp/hero-apk/app/build/outputs/apk/debug/app-debug.apk
adb push examples/hero/media/bbb.mp4 /data/local/tmp/ && adb shell "run-as dev.zinc.hero mkdir -p files/media && run-as dev.zinc.hero cp /data/local/tmp/bbb.mp4 files/media/"
adb shell am start -n dev.zinc.hero/.HeroActivity --es zinc.env "'ZINC_DEMO=video ZINC_VIDEO_LOG=1'"

On the API 37 arm64 emulator (AVD Pixel_10): the decoder is c2.goldfish.h264.decoder, the reader’s picture 640 x 360 is converted in 0.8 to 5.4 ms; 224 pictures in 9.2 s (the clip’s 24 a second), 1 dropped at the start; the program’s 600 frames: work p50 0.26 / p99 11.8 ms, present p50 16.4 ms (60 Hz) (zinc-next-android/hero-video.png). With ZINC_VIDEO_BYTEBUFFER=1 the same decoder gives I420 (colour format 0x13, stride 640, slice height 360) and plays the same. tests/data/mobile-video (two clips of assets/ as a playlist, a jump and a step back) prints the same lines as on the Mac: entries 0, 1, 0 (loop 1), “jumped to 1”, “back to 0”, “video ok: 260 pictures shown, 1 dropped”.

Lifecycle, safe areas, rotation and the keyboard (ZN-625, ZN-626, 2026-10-17)

On the API 37 emulator (a private AVD made from Pixel_10’s config, deleted after), rn-showcase and two small apps (tests/golden/system-signals/lifecycle.tsx, tests/golden/ui/type.tsx exported as apps):

  • Edge-to-edge. The window is the screen (1080 x 2424) and the program draws under the bars; SDL’s legacy theme painted them black over it (ZincActivity: FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS, transparent bars, icons dark on a light theme). The safe area is the bars and the cutout: SDL’s own counted the gesture zones and padded a portrait page by 26 dp on each side (the export narrows SDLSurface’s mask). Portrait safearea 47 0 21 0, landscape 46 0 21 47 (the cutout on the left). rn-showcase pads its header and tab bar with useSafeAreaInsets (zinc-next-android/rn-showcase-safe-area.png, rn-showcase-landscape.png).
  • Home and return. The process stays (same pid), the field’s text and the screen are kept, the picture comes back on a new surface. SDL’s pump blocks inside the call that handled the pause (GetLifecycleEventTimeout reads the global paused flag): the shell runs the program’s frames from the WILL_ENTER_BACKGROUND event and lets only SDL’s loop pump, so AppState background reaches the program before the app stops (zinc-next-android/lifecycle-logcat.txt: background at the pause, active 2 s later at the return).
  • Rotation. settings put system user_rotation 1 and back: a resize, Dimensions 808x360, orientation landscape, the new safe area; and a ZINC_INPUT script’s rotate (zinc:sys orientation, setRequestedOrientation) turns the screen to landscape and back.
  • Keyboard. A finger on the Phone field opens Gboard’s phone pad (traits type=phone); the window neither pans nor resizes (adjustResize on an edge-to-edge window: insets only); KeyboardAvoidingView pads by the part of it the keyboard covers and the field stays 8 px above it (rn-showcase-keyboard.png). Keys tapped on Gboard (1, 2, 1, delete, 3) give 123; back hides the keyboard and the page returns. type.tsx typed on Gboard’s keys (z, i, n, c, delete, then !) shows zin! in the field and below it, as its golden (type-gboard.png). adb shell input text zinc gives Zin! instead: the injected keys go through the IME, which capitalises the start.

Edit this page on GitHub ↗