Skip to content

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:

ItemRequirement
Android versionAndroid 5.0 (API Level 21) or higher
Supported ABIarm64-v8a only
Android StudioNo hard requirement; latest stable version recommended
GradleNo hard requirement; version 8.0 or newer recommended
JavaNo hard requirement; version 17 or newer recommended, aligned with Gradle requirements
Android Gradle PluginNo hard requirement; version 8.1.1 or newer recommended
compileSdk35

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.

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

xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
  • transport-sdk already declares INTERNET in its own manifest
  • A playback-only client only needs network access and does not need to add extra runtime permissions
  • If you use TiRtcAudioInput to capture the microphone, you also need RECORD_AUDIO
  • If you use TiRtcVideoInput to capture the camera and show local preview, you also need CAMERA

Initialize the SDK

Initialize the TiRTC runtime before calling any other SDK API.

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

  1. During development and debugging, generate it with TiRTC DevTools CLI
  2. 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:

  • TiRtcConn only manages connection state, commands, stream messages, and disconnects
  • TiRtcAudioInput / TiRtcVideoInput manage local capture and uplink binding
  • TiRtcAudioOutput / TiRtcVideoOutput manage remote playback and rendering
  • TiRtcVideoInput.attachPreview(...) only manages local preview
  • TiRtcVideoOutput.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 lifecycle
  • input.attach(connection, streamId) / detach(connection) manages uplink binding
  • output.attach(connection, streamId) / detach() manages remote consume route
  • connect()/disconnect() stays independent from attach()/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:

kotlin
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()
  }
}
  • peerId can come from your device list, fixed config, or business-provided data
  • token should be fetched right before connecting, not hard-coded in the client
  • yourBackend.fetchTiRtcToken(...) is only a placeholder. The real implementation depends on your backend API
kotlin
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:

  1. TiRtcConn manages the connection
  2. TiRtcAudioOutput / TiRtcVideoOutput manage playback
  3. TiRtcVideoOutput.attachView(...) manages the local render container
  4. destroy() performs final cleanup

You can organize a playback page with this table:

Activity timingRecommended 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:

kotlin
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 capture
  • attach()/detach() manages the sending relationship between the input and the connection
  • destroy() 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

xml
<FrameLayout
  android:id="@+id/remote_video_container"
  android:layout_width="match_parent"
  android:layout_height="240dp" />

Add the Media Members to the Activity

kotlin
private val audioOutput = TiRtcAudioOutput()
private val videoOutput = TiRtcVideoOutput()

private lateinit var remoteVideoContainer: FrameLayout

private val audioStreamId = 1
private val videoStreamId = 2
private val messageStreamId = 3
  • audioOutput plays remote audio
  • videoOutput renders remote video
  • messageStreamId is used for custom business stream messages and must not reuse the audio or video streamId

Bind the Render Host and the Remote Routes

kotlin
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.

  • audioStreamId binds the remote audio stream
  • videoStreamId binds the remote video stream
  • Android SDK methods accept streamId values in 0..255
  • audio, video, and stream messages share the same streamId namespace and must not overlap

If your Android app also sends media, use TiRtcAudioInput and TiRtcVideoInput directly.

kotlin
private val audioInput = TiRtcAudioInput()
private val videoInput = TiRtcVideoInput()

private val uplinkAudioStreamId = 11
private val uplinkVideoStreamId = 12

Configure and Start the Local Inputs

kotlin
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

kotlin
private fun bindLocalUplink() {
  audioInput.attach(conn, uplinkAudioStreamId)
  videoInput.attach(conn, uplinkVideoStreamId)
}

Keep these responsibilities separate:

  • start()/stop() only manages capture lifecycle
  • attach()/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

kotlin
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

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

kotlin
private val toggleLedCommandId = 0x1001
private val getTimeCommandId = 0x1002

These 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

kotlin
private fun sendToggleCommand() {
  conn.sendCommand(toggleLedCommandId, "toggle_led".encodeToByteArray())
}

Request-Response Command

kotlin
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

kotlin
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.

kotlin
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 peerId correct
  • Has the token expired or does it not match the target
  • Has the app declared the INTERNET permission
  • 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 streamId values
  • Did you call audioOutput.attach(conn, streamId) and videoOutput.attach(conn, streamId)
  • For video, did you also call videoOutput.attachView(container)
  • For capture, did you grant RECORD_AUDIO and CAMERA
  • Did you accidentally treat start()/stop() and attach()/detach() as the same lifecycle

Upload Logs

When you need help diagnosing a problem, call TiRtcDebugging.uploadLogs(...).

kotlin
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

Continue Reading

TiRTC