Android and Apple targets: structure, lifecycle, assets and evidence
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the Android demo project, the devices, platform, content, storage and runtime sources, the Apple CMake and workflows, and CNA’s Apple records (docs/apple-platforms.md, docs/metal-renderer.md, plans/plan_apple_m4.md) at c1c316b9, and Sharp Runtime next @ 41b918c9. Nothing was built, run on an emulator or device, or launched on macOS/iOS for this page; the Android emulator results and the Mac mini M4 and iOS Simulator results are CNA’s own records, not re-run here.
Platform support is not one fact: a source tree can contain platform branches, cross-compile, run in an emulator, package an application, draw a frame and survive real hardware, or stop at any rung in between. This page describes where CNA's Android, macOS and iOS routes stand at this snapshot: how the Android application is put together and where the Android-specific code lives, how Game handles mobile lifecycle events, how content and saves are found on a device, what CNA’s own qualification on a physical Mac mini M4 and in the iOS Simulator measured, what the Apple workflows are configured to check, and which historical results are records of their own revision rather than evidence for this one. The user-level guides are Platforms: Android, iOS and tvOS, Tutorial 82 and Tutorial 109.
Target OS, platform implementation and renderer stay separate
Android, macOS and iOS are target operating systems. All three are served by the SDL3 platform implementation, which is the only one any Apple or Android route configures; iOS is not a CNA_PLATFORM value. The renderer is a third choice: with nothing selected, Android and Apple builds get SDL_RENDERER (Android's CMAKE_SYSTEM_NAME is Android, not Linux, so it does not inherit Linux's OPENGLES3 default); METAL targets macOS and iOS/iPadOS (on iOS it draws into a CAMetalLayer-backed view in SDL’s UIWindow) and CMake rejects it everywhere else; and iOS allows SDL_RENDERER and METAL unless CNA_APPLE_ALLOW_UNVALIDATED_RENDERER=ON is set for an experiment (ApplePlatform.cmake). The same file refuses any other Darwin target: tvOS, watchOS and visionOS fail configuration with a message that names the two supported Apple targets, because nothing has been configured, built or reasoned about for them.
On both mobile systems the operating system owns the process start. The game's main() translation unit must include Entrypoint.hpp, which renames it at preprocessing time: on Android SDL's Java glue locates the game by dlsym() of SDL_main, and on iOS UIKit must start before the game's entry point is called from inside a running application. This is why the SDL3 implementation reports the managedEntrypoint capability and the desktop-only implementations do not. The composition rules are on the cross-platform contract.
How the Android application is put together
The only Android build project in the tree is the Devices demo, a complete Gradle application of 39 files under demo_devices/android: the Gradle wrapper and settings, a manifest, DemodevicesActivity (an SDLActivity subclass), SDL's Java glue under org/libsdl/app, launcher resources, and two native build descriptions. app/build.gradle pins compile and target SDK 35, NDK 30.0.14904198, minimum SDK 24 and the single ABI arm64-v8a. It builds through ndk-build by default and through CMake when the Gradle property BUILD_WITH_CMAKE is set. The CMake route's jni/CMakeLists.txt forces CNA_BUILD_TESTS and CNA_BUILD_EXAMPLES off and adds the CNA root with add_subdirectory, so producing the APK compiles CNA as part of the application and reuses CNA's own .sdl-prebuilt-Android-aarch64 SDL install; CNA's SDL helper forwards ANDROID_ABI, ANDROID_PLATFORM and ANDROID_STL to that SDL build and builds SDL3 as a shared library.
The experimental C ABI library also configures and builds for Android with the NDK alone: libcurl is optional there, and without it the gamer-services and relay transports refuse, as they do in a browser (CBIND-136; a commit record, not a CI job). There is no Android preset, no Android workflow and no CMake check of the API level. The details of the configure route are on configuring CNA: Android.
Android-specific production code is concentrated
Almost all Android-only code is the NDK sensor layer in modules/devices (about 3,300 lines across four sources and six headers). Each AndroidSensorBridge owns an ASensorManager, an ALooper, an event queue and its own worker std::thread, with deliberate stop and join rules so that a destroyed looper is never touched. The Motion backend aggregates six bridges (rotation vector, game rotation vector, gravity, linear acceleration, gyroscope and magnetic field), so the single thread-construction site can produce six concurrent workers for one Motion instance. The sensor mathematics (AndroidMotionMath, AndroidCompassMath, AndroidSensorOrientation) sits outside the __ANDROID__ guards so native tests exercise it on any host; only the I/O boundary is platform-gated. The module's only Android link is target_link_libraries(cna_devices PUBLIC android log) (devices CMake): libandroid.so for the sensor API and liblog.so for disable and destroy diagnostics. The sensor semantics are on device module internals.
CNA does use JNI, in one place: the SDL3 file service resolves a packaged asset name case-insensitively by obtaining the activity through SDL_GetAndroidJNIEnv() and listing directories through the Java AssetManager (Sdl3SystemServices.cpp). Graphics on Android is the default SDL_RENDERER; the only renderer with an explicit Android native-window surface path is WEBGPU (WGPUSurfaceSourceAndroidNativeWindow in WebGPURenderer.cpp), and no record shows it running on Android.
Mobile lifecycle: explicit, with host tests and recorded emulator runs
The platform event contract carries four application-lifecycle transitions, AppLifecycleKind::WillEnterBackground, DidEnterForeground, LowMemory and Terminating (PlatformEvent.hpp). Game reacts to them in its event pump (Game.cpp, PollEvents):
| Event | What Game does | Why |
|---|---|---|
WillEnterBackground | marks itself suspended and sets IsActive false | iOS terminates an application that submits GPU work in the background and Android destroys the rendering surface, so the loop must stop drawing |
DidEnterForeground | clears the suspension, restarts the frame clock and ResetElapsedTime(), sets IsActive true and tells the renderer its surface was invalidated (OnSurfaceInvalidated) | the background period is not gameplay time; without the reset the first frame would run a burst of catch-up updates clamped to MaxElapsedTime |
LowMemory | logs a warning, nothing else | XNA has no hook for it; the game decides what to release |
Terminating | clears the suspension and calls Exit() | it is the last event a mobile process receives, so the loop must leave its wait and reach OnExiting |
The flag is set on every platform but acted on only where isMobilePlatform() is true: there RunLoop() parks the thread in WaitWhileSuspended() instead of ticking, and desktop builds keep the plain loop. The behaviour is a deliberate deviation from FNA, which tracks only IsActive for these events. The portable test GameTest.MobileLifecycleEventsSuspendResumeAndTerminateTheLoop covers the sequence, and the macOS CI job includes it in its filter.
The events also have to reach the pump. SDL3 sends them through SDL_SendAppEvent, which hands them to event watchers only and never queues them, so Sdl3Platform installs an event watch the first time it polls and appends the lifecycle events it collected after the queued events, each delivered once (BL-20; two host tests, one of which fails without the watch). On Android SDL blocks the main thread while the activity is paused, so both halves of a Home-and-return reach the game together when it resumes. On Android, window focus no longer drives IsActive either: SDL focuses its one window when it is created and the activity's own focus changes arrive afterwards, so FocusLost and FocusGained leave IsActive alone there and the lifecycle events decide it, as XNA did on the phone; desktop behaviour is unchanged (BL-21). CNA's commit record measures both on the Android emulator with one game, House Simulator: cold launches are active and accept touch, and Home then return logs deactivated then activated. That is still not a test in the tree, and it does not show that a game's graphics resources survive a real background transition. A useful device or emulator test would drive background, foreground, low memory and process recreation, then check saved state and the reconstruction of graphics resources.
Two phone conventions are handled in the same layer, with host tests rather than device tests. Android's back gesture delivers its key down and up between two input updates, so the SDL3 pump latches it: the next keyboard update reports Escape once, and for a game that has asked for pads the next gamepad update reports Back on player one once, the button Windows Phone games exit on (CBIND-137). And a portrait game runs in portrait: GraphicsDeviceManager resolves a SupportedOrientations of DisplayOrientation.Default from the preferred back-buffer size, creates the device in an orientation the game supports, and the SDL window re-reads the orientation hint on Android (CBIND-138).
Content and saves on a device
On Android a relative content path names a packaged asset, not a file under the working directory (which is commonly / and unreadable to an application). ContentManager.cpp handles that explicitly: under __ANDROID__, ResolveExistingAssetPath leaves a relative path alone instead of walking the working directory, and TryReadAssetBytes reads it through the platform file service's TryLoadFileIgnoringCase. That route serves the .xnb tier, both .cnb tiers (by name and by literal .cnb name, BL-19) and the base stream; the loose tier (ResolveAssetPath's .cnj and extension probing, then the image loader's std::ifstream) still tests std::filesystem::exists, which cannot see APK assets (CNA-BUG-041). The SDL3 file service tries SDL_LoadFile, then the case-insensitive AssetManager resolution described above, and then refuses to fall back to a walk of the host file system for a relative path. TitleContainer follows the same order: std::filesystem first, then the Android asset name through the same service. StorageDevice cannot use HOME on Android (it is not defined), so it asks Sharp Runtime's storage policy for the package's private files directory (SDL_GetAndroidInternalStoragePath(), with /data/local/tmp as a last resort) and keeps its per-game directory beneath it (StorageDevice.cpp, EnsureStorageRoot).
These routes are mostly source paths: they need a real Activity and a packaged AssetManager, and no test in the tree loads content through ContentManager inside an APK. The one device-side observation is CNA's commit record of House Simulator's APK loading all of its .cnb content on the emulator (BL-19). On the desktop, including macOS, a relative content root is resolved under TitleLocation.Path first, as XNA opens content through TitleContainer: when <title path>/<root> is an existing directory it is used, otherwise the root is taken as given, relative to the working directory, and then through the platform file service (CBIND-140). TitleLocation reports the platform's base path. No CNA code chooses an application bundle's Resources directory itself, and the Apple smoke applications load no content, so shipping content inside a macOS or iOS bundle is a layout the application has to arrange and test. The resolution rules themselves are on ContentManager resolution rules.
Recorded Android execution is history for its own revision
CNA's own notes record emulator work from July 2026 (devices-build.md, devices-android.md): a Gradle build of the Devices demo produced a 7.3 MB debug APK, which was installed and launched on the Medium_Phone emulator image and answered synthetic sensor values injected through the emulator console; a screen capture in the same notes shows the demo's own interface drawn by the default SDL_RENDERER, so that earlier revision did present frames on the emulator (no capture is retained, and the later note below says the renderer's Android buildability is currently unverified). The same notes limit the claim: the emulator's console offered no virtual rotation-vector sensor, so the Compass and Motion NDK paths were never driven by injected values, only the SDL-delivered accelerometer and gyroscope. A later note, android-graphics-limitations.md, records an NDK cross-compile that failed inside two Sharp Runtime source files before reaching any CNA graphics code; one of those failures, the use of std::chrono::clock_cast that the NDK's libc++ lacks, is guarded by a library-specific preprocessor split in Sharp Runtime next @ 41b918c9 (read, not built).
Later records come from games rather than the demo. Commits from late September and early October 2026 describe Android emulator runs of House Simulator that found the lifecycle, focus and .cnb defects above (BL-19, BL-20, BL-21) and a game main() that was not renamed to SDL_main when CNA was added as a subdirectory, because the platform definition did not reach the game (BL-13). Another found that models drew as scattered triangles on the emulator: GLES 3.0 has no base-vertex draw, EasyGL emulates it by shifting attribute pointers it reads back, and the emulator's GLES encoder answers those reads from state that does not follow the bound vertex array, so EasyGL now recognises the emulator and rebases the indices on the CPU there (CNA_EASYGL_CPU_BASE_VERTEX=1 forces this anywhere; CBIND-139).
None of this is retained as an artifact: the repository contains no APK, AAB, logcat capture, Android screenshot or machine-readable test result, and no CNA binary has a record of running on physical Android hardware. The honest wording is "recorded emulator execution", not a reproducible Android gate and not current graphics verification; no Android APK of any CNA application is published.
macOS: the Mac mini M4 qualification and the workflows
METAL keeps its Objective-C++ out of every other configuration: the .mm sources join the renderer archive, and Metal, QuartzCore, Foundation and the view framework (AppKit on macOS, UIKit on iOS) are linked only when METAL is the configured renderer (Metal CMake). The Metal CTest prefix needs care: the Metal policy suites are host-portable and are registered as Metal_PortableHelpers in every test-enabled configuration, so ctest -R '^Metal' on a Linux HEADLESS tree passes without executing any Objective-C++, Apple framework, shader compiler or GPU command; the native tests (Metal_Smoke, Metal_Capabilities and the shared pixel fixtures and oracles under the Metal label) exist only in a METAL build (Metal examples CMake). A quoted Metal result must therefore carry the renderer identity of the build that produced it.
CNA's own qualification ran on a physical Mac mini M4 (Apple M4, 16 GB, macOS 27.0.1, Xcode 27, Apple clang 21) in October 2026, recorded task by task in plans/plan_apple_m4.md and summarised in docs/apple-platforms.md. At its close-out every renderer tree that applies to macOS ran its full test suite with no failing case: METAL 11,015/11,015, SOFTWARE 11,084/11,084, OPENGL33 11,916/11,916 with compiled effects on, SDL_GPU on SDL's Metal driver 10,962/10,962, WEBGPU on wgpu-native's Metal backend 10,968/10,968, FNA3D on its Metal driver 10,875/10,875 and SDL_RENDERER 10,940/10,940; the remaining cases skip by name (a capability is absent, another renderer is active, a facility is Linux-only). Those runs were made with the console session locked, so macOS reported every window occluded and on-screen presentation was not observed. For METAL the campaign replaced the earlier refusals with native paths (backbuffer readback, multiple render targets, MSAA, occlusion queries, instancing and multi-stream input, float, packed and DXT formats, cube and volume textures, SpriteBatch-scoped MSL custom effects), and ctest -L Metal passes 261/261 under the Metal validation layers in Debug and Release. CNA's verdict is supported, not primary-production: compiled XNA effects need CNA_METAL_COMPILED_EFFECTS=ON (off by default), custom effects are SpriteBatch-only, PresentInterval.Two presents as One, no soak run has bounded the caches and no Intel Mac was run (docs/metal-renderer.md). The earlier feature-branch run on macos-14 that saw only clear colours is history for its revision; its two causes, disabled readback and a transposed world-view-projection matrix, were fixed in this campaign.
Two workflows run on GitHub's macos-26 runners and check out the sharp-runtime branch CNA's own sibling script chooses. metal-macos-ci.yml configures METAL and runs the Metal test prefix plus a renderer-definition control (its push path filter still names a few files that no longer exist, so it effectively triggers on pull requests and the paths that remain); the hosted GPU is an "Apple Paravirtual device" that cannot run Metal shader validation, so there the tests run with API validation only and one process at a time. apple-ci.yml builds CnaTests on SDL_RENDERER and runs the target-platform, game-window, mobile-lifecycle, storage and desktop-OS suites directly rather than through CTest; it then builds a self-contained cna_macos_smoke.app, lints its Info.plist, uses otool -L to reject any dependency that still points at the build machine (the SDL prebuilt root, Homebrew or /usr/local) in the executable or the embedded frameworks, and launches it until it prints CNA_APPLE_SMOKE_OK. No green hosted run of either workflow is recorded after the campaign's last CI change, so the numbers above are the Mac mini M4's, not CI's. The deployment floors (macOS 13.3, iOS 16.3) are on Platforms: macOS; a binary linked against Homebrew's FFmpeg starts only on the macOS that FFmpeg was built for unless video is configured off.
iOS: a Simulator pixel probe and a device build that is not run
The iOS route is ios.cmake with CNA_IOS_SIMULATOR choosing the SDK; SDL is linked statically, and a small Objective-C++ adapter asks UIKit to re-evaluate supported orientations when GraphicsDeviceManager changes them. The smoke application, apple_smoke.cpp, constructs a Game and a GraphicsDeviceManager, requests LandscapeLeft, runs RunOneFrame() and prints CNA_APPLE_SMOKE_OK: one pass through initialisation, events, update, draw and presentation, with no content.
The apple-ci.yml iOS matrix has four legs, SDL_RENDERER and METAL each for an arm64 device and an arm64 simulator, with tests, examples and networking off (CNA_ENABLE_NET=OFF is required, because the iOS SDK ships no libcurl). It checks the archive’s LC_BUILD_VERSION platform, verifies that the final bundle exports SDL_main and carries no build-machine dynamic dependency, and for the simulator boots a device, ad-hoc signs, installs, launches with the console attached and requires the one-frame marker within a bounded interval; the METAL simulator leg also runs cna_ios_pixel_probe, which reads back a clear colour, a SpriteBatch quad and a BasicEffect triangle. No green hosted run of the matrix is recorded after the campaign. CNA’s own iOS evidence comes from the Mac mini M4 (iOS 27 Simulator, iPhone 17 profile). In the first campaign run the probe was exact on 29 of 31 simulator launches, the two all-zero results being the first two after the initial install. The cause was found later: SDL’s UIKit view-frame computation swapped a portrait window’s bounds before UIKit rotated it to the landscape-only orientation set, so the game drew into a 1346x0 view. CNA now carries an SDL patch for iOS builds, and after it the stricter probe (frame 1 and the ten frames after it) was exact in 8 of 8 launches, with the smoke app passing. The device build is final-linked (Mach-O arm64, platform 2, minimum iOS 16.3, system frameworks only) and not run: no physical iPhone or iPad, signing identity or Apple account was available, so there are no device pixels, touch, audio, storage, background/resume or performance observations.
What would raise the evidence
Each of these would move one route up exactly one rung, and should say which rung it proves: an archived Android graphics build with an emulator frame and its logs; an emulator test that drives the lifecycle transitions and checks state and graphics-resource reconstruction; content loaded through ContentManager inside an APK and inside an Apple bundle; a green hosted run of the Apple and Metal workflows with retained artifacts; on-screen presentation and PresentInterval pacing observed on an unlocked Mac, and a Metal soak run; a run on an Intel Mac; and a result from a physical iOS device. tvOS would need a designed host, not an inferred one. The platform-wide status table is on Platforms: status by operating system.
Read in this order
Entrypoint.hpp: why mobile entry points are a build concern.Game.cpp: the lifecycle cases inPollEventsandWaitWhileSuspended.ContentManager.cppandSdl3SystemServices.cpp: the Android asset route.AndroidSensorBridge.cpp: the NDK sensor thread model.ApplePlatform.cmakeandapple-ci.yml: the Apple gates and what the workflow checks.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Platforms: Android · Platforms: iOS and tvOS · Tutorial 82: Android · Tutorial 109: Metal on macOS
- Architecture
- Runtime architecture · Platform architecture
- Internals
- Device module internals · SDL3 platform internals
- Tests and validation
- Test architecture