Android SDK
com.applooma:rtc-android adds voice, video, live streaming and audio rooms to an Android app. The API is Kotlin-first with a Java-friendly surface. You create an engine, join a channel with a token from your own server, and render the video tracks you receive.
Requirements#
| Item | Value |
|---|---|
| Maven coordinate | com.applooma:rtc-android:0.4.24 |
| Minimum SDK | API 23 (Android 6.0) |
| Compile SDK | API 34 |
| Java / Kotlin target | JVM 17 |
| Languages | Kotlin and Java |
Installation#
Add JitPack to your repositories — one of the SDK's audio-routing libraries is published there, and without it Gradle fails with Could not find com.github.davidliu:audioswitch.
dependencyResolutionManagement {repositories {google()mavenCentral()maven { url = uri("https://jitpack.io") }}}
Then add the dependency to your app module.
dependencies {implementation("com.applooma:rtc-android:0.4.24")}
The library requires Java 17 bytecode. If your module still targets an older level, raise it.
android {compileOptions {sourceCompatibility = JavaVersion.VERSION_17targetCompatibility = JavaVersion.VERSION_17}kotlinOptions { jvmTarget = "17" }}
Permissions
The SDK manifest already declares INTERNET, CAMERA, RECORD_AUDIO, ACCESS_NETWORK_STATE, MODIFY_AUDIO_SETTINGS and the Bluetooth permissions, and manifest merging pulls them into your app. You do not need to repeat them, though listing them explicitly is harmless.
<uses-permission android:name="android.permission.INTERNET" /><uses-permission android:name="android.permission.CAMERA" /><uses-permission android:name="android.permission.RECORD_AUDIO" />
CAMERA and RECORD_AUDIO must be requested at runtime
On Android 6.0 (API 23) and above, declaring a dangerous permission is not enough. Ask the user before you join a channel that publishes. Without the grant, publishing fails silently from the user's point of view and the failure is reported through AppEventHandler.onError.
private val permissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->if (granted.values.all { it }) startCall()}permissions.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
Bluetooth headsets
On Android 12 (API 31) and above, BLUETOOTH_CONNECTis a runtime permission, and without it the SDK cannot see or route audio to a Bluetooth headset — the user hears nothing in their earbuds and the phone's own microphone is used. Ask for it in the same dialog as the microphone; a refusal is not fatal (the call simply stays on the phone's speaker or earpiece), so do not block the join on it. Once granted, a connected headset is preferred automatically; use engine.getAudioRoutes() / engine.setAudioRoute(...) for an in-call route picker and AppEventHandler.onAudioRouteChanged to follow changes. The UIKit screens request it for you.
val wanted = buildList {add(Manifest.permission.RECORD_AUDIO)add(Manifest.permission.CAMERA)if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) add(Manifest.permission.BLUETOOTH_CONNECT)}permissions.launch(wanted.toTypedArray())
A minimal call screen#
The activity below fetches a token from your own server, joins, renders remote video and leaves. Your server holds the API secret; the app never does. See Quickstart for the server side.
class CallActivity : AppCompatActivity() {private lateinit var engine: AppEngineprivate val handler = object : AppEventHandler() {// A user is announced BEFORE their camera track arrives, so a tile// created here has nothing to render yet.override fun onUserJoined(user: AppRemoteUser) {addTile(user.uid)}// The track is ready — this is where video actually starts.override fun onTrackSubscribed(user: AppRemoteUser) {tileFor(user.uid).attach(engine, user)}override fun onUserLeft(user: AppRemoteUser) {removeTile(user.uid)}override fun onDisconnected(reason: String?) {finish()}override fun onError(error: Throwable) {Log.e("AppLooma", "call failed", error)}}override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_call)engine = AppEngine.create(applicationContext, "YOUR_APP_ID", handler)lifecycleScope.launch {// Token comes from YOUR server.val res = api.rtcToken(channel = "lobby")engine.joinChannel(res.token,res.wsUrl,AppJoinOptions(role = AppRole.HOST, camera = true, microphone = true))}}private fun hangUp() {engine.leaveChannel()finish()}override fun onDestroy() {engine.destroy()super.onDestroy()}}
Render on onTrackSubscribed, not on onUserJoined
onUserJoined fires as soon as someone is in the channel. Their camera track is subscribed a moment later and raises onTrackSubscribed. If you attach the video inside onUserJoined it is usually null and the tile stays black. This is the most common integration mistake.
AppEngine#
AppEngine is the entry point. Create one per call screen and pass an AppEventHandler.
val engine = AppEngine.create(applicationContext, "YOUR_APP_ID", handler)
The event handler is optional. Pass the application context rather than an activity; the engine keeps it for the life of the call. create throws IllegalArgumentException when the App ID is blank.
Methods
| Signature | Description |
|---|---|
static AppEngine create(Context context, String appId, AppEventHandler? eventHandler = null, AppEngineOptions options = AppEngineOptions()) | Creates an engine. Options pick the audio scenario (CALL or MEDIA) and the camera preset; see AppEngineOptions. Throws IllegalArgumentException if appId is blank. Any number of engines may coexist, one per channel — a PK battle is two engines. |
suspend fun joinChannel(token: String, wsUrl: String, options: AppJoinOptions = AppJoinOptions()) | Connects, then publishes microphone and camera according to options. Suspends until connected. Throws IllegalStateException if already in a channel. Audience members never publish. |
fun joinChannelAsync(token: String, wsUrl: String, options: AppJoinOptions = AppJoinOptions()) | Java-friendly variant. Returns immediately and reports failures through AppEventHandler.onError. |
fun leaveChannel() | Disconnects, clears the remote user list and stops event collection. The engine can join again afterwards. |
fun enableCamera(on: Boolean) | Turns the local camera on or off. Fire-and-forget; a failure such as a missing CAMERA grant arrives on onError. |
fun enableMicrophone(on: Boolean) | Turns the local microphone on or off. Fire-and-forget, with failures on onError. Since 0.4.5, turning it off while music is playing silences only your voice — the music keeps going. |
fun switchCamera() | Flips between the front and back cameras. Does nothing until the camera is on. |
fun setSpeakerphone(on: Boolean) | Routes audio to the loudspeaker (true) or the earpiece (false). The MEDIA scenario (live, voice rooms) starts on the loudspeaker; CALL starts on the earpiece. Safe to call before joinChannel: the choice is kept and applied when audio starts (since 0.4.13). A wired or Bluetooth headset keeps priority either way. Read it back with isSpeakerphoneOn. |
val hasLocalVideo: Boolean | Whether your own camera is publishing — what a self-view tile should key on. |
fun sendMessage(text: String? = null, data: Map<String, Any?>? = null, reliable: Boolean = true): AppMessage | Sends a chat message or a small JSON object to everyone in the channel; arrives as onMessage on every other device. Audience members can send. Safe before the join completes — up to 50 messages queue and go out the moment you are in. |
fun sendData(data: ByteArray, reliable: Boolean = true) | Broadcasts raw bytes to everyone in the channel (arrives as onDataReceived). Queued until joined, like sendMessage. Set reliable to false for lossy delivery. |
fun muteRemoteUser(uid: String, muted: Boolean) | Stops hearing one user on this device only. Everyone else still hears them; they are not told. |
fun setRemoteVolume(uid: String, volume: Int) | Volume for one user, 0–400 (100 = as sent). |
fun setPlaybackVolume(volume: Int) | Volume of everyone you hear, 0–400. Per-user mutes stay in force. |
fun setRecordingVolume(volume: Int) | Microphone gain, 0–400, applied before sending. |
fun setRemoteVideoQuality(uid: String, quality: AppRemoteQuality) | Which simulcast layer to receive from one camera: LOW for a small tile or a weak link, HIGH for the spotlight. Only matters when the sender publishes several layers (ADAPTIVE mode). With adaptive stream on the layer follows the view size and this call is ignored (logged in the debug log); set AppEngineOptions.adaptiveStream = false to choose layers by hand. |
fun setRemoteVideoEnabled(uid: String, enabled: Boolean) | Stop receiving one user’s video while keeping their audio — the manual audio-only fallback. With adaptive stream on, the SDK unsubscribes the video (and subscribes again on true, about a second to the first frame) because the engine ignores a pause request there. Attached views pick the video up again by themselves. |
fun resubscribe(uid: String, includeAudio: Boolean = false) | Drops and re-takes the subscription to one user’s camera (and microphone when includeAudio is true or their audio is not being received). The remedy for a picture stuck at 0 fps while the sender is sending; the first frame is usually back within a second. Views re-bind by themselves. The SDK also does this on its own for a stalled, visible video (AppEngineOptions.autoRecoverStalledVideo). Since 0.4.13. |
fun playMusic(path: String, loops: Int = 1, startMs: Int = 0) | Plays a local audio file into the room, mixed with your microphone. See In-room music. |
fun pauseMusic() · resumeMusic() · stopMusic() · seekMusic(ms: Int) | Transport controls for the music player. |
fun setMusicVolume(playout: Int, publish: Int) | Two independent volumes, 0–400: how loud the music is in your own ear, and how loud it goes to the room. |
fun setVideoConfig(config: AppVideoConfig) | Change resolution, fps or bitrate while publishing — the thermal-throttle and crowd-rule hook. Resolution and fps restart the camera in place; bitrate and degradation change on the live encoder with no interruption; only a codec or simulcast change republishes the camera (about half a second of video). None of these touch what you receive from others. Self-views follow every change. |
fun setVideoProcessor(processor: AppVideoProcessor?) | A hook on every captured camera frame, before it is encoded and shown in your own preview — brightness, beauty, filters. AppVideoProcessor.process(frame: AppVideoFrame): AppVideoFrame gets an I420 frame (direct Y/U/V ByteBuffers with strides, width, height, rotation, timestampNs); edit it in place and return it, or return a new frame. Runs on the camera thread; each frame is copied to CPU memory, so keep it fast and remove it (null) when not needed. An exception sends the original frame. Survives restarts, camera switches and republishes. Since 0.4.13. |
fun setNoiseSuppression(on: Boolean) | Runtime toggle. Costs CPU per stream, so a crowded voice room can switch it off above a headcount. Re-captures the microphone; a few tens of milliseconds are lost. |
fun captureLocalFrame(maxEdge: Int = 480, callback: (Bitmap?) -> Unit) | One frame of your own camera as an upright Bitmap, on this device only; nothing is uploaded. Null if the camera is off. The primitive for a host presence check. |
fun getUser(uid: String): AppRemoteUser? | Looks a participant up by identity. |
fun destroy() | Leaves the channel and releases the media resources. Call from onDestroy(). The engine cannot be reused. |
Properties
| Property | Type | Description |
|---|---|---|
appId | String | The App ID the engine was created with. |
isJoined | Boolean | True between a successful join and a leave or disconnect. Read-only. |
localUid | String | The identity your server put in the token. Empty before joining. |
channelName | String | The channel currently joined, or an empty string. |
remoteUsers | List<AppRemoteUser> | Everyone else currently in the channel, in join order. |
hosts | List<AppRemoteUser> | Everyone on stage — participants whose token allows publishing — excluding you. |
audience | List<AppRemoteUser> | Everyone watching without publishing. |
audienceCount | Int | How many people are watching. |
currentRole | AppRole | Your own role as your server set it. Changes without a rejoin when your server calls the role endpoint; see onRoleChanged. |
musicState · musicPosition · musicDuration | AppMusicState · Int · Int | Where the music player is, in milliseconds. |
Events#
Extend AppEventHandler and override only what you need — every method has an empty default. All callbacks are delivered on the main thread, so you may touch views directly.
| Callback | Payload | When it fires |
|---|---|---|
onUserJoined(user) | AppRemoteUser | Someone entered the channel, and once per participant already present when you joined. Their video track has not arrived yet. |
onUserLeft(user) | AppRemoteUser | Someone left. Also fired for everyone still in the roster when your own connection drops. |
onTrackSubscribed(user) | AppRemoteUser | A remote camera or microphone became available. Attach the user to an AppVideoView here. |
onDisconnected(reason) | String? | The connection ended. The reason is a short code, or null when it is not known. |
onDataReceived(data, from) | ByteArray, AppRemoteUser? | A data message arrived. The sender is null for platform messages that did not come from a participant. |
onGiftReceived(gift) | AppGiftEvent | A virtual gift was broadcast to the channel by your server. |
onActiveSpeakersChanged(uids) | List<String> | The set of speaking participants changed. The list holds identities. |
onError(error) | Throwable | A background operation failed — a rejected publish, a missing permission, or a failed joinChannelAsync. |
onJoined(localUid) | String | You are in the channel. Media, messages and the roster work from this moment. This is the success counterpart of onError for joinChannelAsync. |
onMessage(message) | AppMessage | Someone called sendMessage. This is how chat, comments, seat signals and any small JSON reach the room. message.text and message.data are what the sender passed; values inside data arrive as JSON types (String, Number, Boolean, JSONObject, JSONArray, JSONObject.NULL). message.from is the sender, taken by the server from the connection the message arrived on — a client cannot forge it; compare from.uid with the uid your server issued the token for. from is null for a message your own server sent. message.sentAt is the arrival time on this phone. Your own messages are not echoed back. Before the join completes up to 50 outgoing messages queue (the oldest are dropped beyond that); keep each under about 15 KB. |
onAudienceChanged(audience) | List<AppRemoteUser> | The audience changed — someone started or stopped watching. Carries the whole list, so a viewer count renders from it directly. |
onRoleChanged(role) | AppRole | Your server changed your role with POST /v1/rooms/:room/participants/:identity/role. The engine has already turned your microphone on or off; update the UI. |
onSpeakerLevels(speakers) | List<AppSpeaker> | Who is speaking and how loudly (level 0–1), at most every 300 ms. Drive a seat’s glow from level. |
onUserMediaChanged(user, video, audio) | AppRemoteUser, Boolean, Boolean | A remote user turned their camera or microphone on or off. Swap a tile for an avatar card here. |
onNetworkQuality(uid, quality) | String, AppNetworkQuality | Another participant’s link quality changed: EXCELLENT, GOOD, POOR or LOST. |
onStats(stats) | AppStats | Your own connection every two seconds: rttMs, uploadLoss, downloadLoss, uploadBitrate, downloadBitrate, quality. |
onReconnecting() · onReconnected() | — | The connection dropped and came back. Media pauses in between; the roster is rebuilt on return. |
onMusicStateChanged(state) · onMusicFinished() | AppMusicState | The music player started, paused, stopped, or reached the end of the file. |
onLocalVideoChanged() | — | Your own camera track was (re)created — camera off→on, switchCamera, setVideoConfig. Every AppVideoView showing you re-binds by itself; use this only for other things that hold your preview. Since 0.4.4. |
onFirstRemoteVideoFrame(uid, elapsedMs) | String, Long | The first decoded frame of that user’s camera reached your screen; elapsedMs counts from joinChannel. Measure join-to-picture with it. Since 0.4.4. |
onConnectionStage(stage, uid, elapsedMs) | AppConnectionStage | CONNECTED when the channel is up, FIRST_REMOTE_VIDEO_FRAME per user — each with ms since joinChannel. Since 0.4.4. |
onRemoteStats(stats) | List<AppRemoteStats> | Every two seconds, one entry per remote user: videoFps, size, videoFreezeCount, packet loss, audioJitterMs — tells you whose picture is stuck or whose audio is dropping. Since 0.4.4. |
onVideoQualityChanged(quality) | AppVideoQualityInfo | Your own camera’s top layer that carries frames changed size, layer or reason: width, height, fps, bitrateKbps, layer (HIGH / MEDIUM / LOW), reason (BANDWIDTH / CPU / NONE). Checked every two seconds while the camera is on. Since 0.4.10. |
After a reconnect the roster is rebuilt: everyone still present is announced again through onUserJoined, followed by onTrackSubscribed for the tracks that are already published. Make your tile code idempotent — adding the same uid twice should be a no-op.
Automatic codec recovery#
Some phones’ hardware video encoders start, and the call connects, but produce a stream nobody can show: viewers see a black tile. The SDK detects this and heals it by itself, on phones it has never seen before. There is nothing to turn on. Since 0.4.15.
- While your camera is on, the SDK checks every two seconds that the encoder keeps producing frames and has produced a key frame within four seconds of publishing. Unhealthy for more than five seconds, or an encoder that fails to start, and the camera is republished on software VP8 (720p at 20 fps in
STABLE_HD). - When your app shows someone’s camera and it arrives but no picture can be decoded for more than six seconds while their camera is on, the SDK tells that publisher (and only them) with a small internal message. Internal messages never reach your message or data callbacks.
- On the publishing side, reports from two different viewers within 20 seconds (or from the only other person in a one-to-one call) make the SDK republish your camera on a safer path: VP8 if it was on another codec, otherwise once more as a single layer. Viewers see about half a second of interruption; audio is not touched. This happens at most once per join.
- A hardware encoder found bad is remembered on the phone for 30 days (and until the next SDK version), so later sessions start on the working path straight away.
- The platform also learns from anonymous device reports (make and model, OS and SDK version, the codec and whether it worked; no user, identity or IP address) and tunes video settings per device model, such as the codec, a resolution or frame rate cap, or a single layer. The SDK fetches that tuning in the background at join, never delaying it, and applies it from the next camera publish.
override fun onVideoCodecFallback(from: String, to: String, reason: String) {// e.g. from = "h264:hw", to = "vp8:sw", reason = "no_frames"Log.i("MyApp", "camera switched $from -> $to ($reason)")}
reason is no_frames (no frames or no key frame), encoder_error (the encoder failed to start or encode) or viewers_cannot_decode (viewers reported no picture). The event is for your logs and support tooling; the app has nothing to do.
Encoder self-test#
The first time an app starts with a new SDK version, the SDK checks the phone’s hardware H.264 encoder before any call: about 30 synthetic 720p frames go through it, and it must produce a decodable start and real output within two seconds. The camera is never opened. It runs in the background after the first AppEngine.create(), gives up after three seconds, and cannot crash the app. An encoder that fails is remembered like one found bad during a call, so the camera is published on the software path from the start, and an anonymous device report is sent with the next join. Since 0.4.16.
// Turn the automatic run off:AppEngine.create(context, appId, handler, AppEngineOptions(encoderSelfTest = false))// Or run it yourself, e.g. on a support screen:AppEngine.runEncoderSelfTest(context) { r ->Log.i("MyApp", "encoder ${r.encoderName} passed=${r.passed} reason=${r.reason}")}
reason is no_output, no_key_frame, encoder_error or timeout. A phone with no hardware encoder in use passes with encoderName null: nothing to test.
Remote diagnostics#
So AppLooma support can look into a problem on your users' devices without asking them to reproduce it, the SDK keeps the last 1500 lines the SDK logs about itself (join, connection state, reconnects, video out resolution, fps and bitrate, codec and codec recovery, the encoder watchdog, the encoder self-test, a stats summary every 10 s, errors inside SDK calls) in memory. They are uploaded, at most about once per call when you leave or are disconnected (and once 60 s into a call), only when:
- the call had a problem: a codec fallback, a viewer reporting no picture, an encoder failure, a failed join, or repeated reconnects;
- AppLooma support turned log collection on for this device model or your project (for a limited time);
- your app asked for it, for example from a "Report a problem" button.
engine.uploadDiagnostics("user_report")
Never collected: audio, video, screen content, chat or data messages, display names, metadata or attribute values. User ids are replaced by a short one-way hash, tokens and URL parameters are removed, and only lines the SDK itself produced are included. If the phone is offline, the latest upload is kept on the device (one file) and sent at the next join. Uploads are stored for 14 days and then deleted. Since 0.4.16.
To turn every upload off, set AppEngineOptions.remoteDiagnostics:
AppEngine.create(context, appId, handler, AppEngineOptions(remoteDiagnostics = false))
Smart routing#
joinChannel asks AppLooma which media server is nearest the device and connects there automatically. There is no backend change to make: keep passing the token and URL your server returns. A room stays on one server, so everyone in it meets in the same place even when they join from far apart. It only applies to applooma.dev URLs; any other URL is used as given. Since 0.4.24.
To connect to exactly the URL you passed, opt out:
AppJoinOptions(role = AppRole.HOST, camera = true, smartRouting = false)
Pre-call network test#
startNetworkTest(serverUrl, token, callback) opens a short, separate connection, measures for about five seconds and leaves. Mint the token for a throwaway channel such as nettest-<uid>. Nothing is published and the channel you are in is not touched. The callback runs once on the main thread with uplink and downlink quality, round-trip time, jitter and loss, or with error set (after 10 seconds at most). stopNetworkTest() cancels it. Since 0.4.16.
engine.startNetworkTest(wsUrl, testToken) { r ->if (r.error != null) return@startNetworkTest showError(r.error)showBars(r.uplinkQuality, r.downlinkQuality, r.rttMs)}
Because the test publishes nothing, loss and jitter are measured on the connection’s own traffic and usually read 0 on a clean line; the quality is the media server’s estimate, so uplink and downlink are usually the same value. There is no echo test on Android yet.
Network fallback#
Decide what gives way when the network cannot carry everything. Both default to NONE. After about four seconds of poor network (a poor or lost quality score, or more than 10 % loss) the fallback starts; after about ten seconds of good network everything comes back. Since 0.4.16.
setRemoteSubscribeFallback(option), for what you receive:VIDEO_LOW_QUALITYasks for every remote camera’s low layer (only when the sender publishes layers and adaptive stream is off);AUDIO_ONLYstops receiving remote video. Video you turned off yourself withsetRemoteVideoEnabledstays off.setLocalPublishFallback(option), for what you send:VIDEO_LOW_QUALITYlowers the camera bitrate to 30 % (at least 300 kbps);AUDIO_ONLYpauses your camera.
engine.setRemoteSubscribeFallback(AppFallbackOption.AUDIO_ONLY)engine.setLocalPublishFallback(AppFallbackOption.VIDEO_LOW_QUALITY)override fun onFallbackStateChanged(isLocal: Boolean, state: AppFallbackOption) {// state NONE = restored}
Restrictive networks#
Some office, hotel and school networks block UDP and every port but 443. The cloud proxy carries the call through AppLooma's relay over TLS on port 443 so it still connects. Set it with AppEngineOptions.cloudProxy:
AppCloudProxy.AUTO(default): connect directly; if that join fails with a connection or network timeout, retry once through the relay on TLS 443. A refused token is not retried.FORCE_TLS_443: always relay on TLS 443. Use it when you already know the network is locked down; it adds a little latency.OFF: never relay.
AppEventHandler.onProxyStateChanged(state, autoRetry) reports the state: DIRECT (not relayed, also after leaving), CONNECTING (joining through the relay) and CONNECTED (in the channel through the relay), with a flag that is true when the relay is used because the direct join failed. The SDK also logs the retry. Since 0.4.17.
val engine = AppEngine.create(context, "YOUR_APP_ID", handler,AppEngineOptions(cloudProxy = AppCloudProxy.FORCE_TLS_443))override fun onProxyStateChanged(state: AppProxyState, autoRetry: Boolean) {if (state == AppProxyState.CONNECTED) showBadge("Connected through the cloud proxy")}
Video enhancement#
setVideoEnhance(AppVideoEnhance(...)) improves your camera before it is sent: beauty (0 to 1, edge-preserving skin smoothing with a slight brightness lift), lowLight (brightens dark rooms, adapting to the scene), sharpen (0 to 1) and warmth (-1 cooler to 1 warmer). Pass null to turn it off; calling again with new values changes them on the next frame. It survives camera off/on, switches and republishes, and runs before any processor you set with setVideoProcessor. The UIKit call screen has an Enhance button that turns on AppVideoEnhance.Portrait. Since 0.4.18.
engine.setVideoEnhance(AppVideoEnhance(beauty = 0.6f, lowLight = true))// or the presetengine.setVideoEnhance(AppVideoEnhance.Portrait)// offengine.setVideoEnhance(null)
Filtering runs on the CPU on the camera thread, with the smoothing done at half resolution and all buffers reused; it is sized for 720p30 on a mid-range phone. At 1080p on older phones prefer lowLight and warmth alone, which are the cheapest. AppVideoFilters is public if you want to call it from your own processor.
Receivers and super-resolution
Enhancement runs on the sender, so every viewer sees the improved picture at no cost to them. Upscaling a low-resolution stream on the receiving phone (super-resolution) is not offered: doing it well needs machine-learning models that are too heavy for an in-call path on mid-range phones. For viewers on weak networks, use the existing receiver quality controls instead (network fallback and the video quality layers) so they get the best layer their connection carries.
Audience latency#
An audience member does not talk back, so a little extra delay buys smoother playback on a bad network. AppEngineOptions.audienceLatency sets the tier; it only applies while the local user is audience (publishing nothing) and switches back as soon as they go on stage. Since 0.4.18.
ULTRA_LOW(default): the lowest delay, the behaviour before this option.LOW: same as ULTRA_LOW on Android: the engine here has no receive-buffer control.STANDARD: prefers lower video layers under congestion: turns onsetRemoteSubscribeFallback(VIDEO_LOW_QUALITY)unless you set a fallback yourself. No extra receive buffer on Android.
AppEngine.create(context, APP_ID, handler, AppEngineOptions(audienceLatency = AppAudienceLatency.STANDARD))
Audio quality profiles#
AppAudioOptions.profile sets how the microphone is captured, processed and encoded. It is chosen at create time and combines with audioScenario: the scenario picks the volume stream, the profile picks the sound. Since 0.4.21.
HD_VOICE(default): HD voice for live streams, voice rooms and loudspeaker calls. 48 kHz voice capture with the phone's and the engine's echo cancelling, noise suppression and automatic gain, a high-pass filter, mono 64 kbps. Add Clear Voice for a noisy room.SPEECH: phone-call voice for 1-to-1 calls. Echo cancellation, noise suppression and automatic gain on, mono 32 kbps. WithAppAudioScenario.CALLthe call starts on the earpiece.MUSIC: karaoke, music hosts and performers. 48 kHz stereo capture where the phone supports it, every processing stage off, 128 kbps, media audio mode. Use it with headphones: without echo cancellation the loudspeaker reaches the microphone and others hear an echo.
noiseSuppressionMode, noiseSuppressionLevel, agcTargetLevelDbfs and micGain still apply on top of any profile.
// 1-to-1 call on the earpieceAppEngineOptions(audioScenario = AppAudioScenario.CALL, audio = AppAudioOptions(profile = AppAudioProfile.SPEECH))// Voice room with Clear Voice for a noisy hostAppEngineOptions(audio = AppAudioOptions(profile = AppAudioProfile.HD_VOICE, noiseSuppressionMode = AppNoiseSuppression.CLEAR))// Karaoke host on headphonesAppEngineOptions(audioScenario = AppAudioScenario.MEDIA, audio = AppAudioOptions(profile = AppAudioProfile.MUSIC))
Clear Voice#
AppAudioOptions.noiseSuppressionMode chooses the noise suppression mode:STANDARD (default) is the standard suppressor,CLEAR is Clear Voice, OFF turns suppression off. Since 0.4.18.
CLEAR runs a spectral noise suppressor in the SDK's microphone post-processing, after the standard one: steady noise such as fans, hiss, air conditioning and traffic rumble drops by 10 dB and more while speech passes. It is a DSP suppressor, not a neural model, and adds 10 ms of latency. A tone held perfectly steady for many seconds is eventually treated as noise. Switch at runtime with engine.setNoiseSuppressionMode(mode); moving between STANDARD and CLEAR is instant.
AppEngineOptions(audio = AppAudioOptions(noiseSuppressionMode = AppNoiseSuppression.CLEAR))engine.setNoiseSuppressionMode(AppNoiseSuppression.STANDARD)
Codec detection#
AppEngine.getSupportedVideoCodecs(context) lists the video codecs this device can send and receive, each as mime, hardware, encoder anddecoder. VP8, VP9 and AV1 always have a software encoder and decoder; H.264 and H.265 are sent only on a hardware encoder the SDK trusts. hardware is true when the SDK would encode on hardware. Since 0.4.18.
AppEngine.getSupportedVideoCodecs(context).forEach { Log.d("codecs", it.toString()) }
Token renewal#
About 30 seconds before the join token expires (read from the token itself, no network), onTokenWillExpire(token) fires. Fetch a new token from your server and pass it to renewToken(token); the SDK uses it from then on for its own requests and warns again before the new one expires. While you are connected, the media server keeps the live connection’s credential fresh by itself, so the call is not interrupted. Since 0.4.16.
override fun onTokenWillExpire(token: String) {scope.launch { engine.renewToken(myServer.fetchToken(channel)) }}
Rendering video#
AppVideoView is a plain FrameLayout. Add it to your layout like any other view, then attach a participant — as early as onUserJoined. If their camera has not arrived yet the view waits and starts drawing the moment it does, so there is no wrong time to attach. (Before 0.4.0 an attach before the track existed was a no-op and the tile stayed black; that is what this fixes.)
// In your layout, or created in codeval tile = AppVideoView(context)container.addView(tile)override fun onTrackSubscribed(user: AppRemoteUser) {tile.attach(engine, user)}override fun onUserLeft(user: AppRemoteUser) {tile.detach()}
Local preview
val preview = AppVideoView(context)container.addView(preview)preview.attachLocal(engine)
A view remembers what it shows. Moving it out of the window and back — a small tile to full screen, picture-in-picture, a seat change, a RecyclerView recycling it — pauses drawing and resumes it without another attach call. A remote view also follows the user’s camera through a republish or resubscribe. detach() and release() forget the target.
setMirror(Boolean) mirrors the picture on that view only (what is sent is unchanged). By default a front-camera self-view is mirrored and everything else is not; resetMirror() returns to that default.
Release the view when you are done
A renderer holds native resources that garbage collection does not free. Call release() when the screen goes away. Detaching happens automatically when the view leaves the window, so a view that is merely scrolled off screen needs nothing.
Detach the renderer when a tile goes away, and release the renderer view itself in onDestroy. A renderer left attached to a track that has ended keeps the last frame on screen.
Types#
AppJoinOptions
| Field | Type | Default | Description |
|---|---|---|---|
role | AppRole | AppRole.HOST | Must match the role your server put in the token. |
camera | Boolean | false | Publish the camera on join. Ignored for audience. |
microphone | Boolean | true | Publish the microphone on join. Ignored for audience. |
smartRouting | Boolean | true | Connect to the nearest AppLooma media server (applooma.dev URLs only). false uses the URL you passed. Since 0.4.24. |
It is a Kotlin data class with @JvmOverloads, so Java callers can write new AppJoinOptions(AppRole.HOST) or pass all three arguments.
AppEngineOptions
| Field | Default | Meaning |
|---|---|---|
audioScenario | CALL | CALL: communication path, echo cancellation tuned for speech, earpiece by default. MEDIA: media attributes (loudspeaker, media volume keys, music-grade bandwidth) while still running in the communication audio mode so the phone’s echo canceller stays engaged — voice rooms with music and live streams. |
video | AppVideoConfig(1080, 30) | See AppVideoConfig below: height, fps, maxBitrate in bit/s, simulcast, codec. Lower the preset on hot or low-end phones. |
softwareEchoCancellation | false | Force the software canceller and ignore the phone’s own. The software canceller is already the default since 0.4.11 (preferHardwareEffects = false); this flag is kept for compatibility and wins over preferHardwareEffects = true. |
audio | AppAudioOptions() | Microphone processing: echoCancellation, noiseSuppression, autoGainControl, preferHardwareEffects, micGain, noiseSuppressionLevel, agcTargetLevelDbfs. See AppAudioOptions below. Since 0.4.4. |
adaptiveStream | null (by mode) | Lower a remote camera’s layer for a small or hidden view and pause it when nothing shows it. null = on in ADAPTIVE, off in STABLE_HD and ULTRA_HD_4K; false turns it off in ADAPTIVE too (and makes setRemoteVideoQuality take effect). Since 0.4.13. |
autoRecoverStalledVideo | true | While a remote camera is attached to a visible AppVideoView and its sender is publishing, a received frame rate of 0 for more than 4 s makes the SDK call resubscribe for that user, at most once per 15 s per user (logged in the debug log). Since 0.4.13. |
AppVideoConfig
| Field | Default | Meaning |
|---|---|---|
mode | STABLE_HD | STABLE_HD, ULTRA_HD_4K or ADAPTIVE — see Video modes below. Fields you set explicitly win over the mode. Since 0.4.11. |
height | 0 (by mode) | 0 = chosen by the mode; otherwise 360 / 540 / 720 / 1080 / 1440 / 2160 (1440 since 0.4.10, 2160 since 0.4.11) — the short edge of a 16:9 frame. Any other value snaps to the nearest preset and logs a warning. |
fps | 30 | Frames per second. |
maxBitrate | 0 | Encoder cap in bits per second — 1_900_000 for 1.9 Mbps. 0 = chosen by the mode: 3.5 Mbps at 1080p and 1.8 Mbps at 720p in STABLE_HD, 25 Mbps in ULTRA_HD_4K; in ADAPTIVE 5 Mbps at 1440p, 4 Mbps at 1080p, 2.2 Mbps at 720p, the preset default below that (since 0.4.10; was 3 / 1.7 Mbps). A value under 10 000 is almost certainly kbps by mistake and is logged as a warning (it would cap the picture at a few kbps). |
minBitrate | 0 | Encoder floor in bits per second; 0 = engine default. Reserved: the engine exposes no per-sender minimum on Android today, so it is kept but does not change what is sent. Since 0.4.10. |
simulcast | null (by mode) | Three layers so weak links still get a picture; false = one layer. null = on in ADAPTIVE, off in STABLE_HD and ULTRA_HD_4K. |
codec | AUTO | AUTO publishes VP8 where the chipset has a hardware VP8 encoder (Exynos, MediaTek, most Samsung) and H.264 in hardware where it does not (budget Qualcomm), always with a VP8 backup layer the server can fall back to. VP8 or H264 force one. AV1 (since 0.4.10) is used only where the chipset has a hardware AV1 encoder, otherwise AUTO applies with a warning; H265 is not available on Android and always resolves to AUTO. AppEngine.publishedCodec tells you what was chosen. Since 0.4.4. |
degradation | AUTO | What the encoder gives up first under pressure: AUTO (BALANCED in STABLE_HD since 0.4.21, KEEP_RESOLUTION in ULTRA_HD_4K; in ADAPTIVE the engine’s choice for a camera: frame rate stays, resolution drops), KEEP_RESOLUTION (sharp picture, frames dropped), KEEP_FRAMERATE, BALANCED. Since 0.4.10. |
// Java: new AppVideoConfig(720, 30, 1_900_000, true) — bitrate in bit/s.val video = AppVideoConfig(height = 720, fps = 30, maxBitrate = 1_900_000, simulcast = false, codec = AppVideoCodec.AUTO)
Video modes (AppVideoMode)
mode sets the overall tuning of your camera. Any field you set yourself (height, maxBitrate, simulcast, degradation) still wins over the mode. Since 0.4.11.
| Mode | What it does |
|---|---|
AppVideoMode.STABLE_HD | Default. One steady HD picture for calls and live rooms: a single layer (no simulcast), resolution kept under pressure (frame rate gives way first), and receivers pinned to the full picture instead of asking for smaller layers by view size. Size chosen once per device from the table below. |
AppVideoMode.ULTRA_HD_4K | 2160p30 (3840×2160), one layer, 25 Mbps (H.265 publishing is not available on Android). Gated: the hardware encoder for the chosen codec must accept 3840×2160; otherwise the SDK uses STABLE_HD and logs a warning. |
AppVideoMode.ADAPTIVE | The behaviour before this release: three simulcast layers, receivers switch layers by view size and network, resolution may drop to keep motion smooth. For large rooms with many small tiles. |
| Device | Resolution | Bitrate cap |
|---|---|---|
| Hardware encoder for the chosen codec | 1080p30 | 3.5 Mbps |
| Software (CPU) encoder, e.g. VP8 on MediaTek | 720p30 | 1.8 Mbps |
4K is a premium mode. It needs a strong network at both ends (20 Mbps or more upload for the sender and download for each viewer is recommended), a device whose encoder can do 3840×2160 — checkAppEngine.isUltraHdSupported() first — and it costs more battery and heat than HD. On a device that fails the check the SDK uses AppVideoMode.STABLE_HD instead and logs a warning.
// Default: nothing to set — STABLE_HD.val engine = AppEngine.create(context, "YOUR_APP_ID", handler)// Premium 4K where the phone can do it.val video = if (AppEngine.isUltraHdSupported()) AppVideoConfig(mode = AppVideoMode.ULTRA_HD_4K) else AppVideoConfig()// The earlier behaviour (three layers, receivers switch by view size).val adaptive = AppVideoConfig(mode = AppVideoMode.ADAPTIVE)
Getting crisp 1080p
AppVideoMode.STABLE_HD is now the default, so a plain engine already keeps the resolution and sends one steady 1080p layer where the encoder is hardware (720p on a software encoder). Publish from a strong connection, and watch onVideoQualityChanged: reason BANDWIDTHmeans the uplink is the limit, the CPU reason means the device is — drop to 720p then. To force 1080p on a device the tier would put on 720p, set height and maxBitrate explicitly as below.
val engine = AppEngine.create(context, "YOUR_APP_ID", handler, AppEngineOptions(video = AppVideoConfig(height = 1080, fps = 30, maxBitrate = 4_000_000, degradation = AppVideoDegradation.KEEP_RESOLUTION),))override fun onVideoQualityChanged(quality: AppVideoQualityInfo) {Log.d("live", "${quality.width}x${quality.height} @${quality.fps} ${quality.bitrateKbps} kbps ${quality.layer} ${quality.reason}")}
AppAudioOptions
| Field | Default | Meaning |
|---|---|---|
echoCancellation | true | Acoustic echo cancellation on the microphone. |
noiseSuppression | true | Background-noise suppression. Also switchable at runtime with setNoiseSuppression(). |
autoGainControl | true | Automatic gain control — evens out a voice that moves closer and further. |
preferHardwareEffects | false | Use the phone’s own echo canceller and noise suppressor instead of the software ones. Off by default since 0.4.11: many budget phones ship a weak hardware canceller and loudspeaker voice rooms echoed. Turn it on only if you play in-room music through the SDK player on the loudspeaker, because only the platform canceller references that playout. |
micGain | 100 | Microphone gain in percent applied AFTER echo cancellation, noise suppression and AGC, through a soft limiter: 200 makes a distant voice twice as loud without clipping. Change at runtime with setMicGain(). Prefer this over setRecordingVolume(), which sits BEFORE processing and clips when raised. |
noiseSuppressionLevel | LOW | How hard residual noise between words is pushed down after the suppressor: OFF, LOW (-60 dBFS), MODERATE (-54), HIGH (-48), VERY_HIGH (-42). Below the level the microphone is eased down by 20 dB; speech above it passes untouched. HIGH / VERY_HIGH for a street or a fan next to the phone. Runtime: setNoiseSuppressionLevel(). Since 0.4.6. |
agcTargetLevelDbfs | 0 (off) | Level the microphone is steered towards while someone talks, in dBFS; -18 is a good broadcast target. Works after noise handling, on top of the engine’s own AGC, and never lifts silence. Runtime: setAgcTargetLevel(). Since 0.4.6. |
profile | HD_VOICE | Audio quality profile: SPEECH, HD_VOICE or MUSIC. MUSIC ignores noiseSuppression and autoGainControl; see Audio quality profiles. Since 0.4.21. |
AppRemoteStats
Delivered through onRemoteStats every two seconds, one per remote user: uid, videoFps, videoWidth/videoHeight, videoFreezeCount (times that user’s picture stalled on your phone), videoPacketsLost, audioPacketsLost, audioJitterMs. A camera you are subscribed to reporting videoFps 0 is a stuck picture; a climbing audioPacketsLost is why one voice is breaking up while the others are fine.
AppRole
| Value | Can publish | Typical use |
|---|---|---|
AppRole.HOST | Yes | The person running the call or the stream. |
AppRole.COHOST | Yes | An invited guest who also publishes audio and video. |
AppRole.AUDIENCE | No | A viewer. Camera and microphone options are ignored. |
AppRemoteUser
| Property | Type | Description |
|---|---|---|
uid | String | The identity your server put in that user’s token. Unique within the channel. |
displayName | String? | Display name, if your server set one. |
metadata | String? | The metadata string your server attached to the token. |
isSpeaking | Boolean | True while this user is detected as speaking. |
hasVideo | Boolean | Whether this participant is publishing a camera. False for a moment after they join, because a participant is announced before their track arrives. |
AppGiftEvent
Delivered on onGiftReceived when your server sends a gift with POST /v1/gifts/send. It is also parseable directly from a data payload with AppGiftEvent.tryParse(bytes), which returns null when the payload is not a gift.
| Field | Type | Description |
|---|---|---|
txId | String | Transaction id of the gift. Use it to deduplicate. |
giftId | String | Id of the gift in your catalogue. |
giftName | String | Display name of the gift. |
imageUrl | String | Image to show in the animation. |
coinPrice | Int | Price in coins. |
sender | String | Identity of the sender. |
receiver | String | Identity of the receiver. |
quantity | Int | How many were sent. |
Lifecycle and cleanup#
leaveChannel()ends the call and stops event collection. The engine stays usable and can join again.destroy()leaves the channel and releases the media resources. Call it fromonDestroy(). After this the engine is finished — create a new one for the next call.- Detach renderers from tracks before you remove the views, and release the renderer views themselves.
override fun onDestroy() {engine.destroy()super.onDestroy()}
Calls that survive backgrounding
An activity that is destroyed on rotation destroys the engine with it and ends the call. For a call that keeps running, hold the engine in a ViewModel or a foreground service and destroy it there.
In-room music#
A voice room needs a song. playMusic decodes a local file (mp3, aac/m4a, ogg, wav, flac — anything the phone can decode), mixes it into your microphone so the room hears it, and plays it on your own device so you hear it too. The two volumes are independent: a host can turn the song up in their own ear without shouting into the audience.
engine.playMusic(file.absolutePath) // loops = 1engine.setMusicVolume(playout = 100, publish = 80)// transportengine.pauseMusic(); engine.resumeMusic(); engine.seekMusic(30_000); engine.stopMusic()val progress = engine.musicPosition.toFloat() / engine.musicDurationoverride fun onMusicFinished() { playNextTrack() }
The music does not depend on the microphone (0.4.5). A host who mutes with enableMicrophone(false) while a song plays silences only their voice; the song keeps going. playMusic while muted starts the song without unmuting. When the song ends, or stopMusic is called, the microphone returns to whatever you last asked for.
The music travels inside your microphone track, so the room hears it while your microphone is on. Mute yourself and the song keeps playing in your ear but stops going out; unmute and it resumes for everyone. Create the engine with AppAudioScenario.MEDIA for a voice room with music — it widens the audio path and routes to the loudspeaker.
Changing roles without a rejoin#
Seating a listener, or sending a speaker back to the audience, used to mean leave and join again: a gap in audio and a new billing session. Your server now changes the role in place:
await applooma.setRole('room-42', 'user-7', 'cohost'); // or 'audience'
On that user’s phone the engine receives the new permission, turns the microphone on (or off), and fires onRoleChanged(role). Nothing disconnects; hosts and audience update on every other device. The REST form is POST /v1/rooms/:roomName/participants/:identity/role with your API key.
Data messages#
engine.sendData("""{"text":"hello"}""".toByteArray())override fun onDataReceived(data: ByteArray, from: AppRemoteUser?) {val text = String(data, Charsets.UTF_8)Log.d("AppLooma", "${from?.uid ?: "server"}: $text")}
Troubleshooting#
A remote tile stays black
You attach the video inside onUserJoined. The user is announced before their camera track is subscribed, so it is still null at that point. Attach the renderer in onTrackSubscribed.
Camera or microphone never turns on
On Android 6.0 and above, CAMERA and RECORD_AUDIO must be granted at runtime. The SDK declaring them in its manifest is not the grant. Because enableCamera and enableMicrophone are fire-and-forget, the failure appears on AppEventHandler.onError rather than as a thrown exception — implement onError during development so you see it.
Bluetooth earbuds are silent
On Android 12 and above the app must hold BLUETOOTH_CONNECT at runtime before joining; otherwise the headset is never offered as a route and audio stays on the phone. The SDK logs a warning at join time when the grant is missing. See Bluetooth headsets.
Audience members cannot publish
A token issued with the AUDIENCE role carries no publish rights, and AppJoinOptions skips publishing entirely for that role. To let a viewer speak, have your server issue a new token with COHOST, then rejoin with it.
The other participant disappears when a second device joins
Two connections that share one identity are treated as the same participant, and the newer connection replaces the older one. Give every participant a unique identity when your server issues the token.
My own preview freezes but everyone else sees me move
The publisher’s camera track was replaced underneath the self-view — camera off→on, a camera switch, a settings change, or a bandwidth-driven restart — and the view kept drawing the dead track. Fixed in 0.4.4: a view attached with attachLocal follows the local track through every restart (and onLocalVideoChanged fires). On older versions, call attachLocal again after enableCamera(true).
Up to 0.4.12 two gaps remained: a view that left the window (a seat change or a tile moving to full screen) stopped following the camera for good, and setVideoConfig muted and unmuted the same track instead of publishing a new one. Both are fixed since 0.4.13: the view re-binds when it returns to a window, and follows a restarted or republished track.
A remote picture sits at 0 fps after a layout change
With adaptive stream on (the ADAPTIVE mode), the engine pauses a remote camera that no visible view shows, and a view that is moved (picture- in-picture, full screen, a seat change) could leave it paused. Since 0.4.13 views re-bind when they return to a window, a visible stalled video is resubscribed automatically after 4 s (autoRecoverStalledVideo), and you can call resubscribe(uid) yourself. STABLE_HD, the default since 0.4.11, has adaptive stream off; in ADAPTIVE you can turn it off with AppEngineOptions.adaptiveStream = false.
Blurry, stuttering picture on a budget phone
Chipsets without a hardware VP8 encoder (many low-end Qualcomm) were encoding 720p VP8 on the CPU, which drops frames and can starve the microphone. Since 0.4.4 the default codec = AUTO publishes H.264 in hardware on those phones (with a VP8 backup layer for viewers), and VP8 where the chipset does it in hardware. Check engine.publishedCodec, and make sure maxBitrate is in bit/s — 1900 caps the picture at 1.9 kbps.
I hear my own voice come back after a minute
Someone’s loudspeaker is reaching their microphone and their echo canceller has lost the lock. Since 0.4.11 every phone uses the SDK’s software canceller by default, because many budget phones ship a weak hardware one. If your app plays in-room music on the loudspeaker through the SDK player, set preferHardwareEffects = true so the platform canceller also removes the music. Two test phones on loudspeaker in the same room will always hear each other: use a headset on one of them.
Viewers say the host is too quiet at arm’s length
Raise AppAudioOptions.micGain (or call setMicGain(200)). It is applied after processing through a soft limiter, so it does not clip and does not amplify what noise suppression was removing. Do not reach for setRecordingVolume for this: it sits before processing and clips on loud moments.
A camera freezes after a minute or two
Almost always the phone, not the network: Android pauses camera capture when your app leaves the foreground or the screen turns off, and a battery optimiser can do the same to a long call. The track stays published, so everyone else sees the last frame. Run the call inside a foreground service with the camera and microphone types, keep the screen on while the call UI is visible, and re-enable the camera in onResume. If it still happens, read onStats: an uploadLoss above 0.1 with a rising rttMs is the network, and the fix is a lower preset in AppEngineOptions.video.
The first frame takes two or three seconds
Three things add up: the attach happening before the track exists (fixed by 0.4.0’s auto-attach), the encoder starting on the publisher’s phone, and the first key frame. Attach in onUserJoined, not later, and on the publishing side turn the camera on in AppJoinOptions so it is warming up during the join rather than after it. Measure it rather than guess: onConnectionStage(CONNECTED, …) and onFirstRemoteVideoFrame(uid, elapsedMs) (0.4.4) report the milliseconds from joinChannel to the channel being up and to the first decoded frame of each user. A large gap between the two on a budget phone usually means the publisher was encoding VP8 in software (a late key frame) — see the codec note above.
Being in two channels at once (PK, watching a second stream)
Create a second AppEngine. Instances are independent and any number may coexist; each holds its own connection, roster and handler.
The phone has one audio route, so it is shared: the first engine to join decides the audio scenario and the route, and an engine created or joining while another is in a channel adopts them (a warning is logged when its own audioScenario differs). A PK engine created with the default CALL scenario no longer moves the live stream to the earpiece. The route is released only when the last engine leaves, and setAudioRoute on either engine moves both. Since 0.4.13.
IllegalStateException: Already in a channel
joinChannel throws if the engine is already connected. Check engine.isJoined, or call leaveChannel() first. A common cause is joining from onCreate without guarding against the activity being recreated.
Java: the build fails on the coroutine method
joinChannel is a suspending function and is not callable from Java. Use joinChannelAsync, which returns immediately and reports failures through onError.
The join is rejected
Channel names must match /^[a-zA-Z0-9_-]{1,64}$/. Tokens also expire, so fetch a fresh one for each join rather than caching it across sessions.
What changed#
0.4.24
- Smart routing:
joinChannelconnects to the nearest AppLooma media server for the device; the room stays on one server. Only applies to applooma.dev URLs, with a 2 s limit and a silent fallback to the URL you passed. Opt out withAppJoinOptions(smartRouting = false). See Smart routing.
0.4.22
- An engine with
AppAudioScenario.CALLstarts on the earpiece again with the default HD_VOICE profile, as before 0.4.21. Live streams and voice rooms (MEDIA) still start on the loudspeaker. - UIKit 0.2.16 is built on 0.4.22; no UIKit API changes.
0.4.21
- Audio quality profiles:
AppAudioOptions.profilewithSPEECH,HD_VOICE(default) andMUSIC. HD_VOICE is clear 48 kHz voice for live streams and voice rooms, mono 64 kbps. See Audio quality profiles. - STABLE_HD now degrades BALANCED by default, so a live stream is smooth from its first seconds instead of starting at a few frames per second while bandwidth ramps up.
- Switching to the back camera no longer shows a mirrored picture for a few seconds.
- A publisher connection that fails right after join is retried through the relay.
- UIKit 0.2.15 is built on 0.4.21; no UIKit API changes.
0.4.20
- The encoded camera picture never exceeds the video tier: a larger camera frame (for example 960x720 for a 540p tier) is scaled down by the sender instead of being encoded at full size.
- Live bitrate changes from
setVideoConfigapply again in apps built with a code shrinker. - A viewer's "no picture" report no longer republishes the camera when the stream is already software and single layer, or while the local encoder is producing frames.
- Weak phones start at 540p20 on a software encoder, and a software encoder below 12 fps for 10 s steps down one tier (540p, then 360p) without a republish; reported through
onVideoQualityChangedwith reasonCPU. - Two engines at once (PK): a second engine does not open the camera while another engine publishes it.
- UIKit 0.2.14 is built on 0.4.20; no UIKit API changes.
0.4.19
- Cloud proxy auto-retry no longer reports a disconnect to the app while it switches to the relay.
- Hardware H.264 on Codec2 encoders: the Baseline SPS is rewritten to Constrained Baseline flags.
- UIKit 0.2.13 is built on 0.4.19; no UIKit API changes.
0.4.18
- Video enhancement (
setVideoEnhance); UIKit 0.2.11 adds an Enhance button to the call controls, UIKit 0.2.12 to the live stream host controls — see the sections above. - Clear Voice noise suppression (
noiseSuppressionMode) — see the sections above. - Audience latency tiers (
audienceLatency) — see the sections above. - Codec detection (
AppEngine.getSupportedVideoCodecs(context)) — see the sections above. - A hardware H.264 path for more devices, behind remote device config and off by default.
0.4.17
- Cloud proxy for restrictive networks (
AppEngineOptions.cloudProxy,onProxyStateChanged) — see the section above.
0.4.16
- Encoder self-test on first launch (
runEncoderSelfTest,AppEngineOptions.encoderSelfTest), pre-call network test (startNetworkTest), network fallback (setRemoteSubscribeFallback,setLocalPublishFallback,onFallbackStateChanged) and token renewal (onTokenWillExpire,renewToken). - Remote diagnostics: scrubbed SDK logs uploaded after a call with a problem, when support asks, or on
uploadDiagnostics(reason). Opt out withAppEngineOptions.remoteDiagnostics.
0.4.15
- Automatic codec recovery: a camera whose encoder stops producing frames, fails, or that viewers cannot decode is republished on a safer path by itself. New
onVideoCodecFallback(from, to, reason). Per-device video settings are fetched in the background at join.
0.4.14
- No more black camera from newer Qualcomm and MediaTek phones: H.264 is published only through encoders proven across the field; every other phone publishes VP8, which all phones, iPhones and browsers can show.
0.4.13
setSpeakerphone()called before the first join is kept and applied when audio starts (voice rooms in the CALL scenario came up on the earpiece). NewisSpeakerphoneOn.AppEngineOptions.adaptiveStream(null = by mode) andautoRecoverStalledVideo(default true): a visible remote video stuck at 0 fps for 4 s is resubscribed.AppEngine.resubscribe(uid).setRemoteVideoEnabledworks with adaptive stream on (via the subscription);setRemoteVideoQualitylogs that adaptive stream owns the layer.AppVideoViewre-binds when it returns to a window, follows remote republishes, and gainssetMirror/resetMirror.setVideoConfig: bitrate and degradation change on the live encoder; codec and simulcast changes really republish; self-views follow andonLocalVideoChangedfires on restarts.setVideoProcessor(AppVideoProcessor?)— capture-side frame hook.- Several engines share one audio route; a second engine adopts the first one’s scenario and does not reset the route on leave.
0.4.10
AppVideoConfig.degradation—KEEP_RESOLUTION/KEEP_FRAMERATE/BALANCED/AUTO;minBitrate(reserved).- Default caps raised: 4 Mbps at 1080p, 2.2 Mbps at 720p when
maxBitrateis 0. Newheight = 1440preset (2560×1440, 5 Mbps, 30 fps). AppVideoCodec.AV1where the chipset encodes it;H265resolves to AUTO. The VP8 backup layer is kept for every codec but VP8.onVideoQualityChanged— what your own camera is sending (size, fps, bitrate, layer, reason).
0.4.9
- Camera codec is chosen per phone: hardware H.264 only where the encoder is one the engine can drive (0.4.8 – 0.4.9); elsewhere VP8, so a published camera always carries frames. Fixes black video from MediaTek phones (realme, Infinix).
0.4.7
- Fixes a crash in the microphone processor on phones that report a banded audio buffer (realme, vivo, Android 16) — the app was thrown out of every room ~20 s after joining.
AppEngine.enableDebugLogging(sink)mirrors engine logs into your own sink for support tickets.
0.4.6
AppAudioOptions.noiseSuppressionLevel(OFF … VERY_HIGH) andsetNoiseSuppressionLevel()— how hard residual noise between words is pushed down, after the suppressor.AppAudioOptions.agcTargetLevelDbfsandsetAgcTargetLevel()— a target level the microphone is steered to while someone talks (-18 dBFS for broadcast); never lifts silence.
0.4.5
- Music no longer depends on the microphone.
enableMicrophone(false)while a track is playing keeps the music going and silences only your voice;playMusicwhile the microphone is off starts the music without turning the microphone on. When the music ends orstopMusicis called, the microphone returns to whatever you last asked for. isMicrophoneEnablednow reports what you asked for, not whether the engine is internally capturing to carry the music.
0.4.4
AppVideoConfig.codec—AUTO(default) publishes VP8 where the phone has a hardware VP8 encoder, otherwise H.264 in hardware, always with a VP8 backup layer;VP8/H264force one.AppEngine.publishedCodec.- Self-preview follows the local camera through off→on, switch and settings changes — no more frozen self-view.
onLocalVideoChanged(). - Echo: MEDIA scenario runs in the communication audio mode with media attributes, and the phone’s hardware echo canceller / noise suppressor are used when present.
softwareEchoCancellationnow defaults tofalse. - In-room music plays on the media path (loudspeaker, media volume keys) in MEDIA sessions.
AppAudioOptions— independent AEC / NS / AGC switches,preferHardwareEffects, andmicGainapplied after processing with a soft limiter (setMicGain).onRemoteStats— per-user fps, size, freeze count, packet loss, jitter.onFirstRemoteVideoFrameandonConnectionStagefor join-to-picture timing.- Guardrails:
maxBitrateunder 10 000 logs a units warning; a non-presetheightsnaps to the nearest preset with a warning.
0.4.3
- Published VP8 by default (later refined by
codec = AUTOin 0.4.4).
0.4.2
- Dynacast off and explicit auto-subscribe: every publisher always sends, every joiner receives all tracks — fixes co-hosts seeing a black frame on flaky links.
0.4.1
setVideoConfigat runtime,setNoiseSuppression,captureLocalFrame.