WhatsCanvas Android Integration Guide¶
This guide describes how to embed WhatsCanvas in an Android application using
OpenGL ES 3, GLSurfaceView, CMake, and JNI. The runnable implementation is in
the repository's platforms/android/ directory; this document defines the integration
and lifecycle contract that a production host should follow.
WhatsCanvas does not currently publish an Android AAR. Integrate it from source or use the checked-in Android application as the starting module.
Integration Architecture¶
The Android host owns the window, EGL context, render thread, and presentation:
Activity
-> GLSurfaceView
-> GLSurfaceView.Renderer (GL thread)
-> per-view JNI native handle
-> wsc::Canvas::Backend::OpenGLES
-> GLSurfaceView default framebuffer
The important ownership boundary is that Android owns EGL and buffer swapping;
WhatsCanvas records and executes drawing commands into the framebuffer that is
current during GLSurfaceView.Renderer.onDrawFrame.
Supported Configuration¶
The reference application currently uses:
| Component | Version / requirement |
|---|---|
| OpenGL ES | 3.0 or newer |
| Android Gradle Plugin | 7.2.2 |
| Gradle | 7.4 |
| Kotlin | 1.7.20 |
| compile / target SDK | 33 |
| min SDK | 21 |
| NDK | 21.4.7075529 |
| CMake | 3.18.1 |
| C++ | 17 |
| ABIs | armeabi-v7a, arm64-v8a, x86_64 |
These exact build-tool versions describe the checked-in sample rather than a permanent library requirement. OpenGL ES 3 and C++17 are the runtime and source requirements that an embedding application must preserve.
1. Add the Native Build¶
Enable only the OpenGL ES backend in the Android CMake project. The sample uses
the following shape in platforms/android/app/src/main/cpp/CMakeLists.txt:
set(WHATSCANVAS_BUILD_OPENGL OFF CACHE BOOL "" FORCE)
set(WHATSCANVAS_BUILD_OPENGLES ON CACHE BOOL "" FORCE)
set(WHATSCANVAS_BUILD_SOFTWARE OFF CACHE BOOL "" FORCE)
set(WHATSCANVAS_BUILD_DEMO OFF CACHE BOOL "" FORCE)
set(WHATSCANVAS_BUILD_BENCHMARKS OFF CACHE BOOL "" FORCE)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
find_library(ANDROID_LIBRARY android REQUIRED)
find_library(EGL_LIBRARY EGL REQUIRED)
find_library(GLES3_LIBRARY GLESv3 REQUIRED)
find_library(LOG_LIBRARY log REQUIRED)
set(WHATSCANVAS_OPENGLES_LIBRARIES
"${EGL_LIBRARY};${GLES3_LIBRARY}"
CACHE STRING "" FORCE)
add_subdirectory("${WHATSCANVAS_ROOT}" whatscanvas)
add_library(my_android_renderer SHARED my_renderer_jni.cpp)
target_link_libraries(my_android_renderer PRIVATE
WhatsCanvas::OpenGLES
${ANDROID_LIBRARY}
${EGL_LIBRARY}
${GLES3_LIBRARY}
${LOG_LIBRARY}
${CMAKE_DL_LIBS})
Set WHATSCANVAS_ROOT to the repository root. The relative path in the sample
depends on its checked-in directory layout and should not be copied unchanged
into a differently structured application.
The sample sets WHATSCANVAS_ENABLE_FREETYPE_RASTERIZER=OFF and
WHATSCANVAS_ENABLE_OPENTYPE_SHAPING=OFF to keep the first APK small, so text
falls back to stb_truetype rasterization and simple shaping. An application
that needs FreeType/HarfBuzz shaping should enable and package those
dependencies deliberately, then validate complex scripts, APK size, and every
ABI.
2. Configure Gradle¶
Connect the application module to CMake and select the ABIs that are actually distributed:
android {
compileSdkVersion 33
ndkVersion "21.4.7075529"
defaultConfig {
minSdkVersion 21
targetSdkVersion 33
externalNativeBuild {
cmake {
cppFlags '-std=c++17', '-fexceptions', '-frtti'
arguments '-DANDROID_STL=c++_shared'
}
}
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.18.1'
}
}
}
Keep libc++_shared.so in the packaged APK when using
-DANDROID_STL=c++_shared. If the host application chooses a different ABI
set, dependencies and native libraries must use the same set.
3. Create the OpenGL ES Host¶
Configure GLSurfaceView before installing its renderer:
setEGLContextClientVersion(3)
setEGLConfigChooser(8, 8, 8, 8, 24, 8)
setRenderer(canvasRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
Declare the GLES requirement in AndroidManifest.xml so incompatible devices
are filtered or rejected before the renderer starts:
The reference host requests 8/8/8/8 + depth 24 + stencil 8. Current
anti-aliased path clipping uses clip masks rather than stencil operations, so a
stencil buffer is conservative rather than universally required. If the host
chooses a smaller EGL configuration, validate every feature it uses.
Load the JNI library from the renderer class or another initialization point:
4. Use One Native Renderer per View¶
Do not store the active Canvas, images, dimensions, or frame statistics in
process-wide JNI globals. Each GLSurfaceView should own an opaque native
handle whose C++ object owns all resources for that view.
The reference contract is:
| Kotlin/JNI operation | Native responsibility |
|---|---|
nativeCreate() |
Allocate a renderer object; do not create GL resources yet. |
nativeSurfaceCreated(handle) |
Load GLES procedures while the new context is current. |
nativeResize(handle, width, height, density) |
Set the viewport and create/initialize the sized Canvas. |
nativeRender(handle, elapsed) |
Execute one complete frame. |
nativeDestroy(handle) |
Finalize GL resources and delete the renderer on the GL thread. |
This avoids cross-view state corruption and makes context recreation explicit. Treat the handle as thread-confined while rendering; the UI thread should only create a handle while the GL thread is paused.
5. Initialize Canvas on the GL Thread¶
onSurfaceCreated runs with the GLES context current. Load functions there:
void* loadOpenGlesProcedure(const char* name)
{
if (const auto procedure = eglGetProcAddress(name)) {
return reinterpret_cast<void*>(procedure);
}
return dlsym(RTLD_DEFAULT, name);
}
const bool loaded = wsc::Canvas::loadOpenGL(&loadOpenGlesProcedure);
In onSurfaceChanged, use physical pixel dimensions for the Canvas and apply
Android density separately:
glViewport(0, 0, width, height);
const float safeDensity = std::isfinite(density) && density > 0.0f
? density : 1.0f;
auto canvas = wsc::Canvas::create(
wsc::Canvas::Backend::OpenGLES, width, height);
canvas->setDevicePixelRatio(safeDensity);
if (!canvas->initializeContext()) {
// Stop rendering and report initialization failure.
}
Accept every finite positive Android density, including low-density values
below 1.0; use 1.0 only as a fallback for invalid input. Layout can then use
logical dimensions width / density and height / density while the renderer
retains a physical-resolution target. Read density again during
onSurfaceChanged instead of permanently capturing the value when the View is
constructed.
6. Render and Present¶
Render only from onDrawFrame or work explicitly queued to the GL thread:
glDisable(GL_DEPTH_TEST);
glDisable(GL_SCISSOR_TEST);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
canvas->beginFrame();
// Canvas draw calls...
canvas->endFrame();
GLSurfaceView presents after the callback returns. The application should not
perform its own eglSwapBuffers inside the renderer callback.
7. Handle Pause and Resume¶
GL resources must be finalized while their EGL context is still current. Queue
destruction before pausing the GLSurfaceView:
override fun onPause() {
canvasView.releaseNativeRenderer() // queueEvent { nativeDestroy(handle) }
canvasView.onPause() // waits for the GL thread to pause
super.onPause()
}
override fun onResume() {
super.onResume()
canvasView.ensureNativeRenderer()
canvasView.onResume()
}
Android's GLSurfaceView processes queued events before releasing the EGL
surface/context during onPause. nativeDestroy should call
Canvas::finalizeContext() before deleting the Canvas; finalization also clears
renderer-owned resources.
On resume, create a fresh native renderer. Expect Android to call
onSurfaceCreated and onSurfaceChanged again, and recreate images, glyph
atlases, fonts, clip resources, and other context-bound state.
Do not finalize a GLES Canvas from Activity.onDestroy on the UI thread. At
that point the EGL context may no longer be current.
Unexpected EGL context loss¶
Orderly pause/resume is not the same as involuntary EGL context loss. After the old context has already been lost, do not call GL deletion/finalization for its objects against the newly created context. The current sample validates the orderly pause path but does not yet expose a Canvas "abandon context" operation for this case. A production host that must recover from unexpected loss needs a separate abandoned-resource path, followed by complete renderer recreation.
8. Fonts and Text¶
Android 10 / API 29 added the NDK system font matcher. The reference host loads the matcher dynamically so the APK remains compatible with min SDK 21:
- API 29+: match a Latin and a CJK character through
AFontMatcher, then register the returned path, collection index, weight, and slant. - If matching or registration fails, including on API 29+, probe the legacy Roboto and Noto CJK locations used by common Android system images. This is also the primary compatibility path on API 21-28.
- Configure a
FontFallbackChainafter registration.
The two probe characters do not enumerate a device's complete multilingual fallback universe. System font files and family contents can vary by Android version and OEM. For deterministic brand typography, package licensed font assets with the app and register those files or bytes instead of depending only on system paths.
If text is missing, inspect WhatsCanvas logcat messages for font registration,
verify that the font contains the requested glyphs, and confirm that the R8
glyph atlas is supported by the active GLES context.
9. Images and Other GL Resources¶
Images uploaded from RGBA data, decoded assets, wrapped external textures, and
render targets belong to the current GL context. Destroy them before
Canvas::finalizeContext() and recreate them after context initialization.
External textures remain owned by the host. Their lifetime must cover every frame in which WhatsCanvas samples them, and they must be compatible with the current GLES context.
10. Orientation and Responsive Layout¶
onSurfaceChanged can occur without Activity recreation when orientation and
screen-size changes are handled in the manifest. Always update:
glViewportwith physical pixels;- Canvas size/context as required by the host strategy;
- logical layout width and height derived from density;
- cached size-dependent application geometry.
The reference scene uses a 2-by-4 grid in portrait and 4-by-2 in landscape, with a compact card layout for short landscape viewports.
11. Build and Validate¶
From platforms/android/ on Windows:
Install and launch the debug APK:
adb install -r app\build\outputs\apk\debug\app-debug.apk
adb shell am start -W -n com.whatscanvas.demo/.MainActivity
adb logcat -s WhatsCanvas:V AndroidRuntime:E libc:F
Validate at least:
- Latin and CJK text;
- fills, strokes, round caps, dashes, clipping, gradients, and blend modes;
- raw and decoded images used by the product;
- portrait and landscape resizing;
- background/foreground and repeated context recreation;
- every distributed ABI on an emulator or physical device;
- one real target device/GPU before release.
The source-level cross-platform gates remain relevant because geometry,
anti-aliasing, text atlas, and command generation are shared by Android and
other platforms. See doc/CROSS_PLATFORM_VALIDATION_MATRIX.md in the source
tree.
Troubleshooting¶
Blank or black surface¶
- Confirm GLES 3 was requested before
setRenderer. - Confirm
Canvas::loadOpenGLandinitializeContextsucceeded. - Confirm rendering happens on the GL thread with the context current.
- Check width and height passed to Canvas and
glViewport.
Text is absent¶
- Check the font registration paths and face indices in logcat.
- Verify fallback families and glyph coverage, especially for CJK.
- Ensure the glyph atlas texture was recreated after context loss.
Dark seams on round caps or dashes¶
Use a version containing the round-cap shared-topology fix in
StrokeTessellator. This was a cross-platform analytic-AA issue, not an
Android-only GLES workaround.
Emulator exits during app launch or resume¶
Distinguish an application crash from an emulator-host graphics failure. If the
entire AVD disappears and no AndroidRuntime/libc crash is recorded, retry
with another emulator GPU backend. On the reference Windows machine,
-gpu host was stable while SwiftShader state transitions were not.
Current Limitations¶
- No published AAR or Prefab package.
- No Android CI or managed emulator gate yet.
- No checked-in touch/input abstraction.
- Encoded image decoding is available in the library but is not demonstrated by the current Android scene.
- Vulkan Android presentation is not wired into this host.
- Lifecycle validation is currently manual rather than an instrumentation test.
- Unexpected EGL context loss still needs an explicit abandoned-resource API; only orderly pause/resume recreation is implemented by the sample.
Relationship to iOS¶
Android and iOS should keep separate host-integration documents because their surface, context, lifecycle, font, and packaging contracts differ. Shared Canvas behavior belongs in the platform-neutral library documentation.
The current Apple-platform boundary is documented in
iOS Build Notes. When an in-repository UIKit/Metal host is
implemented, add IOS_INTEGRATION.md beside this guide and keep
IOS_BUILD_NOTES.md focused on toolchain and backend build configuration.