Android and Apple targets: structure, lifecycle, assets and evidence

CNA snapshot 009d40f5  ·  Deep Dives › Cross-platform engineering  ·  source links pinned to 009d40f5

✓

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 009d40f5; 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 at 009d40f5, and Sharp Runtime next @ 41b918c9. Nothing was built, run on an emulator or device, or launched on macOS/iOS; the Android emulator results are CNA's own records of an earlier revision.

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 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 is macOS-only; and iOS allows only SDL_RENDERER 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.

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, and tested only in portable form

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):

EventWhat Game doesWhy
WillEnterBackgroundmarks itself suspended and sets IsActive falseiOS terminates an application that submits GPU work in the background and Android destroys the rendering surface, so the loop must stop drawing
DidEnterForegroundclears 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
LowMemorylogs a warning, nothing elseXNA has no hook for it; the game decides what to release
Terminatingclears 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. That is source-level policy plus a portable test; it does not show that each mobile host delivers the events in this order, or 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; nothing in the tree does that at this snapshot.

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 and the base stream only: the .cnb tier and the loose tier (ResolveAssetPath's .cnj and extension probing, then the image loader's std::ifstream) test 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 source paths, not device observations: they need a real Activity and a packaged AssetManager, and no test at this snapshot loads content through ContentManager inside an APK. On the desktop, including macOS, a relative content root is resolved against the process working directory first and then through the platform file service; TitleLocation reports the platform's base path. No CNA code chooses an application bundle's Resources directory, 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).

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 of an earlier revision", not a reproducible Android gate and not current graphics verification; no Android APK of any CNA application is published.

macOS: two declared routes

METAL keeps its Objective-C++ out of every other configuration: the .mm sources join the renderer archive, and AppKit, Metal, QuartzCore and Foundation are linked, only when METAL is the configured renderer (Metal CMake). That is a sound portability seam, but source isolation is not native validation. The Metal CTest prefix needs the same 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; only Metal_Smoke and Metal_Capabilities require a METAL build (Metal examples CMake). A quoted Metal result must therefore carry the renderer identity of the build that produced it.

Two workflows are configured for macOS 14, and both pin a sharp-runtime revision that predates the components this snapshot requests, so by a static reading neither can configure at this snapshot (CNA-BUG-199). metal-macos-ci.yml configures METAL, enables Metal validation variables and runs the Metal test prefix plus a renderer-definition control (its push path filter names files that no longer exist, so it effectively triggers on pull requests and the paths that remain). 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. Earlier Apple results from the Metal renderer's original feature branch are history for that revision: the renderer's interfaces, ownership and registration changed afterwards. docs/metal-renderer.md keeps the record: GitHub Actions run 29814126178 built that tree on macos-14 with Xcode 15.4 and passed 136 of 143 tests; the seven failures were draw and readback cases (the two PBR goldens, DrawUserPrimitives, the SpriteBatch custom effect, multiple render targets and both MSAA cases) that saw only the clear colour or a binary edge. The adaptation that followed refuses backbuffer readback (MetalRenderer::ReadBackbuffer throws NotSupportedException) and disables MSAA, and the repository retains no logs or artifacts of the run. The deployment floors (macOS 13.3, iOS 16.3) are on Platforms: macOS.

iOS: final link and one simulator frame

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 builds for an arm64 device and an arm64 simulator with tests, examples and networking off. 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 before terminating the application. No physical device is run. Like the macOS jobs, the workflow checks CNA out against an older pinned sharp-runtime revision than this snapshot's component closure needs, so by a static reading it stops at configure time (CNA-BUG-199): it describes what the route is configured to do, not a passing run, and no run record was retrieved for this page.

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; retained artifacts from the Apple and Metal workflows; 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

  1. Entrypoint.hpp: why mobile entry points are a build concern.
  2. Game.cpp: the lifecycle cases in PollEvents and WaitWhileSuspended.
  3. ContentManager.cpp and Sdl3SystemServices.cpp: the Android asset route.
  4. AndroidSensorBridge.cpp: the NDK sensor thread model.
  5. ApplePlatform.cmake and apple-ci.yml: the Apple gates and what the workflow checks.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture