Android Quick Start
This page shows how to complete the minimum TiRTC Android integration and bring up a first successful connection.
Official Demo
If you want a standalone sample project you can build directly, start with the official open-source Android demo repository: tangeai/tirtc-example-android.
Prerequisites
Confirm the following before integration starts:
| Item | Requirement |
|---|---|
| Android version | Android 5.0 (API Level 21) or higher |
| Supported ABI | arm64-v8a only |
| Android Studio | No hard requirement; latest stable version recommended |
| Gradle | No hard requirement; version 8.0 or newer recommended |
| Java | No hard requirement; version 17 or newer recommended, aligned with Gradle requirements |
| Android Gradle Plugin | No hard requirement; version 8.1.1 or newer recommended |
compileSdk | 35 |
Keep your toolchain versions as close as possible to the official samples.
Get the SDK
Get the SDK version from the Android SDK Release Notes, then replace <latest-version> in the example below.
pluginManagement {
repositories {
maven {
url = uri("http://repo-sdk.tange-ai.com/repository/maven-public/")
isAllowInsecureProtocol = true
credentials {
username = "tange_user"
password = "tange_user"
}
}
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencies {
implementation("com.tange.ai:tirtc-av:<latest-version>")
}Declare Permissions
AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />transport-sdkalready declaresINTERNETin its own manifest- A playback-only client only needs network access and does not need to add extra runtime permissions
- If you use
TiRtcAudioInputto capture the microphone, you also needRECORD_AUDIO - If you use
TiRtcVideoInputto capture the camera and show local preview, you also needCAMERA
Initialize the SDK
Initialize the TiRTC runtime before calling any other SDK API.
class DemoApp : Application() {
override fun onCreate() {
super.onCreate()
TiRtc.initialize(
TiRtc.Config.Builder(this)
.setEnableConsoleLog(BuildConfig.DEBUG)
.build(),
)
}
}Get a Connection Token
A connection token is a short-lived credential used to verify that the client is allowed to connect to TiRTC.
Before you connect, prepare peerId and then fetch the token required for this connection.
There are two common ways to get a token:
- During development and debugging, generate it with TiRTC DevTools CLI
- In production, generate it in your own backend according to the documented signing rule. See Generate a Connection Token
TiRTC DevTools CLI is only suitable for development and local debugging. It is not a production delivery strategy.
In production, your business backend should sign and issue the token, and the client should only request the result.
Do not store accessId, secretKey, or any other signing credentials in the client app. These are server-side secrets, and leakage is a serious security risk.
Understand the Object Model
The Android SDK now expresses media lifecycle on the media objects themselves:
TiRtcConnonly manages connection state, commands, stream messages, and disconnectsTiRtcAudioInput/TiRtcVideoInputmanage local capture and uplink bindingTiRtcAudioOutput/TiRtcVideoOutputmanage remote playback and renderingTiRtcVideoInput.attachPreview(...)only manages local previewTiRtcVideoOutput.attachView(...)only manages the local render host
Do not treat TiRtcConn as the media binding owner anymore. The public contract is now:
input.start()/stop()manages producer lifecycleinput.attach(connection, streamId)/detach(connection)manages uplink bindingoutput.attach(connection, streamId)/detach()manages remote consume routeconnect()/disconnect()stays independent fromattach()/detach()
Establish a Connection
Use TiRtcConn to create one connection to the remote peer. Commands, stream messages, and media-object attach calls all operate around this connection.
The following examples assume a single PlayerActivity:
class PlayerActivity : AppCompatActivity() {
private val conn = TiRtcConn()
private val peerId = "your-peer-id"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
conn.setObserver(createConnObserver())
}
override fun onResume() {
super.onResume()
lifecycleScope.launch {
val token = yourBackend.fetchTiRtcToken(peerId = peerId)
conn.connect(peerId, token)
}
}
override fun onPause() {
super.onPause()
conn.disconnect()
}
override fun onDestroy() {
super.onDestroy()
releaseRtc()
}
}peerIdcan come from your device list, fixed config, or business-provided datatokenshould be fetched right before connecting, not hard-coded in the clientyourBackend.fetchTiRtcToken(...)is only a placeholder. The real implementation depends on your backend API
private fun createConnObserver() =
object : TiRtcConn.Observer {
override fun onStateChanged(state: TiRtcConn.State) {
println("conn state=$state")
}
override fun onDisconnected(errorCode: Int) {
println("conn disconnected error=$errorCode")
}
override fun onRemoteCommandRequest(
remoteRequestId: Long,
command: RtcConnCommand,
) {
replyRemoteCommand(remoteRequestId, command)
}
override fun onStreamMessageReceived(
streamId: Int,
message: RtcStreamMessage,
) {
val text = message.data.decodeToString()
println(
"stream message received streamId=$streamId ts=${message.timestampMs} text=$text",
)
}
override fun onError(code: Int, message: String) {
println("conn error code=$code message=$message")
}
}Map It to Activity Lifecycle
If the page behavior is simply “connect when entering, disconnect when leaving,” the clearest and safest way to structure the code is to place the actions directly on the Activity lifecycle.
Keep the mental model to four layers:
TiRtcConnmanages the connectionTiRtcAudioOutput/TiRtcVideoOutputmanage playbackTiRtcVideoOutput.attachView(...)manages the local render containerdestroy()performs final cleanup
You can organize a playback page with this table:
| Activity timing | Recommended action |
|---|---|
onCreate() | Create conn, audioOutput, and videoOutput; set observers; call videoOutput.attachView(container) to bind the render host |
onResume() | Fetch a fresh token; call audioOutput.attach(conn, audioStreamId) and videoOutput.attach(conn, videoStreamId); then call conn.connect(peerId, token) |
onPause() | Call conn.disconnect(); if you want playback to stop immediately after leaving the page, also call audioOutput.detach() and videoOutput.detach() |
onDestroy() | Call audioOutput.detach(), videoOutput.detach(), and videoOutput.detachView(), then destroy() the objects |
One minimal playback page can look like this:
class PlayerActivity : AppCompatActivity() {
private val conn = TiRtcConn()
private val audioOutput = TiRtcAudioOutput()
private val videoOutput = TiRtcVideoOutput()
private val audioStreamId = 1
private val videoStreamId = 2
private val peerId = "your-peer-id"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
conn.setObserver(createConnObserver())
videoOutput.attachView(findViewById(R.id.remote_video_container))
}
override fun onResume() {
super.onResume()
lifecycleScope.launch {
val token = yourBackend.fetchTiRtcToken(peerId = peerId)
audioOutput.attach(conn, audioStreamId)
videoOutput.attach(conn, videoStreamId)
conn.connect(peerId, token)
}
}
override fun onPause() {
super.onPause()
conn.disconnect()
}
override fun onDestroy() {
super.onDestroy()
audioOutput.detach()
videoOutput.detach()
videoOutput.detachView()
audioOutput.destroy()
videoOutput.destroy()
conn.destroy()
}
}The important part is not to mix these three concerns together:
attachView(...)only means “where the picture should render”output.attach(...)only means “which remote stream this output should consume”connect(...)only means “when the transport connection is established”
If the page also captures and sends local media later, extend this skeleton with audioInput and videoInput, but keep the same separation:
start()/stop()manages captureattach()/detach()manages the sending relationship between the input and the connectiondestroy()performs final cleanup
Play Remote Audio and Video
Before you can play remote media, prepare a video container, an audio output, a video output, and the corresponding streamId values.
Prepare the Video Container
<FrameLayout
android:id="@+id/remote_video_container"
android:layout_width="match_parent"
android:layout_height="240dp" />Add the Media Members to the Activity
private val audioOutput = TiRtcAudioOutput()
private val videoOutput = TiRtcVideoOutput()
private lateinit var remoteVideoContainer: FrameLayout
private val audioStreamId = 1
private val videoStreamId = 2
private val messageStreamId = 3audioOutputplays remote audiovideoOutputrenders remote videomessageStreamIdis used for custom business stream messages and must not reuse the audio or videostreamId
Bind the Render Host and the Remote Routes
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
remoteVideoContainer = findViewById(R.id.remote_video_container)
videoOutput.attachView(remoteVideoContainer)
}
private fun bindRemoteMedia() {
audioOutput.attach(conn, audioStreamId)
videoOutput.attach(conn, videoStreamId)
}You can call bindRemoteMedia() before connect(...) or after the connection is established. attach() only declares the media binding relationship. It does not establish the connection.
When remote media arrives and the streamId matches:
- audio starts through
audioOutput - video renders into the container attached by
attachView(...)
audioStreamId and videoStreamId
Audio and video use separate streamId values.
audioStreamIdbinds the remote audio streamvideoStreamIdbinds the remote video stream- Android SDK methods accept
streamIdvalues in0..255 - audio, video, and stream messages share the same
streamIdnamespace and must not overlap
Capture Local Audio and Video for Uplink
If your Android app also sends media, use TiRtcAudioInput and TiRtcVideoInput directly.
private val audioInput = TiRtcAudioInput()
private val videoInput = TiRtcVideoInput()
private val uplinkAudioStreamId = 11
private val uplinkVideoStreamId = 12Configure and Start the Local Inputs
private fun startLocalCapture(previewContainer: FrameLayout) {
val videoOptions = TiRtcVideoInput.Options().apply {
width = 640
height = 380
fps = 15
cameraFacing = RtcCameraFacing.FRONT
}
videoInput.setOptions(videoOptions)
videoInput.attachPreview(previewContainer)
audioInput.start()
videoInput.start()
}Bind Them to the Connection
private fun bindLocalUplink() {
audioInput.attach(conn, uplinkAudioStreamId)
videoInput.attach(conn, uplinkVideoStreamId)
}Keep these responsibilities separate:
start()/stop()only manages capture lifecycleattach()/detach()only manages the binding between the input object and the connection
This means you can start capture first and attach later, or attach first and keep the binding relationship across later connection establishment.
Send and Receive Stream Messages
Stream messages are suitable for lightweight business data and are always attached to a specific streamId.
Receive Stream Messages
override fun onStreamMessageReceived(
streamId: Int,
message: RtcStreamMessage,
) {
val text = message.data.decodeToString()
println(
"stream message received streamId=$streamId ts=${message.timestampMs} text=$text",
)
}Send a Stream Message
private fun sendStreamPing() {
conn.sendStreamMessage(
streamId = messageStreamId,
timestampMs = System.currentTimeMillis(),
data = "hello".encodeToByteArray(),
)
}Send Commands
Commands are used to control remote behavior or to send a request and wait for a response.
First define the command IDs agreed between your client and the remote side:
private val toggleLedCommandId = 0x1001
private val getTimeCommandId = 0x1002These commandId values must be agreed in your peer protocol and stay within 0x1000..0x7FFF. Values below 0x1000 are reserved for internal runtime commands.
One-Way Command
private fun sendToggleCommand() {
conn.sendCommand(toggleLedCommandId, "toggle_led".encodeToByteArray())
}Request-Response Command
private fun requestRemoteTime() {
conn.requestCommand(
commandId = getTimeCommandId,
data = "get_time".encodeToByteArray(),
timeoutMs = 3_000,
callback = object : TiRtcConn.CommandCallback {
override fun onSuccess(response: RtcConnCommandResponse) {
println(
"request success commandId=${response.commandId} payload=${response.data.decodeToString()}",
)
}
override fun onFailure(code: Int, message: String) {
println("request failed code=$code message=$message")
}
},
)
}Reply to a Remote Request
private fun replyRemoteCommand(
remoteRequestId: Long,
command: RtcConnCommand,
) {
conn.replyRemoteCommand(
remoteRequestId = remoteRequestId,
commandId = command.commandId,
data = "ok".encodeToByteArray(),
)
}Release Resources
When the page exits or these objects are no longer needed, release them in the reverse direction of creation.
private fun releaseRtc() {
audioInput.detach(conn)
videoInput.detach(conn)
audioOutput.detach()
videoOutput.detach()
audioInput.stop()
videoInput.stop()
videoInput.detachPreview()
videoOutput.detachView()
audioInput.destroy()
videoInput.destroy()
audioOutput.destroy()
videoOutput.destroy()
conn.destroy()
}If the page only plays remote media and never creates inputs, you can omit the audioInput and videoInput release steps.
Troubleshooting Starts Here
Cannot Connect
If the connection cannot be established, check these items first:
- Is
peerIdcorrect - Has the
tokenexpired or does it not match the target - Has the app declared the
INTERNETpermission - Did you call
TiRtc.initialize(...)before using the SDK
Then inspect the callbacks for direct clues:
onError(...)onDisconnected(...)
Connected but No Audio or Video
Check these items first:
- Do the sender and receiver use the same
streamIdvalues - Did you call
audioOutput.attach(conn, streamId)andvideoOutput.attach(conn, streamId) - For video, did you also call
videoOutput.attachView(container) - For capture, did you grant
RECORD_AUDIOandCAMERA - Did you accidentally treat
start()/stop()andattach()/detach()as the same lifecycle
Upload Logs
When you need help diagnosing a problem, call TiRtcDebugging.uploadLogs(...).
val code =
TiRtcDebugging.uploadLogs(
object : TiRtcDebugging.UploadLogsCallback {
override fun onSuccess(logId: String) {
println("upload logs success logId=$logId")
}
override fun onFailure(code: Int) {
println("upload logs failed code=$code")
}
},
)
println("uploadLogs submit code=$code")When reporting an issue, include these details when possible:
- when the issue happened
- device model and Android version
- the connection target
- whether the issue is connect failure, no audio, no video, or command failure