Android Native
Lumaris ships as a normal Maven artifact. Add a repository, add a dependency, call one initialiser with your licence key — and from there build either of the two things the SDK is for. Which artifact you add is the tab; what you build with it is the section.
- Minimum SDK
- 28 (Android 9)
- Artifact
- com.apima.lumaris:lumaris
- Version
- 0.4.0
- ABIs
- arm64-v8a · armeabi-v7a · x86_64
- Call provider
- Optional — Agora today
Choose your integration
The core artifact, with no call provider linked. It gives you two things: a finished makeup screen you open in one call, and the provider-agnostic session that hands you the filtered frame and the delay-matched microphone so you can publish them yourself — into WebRTC, LiveKit, Daily, Twilio, or a recorder. Set up once, then build either or both.
Setup
The same four steps whichever of the two you build.
Add the repository
Lumaris is served from its own Maven repository. It is public and needs no credentials — no token, no username, nothing to keep out of source control.
settings.gradle.kts dependencyResolutionManagement { repositories { google() mavenCentral() maven { url = uri("https://maven.apimastudio.com") } }}Add the dependency
The makeup engine on its own: camera, face tracking, the native landmark pipeline and the shader layers. It links no call provider at all.
app/build.gradle.kts dependencies { implementation("com.apima.lumaris:lumaris:0.4.0")}About 6 MB across three ABIs. If you are integrating with Agora, use the artifact on the Agora tab instead of this one — not both.
Check your minimum SDK
Face tracking needs Android 9 (API 28) or later, and the renderer needs OpenGL ES 3.0.
app/build.gradle.kts android { defaultConfig { minSdk = 28 }}If your app supports older Android versions, you can keep your own minimum. On devices below Android 9 the makeup does not start, and the rest of your app runs as normal — check LumarisCallSession.isMakeupActive to hide makeup controls there.
Initialise with your licence key
Call this once, as early as you like. It returns immediately — activation is a network call on a background thread, so there is nothing to await, nothing to catch and no callback to wire up. Your licence key comes from the project page in the Lumaris dashboard. It is safe in source control: it only works together with the package name and signing certificate registered against it.
MyApplication.kt import android.app.Applicationimport com.apima.lumaris.Lumarisclass MyApplication : Application() { override fun onCreate() { super.onCreate() Lumaris.init(this, "lum_your_license_key") }}Makeup stays off until activation succeeds, then turns on by itself. If something is wrong, the SDK says so in logcat under the tag Lumaris — with the reason code and what to do about it.
The makeup screen
The SDK's own full-screen editor: live camera, six makeup categories, AR masks, three saved looks. You open it and read back what the user built. No rendering code of your own.
Ask for the camera permission
The SDK declares CAMERA, INTERNET and ACCESS_NETWORK_STATE in its own manifest, so your manifest needs no edit — they merge in. The runtime request is still yours to make, because when to ask a user is your product's decision, not ours.
ProfileActivity.kt private val requestCamera = registerForActivityResult( ActivityResultContracts.RequestPermission(),) { granted -> if (granted) Lumaris.openSettings(this)}private fun openMakeup() { val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED if (granted) Lumaris.openSettings(this) else requestCamera.launch(Manifest.permission.CAMERA)}Open the screen
One call, from wherever your own UI puts it — a row on a profile page, a button in a call lobby. The screen is self-contained: the user sees themselves, tries looks and everything is saved as they go. There is no result to collect.
ProfileActivity.kt binding.makeupRow.setOnClickListener { Lumaris.openSettings(this)}Read the look back
Three saved slots, one of them marked as the default. defaultLook is never null, so there is no empty state to handle — a user who has never opened the screen still gets something sensible.
ProfileActivity.kt val look = Lumaris.defaultLook(this)look.namelook.maskEnabledlook.categoriesval all = Lumaris.looks(this)Optional: give first-time users something to see
On a fresh install the default slot is a blank slate nobody finds on their own. This fills it once with a randomly chosen, fully-on look. Every call after the first is a no-op — including for a user who later turns everything back off deliberately, which must stick.
MyApplication.kt Lumaris.init(this, "lum_your_license_key")Lumaris.randomizeDefaultLookIfEmpty(this)Optional: watch the licence state
Purely informational — the SDK gates itself and needs nothing from you here. Worth wiring into a debug screen while you integrate, so a rejected key is visible without reading logcat.
DebugActivity.kt lifecycleScope.launch { Lumaris.licenseState.collect { state -> when (state) { is LicenseState.Active -> showBadge(state.grade) is LicenseState.Rejected -> showError(state.reasonCode, state.message) else -> Unit } }}
Into your own video pipeline
LumarisCallSession is the provider-agnostic core: camera, face tracking, makeup render, local preview and a microphone already delayed to match the video. It hands you a filtered frame and an audio chunk and has no opinion about where they go. The Agora adapter on the next tab is a thin layer over exactly this and reaches nothing you cannot — use it as the worked example.
Add the call permissions
Camera and network already merge in from the SDK's own manifest. The audio ones are yours, because a call is the only thing that needs them. RECORD_AUDIO and CAMERA both need a runtime request before you start the session.
AndroidManifest.xml <uses-permission android:name="android.permission.RECORD_AUDIO" /><uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /><uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />Start the session with two callbacks
Everything the SDK does is behind this one call. localPreview is the view group the local user sees themselves in; the two callbacks are where your provider takes over.
CallActivity.kt LumarisCallSession.start( context = this, lifecycleOwner = this, localPreview = binding.localVideoContainer, onComposedFrameTexture = { textureId, width, height, eglContext, timestampNs -> // The SDK's GL render thread, with eglContext already current. // textureId is overwritten by the next frame: copy, then push. val slot = myTexturePool.next(width, height) myTexturePool.blit(from = textureId, into = slot) myProvider.pushVideo(slot, width, height, eglContext, timestampNs) }, onAudioChunk = { chunk, sampleRate, channels -> // 16 kHz mono PCM16, 10 ms, already delayed to match the video. myProvider.pushAudio(chunk, sampleRate, channels) },)The video callback runs on the SDK's GL render thread with its EGL context current, and the texture it gives you is overwritten by the next frame. Copy it into your own texture before returning — the Agora adapter keeps a small round-robin pool and blits into it, and skipping that step is torn frames at the far end, not a crash.
Un-mirror what leaves the device
The local preview is mirrored, because a self-view that is not reads as wrong. What you publish must not be: the person on the other end should see you the way anyone across a table would. Check isMirrored rather than assuming — it changes with the camera.
CallActivity.kt val outgoing = Matrix()if (LumarisCallSession.isMirrored) { outgoing.postScale(-1f, 1f, 0.5f, 0.5f)}Wire the lifecycle
pause suspends the GL render thread and the microphone; the camera pauses on its own through the lifecycleOwner you passed to start. Backgrounding is a pause rather than something the SDK fights to survive — keeping capture alive in the background would mean a permanent camera-in-use notification you never asked for.
CallActivity.kt override fun onResume() { super.onResume() LumarisCallSession.resume()}override fun onPause() { super.onPause() LumarisCallSession.pause()}override fun onDestroy() { super.onDestroy() LumarisCallSession.stop()}Control it while it runs
Apply a saved look, turn makeup off and on, or flip between the front and rear camera — all live, with no restart. Apply a look explicitly after start: without it the session runs with nothing applied and only whatever the catalogue enables by default shows, which is how a user's saved AR mask appears to go missing.
CallActivity.kt LumarisCallSession.applyLook(Lumaris.defaultLook(this))LumarisCallSession.setMakeupEnabled(false)LumarisCallSession.switchCamera()
LumarisCallSession is public API and stays that way — it is the same surface the Agora adapter on the next tab is built on, so that adapter doubles as a complete worked example of everything in this last section. Targeting a provider we have not covered yet? Get in touch and we will point you at the right pieces.
Lumaris publishes the makeup-filtered frame onto Agora's own custom video track, and holds your microphone back to match the video's delay so lip sync survives. What the local user sees is exactly what the remote party receives — there is no separate raw self-view. Agora is optional and lives in its own artifact: the core SDK links none of it.
Add the repository
The same public repository as the standalone path. No credentials.
settings.gradle.kts dependencyResolutionManagement { repositories { google() mavenCentral() maven { url = uri("https://maven.apimastudio.com") } }}Add the Agora artifact
This one artifact brings both the Lumaris engine and the Agora RTC SDK with it, so it is the only Lumaris line you need.
app/build.gradle.kts dependencies { implementation("com.apima.lumaris:lumaris-agora:0.4.0")}Do not also add com.apima.lumaris:lumaris — this artifact already depends on it. The split exists so apps without a call provider are not made to carry Agora's 121 MB.
Add the call permissions
Camera and network already merge in from the SDK's own manifest. The audio ones are yours. RECORD_AUDIO and CAMERA both need a runtime request before the call starts.
AndroidManifest.xml <uses-permission android:name="android.permission.RECORD_AUDIO" /><uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /><uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />Initialise with your licence key
Identical to the standalone path — once, as early as you like, nothing to await. Makeup stays off until activation succeeds.
MyApplication.kt import android.app.Applicationimport com.apima.lumaris.Lumarisclass MyApplication : Application() { override fun onCreate() { super.onCreate() Lumaris.init(this, "lum_your_license_key") }}Create the custom tracks
Two tracks: one for the filtered video, one for the delay-matched audio. Create them before joining, because their ids go into the join options in the next step.
CallActivity.kt val engine = RtcEngine.create(this, AGORA_APP_ID, eventHandler)engine.enableVideo()val tracks = LumarisAgoraVideo.createTracks(engine)Join with the custom tracks published
The four flags that matter are the two pairs turning Agora's own capture off and the custom tracks on. Agora must not also grab the camera or the microphone — Lumaris owns both, and two things competing for one camera is a black frame.
CallActivity.kt val options = ChannelMediaOptions().apply { clientRoleType = Constants.CLIENT_ROLE_BROADCASTER channelProfile = Constants.CHANNEL_PROFILE_COMMUNICATION publishCameraTrack = false publishCustomVideoTrack = true customVideoTrackId = tracks.videoTrackId publishMicrophoneTrack = false publishCustomAudioTrack = true publishCustomAudioTrackId = tracks.audioTrackId autoSubscribeVideo = true autoSubscribeAudio = true}engine.joinChannel(token, channelName, uid, options)Start Lumaris
Pass the view group the local user should see themselves in. Lumaris renders into it directly and Agora is never asked to render the local video, because the frame Agora would render is the one Lumaris already drew. You can call this before or after joinChannel — frames pushed on a track that has not joined yet simply are not sent anywhere.
CallActivity.kt LumarisAgoraVideo.start( context = this, lifecycleOwner = this, engine = engine, tracks = tracks, localPreview = binding.localVideoContainer,)LumarisAgoraVideo.applyLook(Lumaris.defaultLook(this))Apply a look explicitly after start. Without it the session runs with nothing applied, and only whatever the catalogue happens to enable by default shows — which is why a user's saved AR mask can appear to go missing.
Wire the lifecycle
pause suspends the GL render thread and the microphone; the camera pauses on its own through the lifecycleOwner you passed to start. Backgrounding is a pause rather than something the SDK fights to survive.
CallActivity.kt override fun onResume() { super.onResume() LumarisAgoraVideo.resume()}override fun onPause() { super.onPause() LumarisAgoraVideo.pause()}override fun onDestroy() { super.onDestroy() LumarisAgoraVideo.stop() RtcEngine.destroy()}Control it during the call
Turn makeup off and on, switch to another saved look, or flip between the front and rear camera — all mid-call, with no restart. The remote party sees each change as it happens.
CallActivity.kt LumarisAgoraVideo.setMakeupEnabled(false)LumarisAgoraVideo.applyLook(Lumaris.looks(this)[1])LumarisAgoraVideo.switchCamera()
This adapter is about 400 lines over LumarisCallSession, and it is the shape every future provider adapter follows. If you need one that is not Agora, the Standalone tab's last section is the same extension point it is built on.