On this page
TL;DR — Add
com.bytedanceapi:ttsdk-player_standard:1.52.100.5with Gradle (API level 16+, Java 8,armeabi-v7a/arm64-v8a), put the licence file Wandura sends you in your assets, start the SDK, and read the two device values after the licence check passes. Your app sends them to your backend, gets a ticket back, and gives the ticket to the SDK. On an SDK error, ask for one fresh ticket and resume.
Prerequisites
| Licence | The .lic file and App ID from Wandura, bound to your final package name. See Player SDK. |
| Toolchain | Gradle, Android API level 16 or later, Java 8, ABIs armeabi-v7a or arm64-v8a. Permissions INTERNET, WAKE_LOCK, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE. |
| Devices | A real Android phone for encrypted playback. |
| Your backend | A ticket endpoint: Playback flow and paywall. |
The exact SDK calls (start-up, licence check, device values, playback, subtitles) are in the Player SDK guide on partner.wandura.ai → Developers → Player SDK. Wandura has verified this flow end to end on iOS; on Android it follows the vendor guide, so share your first test results in your channel.
The flow in your app
- Start the SDK at launch with your App ID and licence file, and check the licence before anything else.
- Read the device values (device ID →
union_info, device public key →drm_kek). Never sendnull,"(null)"or an empty string: throw instead. - Ask your backend to play the episode. Your backend applies your paywall and asks Wandura; your app never holds an API key.
- Play: give
vidandplay_auth_tokento the SDK; add the subtitles. - Preload the next episodes with the prefetched tickets your backend returns.
- On any SDK error (an expired ticket included): request one fresh ticket and resume at the same position. Do not branch on SDK error codes, and do not loop.
- On resume: if the current ticket has less than a minute left, replace it before playing on.
Code
The file below is the app side of that flow, in Kotlin. Only PlayerSdk touches the SDK: fill it in with the calls from the portal guide. Your backend's response shape matches the Node and Python backend examples.
The SDK behind one interface
data class DeviceValues(val unionInfo: String, val drmKek: String)
interface PlayerSdk {
/** Read only after the SDK has started and its licence check succeeded. Throws instead of returning null:
* never send "(null)", "null" or an empty string to your backend. */
fun deviceValues(): DeviceValues
fun play(vid: String, playAuthToken: String, startAtMs: Long)
fun preload(vid: String, playAuthToken: String)
fun addSubtitles(subtitles: List<Subtitle>)
val currentPositionMs: Long
/** A playback error, including an expired ticket. Do not branch on SDK error codes. */
var onError: ((Throwable) -> Unit)?
}Your backend's contract
@Serializable
data class Subtitle(val lang: String, val url: String, val format: String)
@Serializable
data class Ticket(
val vid: String,
@SerialName("play_auth_token") val playAuthToken: String, // memory only: never log, never persist
@SerialName("expires_at") val expiresAt: String,
val subtitles: List<Subtitle> = emptyList(),
) {
/** Wandura's clock decides; allow a minute for a device clock that runs slow. */
fun isFresh(now: Instant = Instant.now()): Boolean = Instant.parse(expiresAt).isAfter(now.plusSeconds(60))
}
@Serializable
data class NextTicket(@SerialName("episode_no") val episodeNo: Int, val ticket: Ticket)
/** The shape the example backends (Node, Python) return from YOUR `POST /v1/play`. */
@Serializable
data class PlayResponse(
val ok: Boolean,
val current: Ticket? = null,
val next: List<NextTicket> = emptyList(),
val outcome: String? = null, // locked | unavailable | not_ready | busy | broken — unknown = broken
)
/** Your backend. Implement with your HTTP client (OkHttp, Retrofit, Ktor) and a 15-second timeout: your backend may
* retry Wandura for a few seconds. Send YOUR viewer session, never a Wandura key. */
interface Backend {
suspend fun play(filmId: String, episodeNo: Int, device: DeviceValues): PlayResponse
}Start, prefetch, expiry, errors
sealed interface ScreenState {
data object Loading : ScreenState
data object Playing : ScreenState
data object Paywall : ScreenState
data class Message(val text: String, val canRetry: Boolean) : ScreenState
}
class PlaybackController(
private val sdk: PlayerSdk,
private val backend: Backend,
private val scope: CoroutineScope,
private val onState: (ScreenState) -> Unit,
) {
private val prefetched = mutableMapOf<Int, Ticket>() // episode number → ticket for THIS device only
private var current: Triple<String, Int, Ticket>? = null
private var refreshedAfterError = false
init {
sdk.onError = { scope.launch { recoverFromPlayerError() } }
}
/** The viewer tapped an episode. */
suspend fun start(filmId: String, episodeNo: Int) {
onState(ScreenState.Loading)
refreshedAfterError = false
val device = runCatching { sdk.deviceValues() }.getOrElse {
return onState(ScreenState.Message("Couldn't load this episode", canRetry = false)) // SDK setup bug
}
// A prefetched ticket starts the episode at once. The play request is still sent: it records the view.
prefetched.remove(episodeNo)?.takeIf { it.isFresh() }?.let { begin(filmId, episodeNo, it, 0) }
val r = runCatching { backend.play(filmId, episodeNo, device) }.getOrNull()
when {
r?.ok == true && r.current != null -> {
if (current?.second != episodeNo) begin(filmId, episodeNo, r.current, 0)
r.next.forEach { n -> // a prefetch missing from `next` is not an error
prefetched[n.episodeNo] = n.ticket
sdk.preload(n.ticket.vid, n.ticket.playAuthToken)
}
}
current?.second == episodeNo -> Unit // already playing from the prefetched ticket
r == null -> onState(ScreenState.Message("Taking longer than usual", canRetry = true))
else -> show(r.outcome)
}
}
/** Expired ticket or any SDK error: ONE fresh ticket, resume at the same position. Never a loop. */
private suspend fun recoverFromPlayerError() {
val c = current
val device = runCatching { sdk.deviceValues() }.getOrNull()
if (c == null || refreshedAfterError || device == null) {
return onState(ScreenState.Message("Couldn't load this episode", canRetry = true))
}
refreshedAfterError = true
val position = sdk.currentPositionMs
val r = runCatching { backend.play(c.first, c.second, device) }.getOrNull()
val ticket = r?.current
if (r?.ok != true || ticket == null) return onState(ScreenState.Message("Couldn't load this episode", canRetry = true))
begin(c.first, c.second, ticket, position)
}
/** Back from the background: a ticket with less than a minute left is replaced before resuming. */
suspend fun onResume() {
val c = current ?: return
if (c.third.isFresh()) return
refreshedAfterError = false
recoverFromPlayerError()
}
private fun begin(filmId: String, episodeNo: Int, ticket: Ticket, atMs: Long) {
current = Triple(filmId, episodeNo, ticket)
sdk.play(ticket.vid, ticket.playAuthToken, atMs)
sdk.addSubtitles(ticket.subtitles) // empty list = no subtitles yet, not an error
onState(ScreenState.Playing)
}
private fun show(outcome: String?) = onState(
when (outcome) {
"locked" -> ScreenState.Paywall
"unavailable" -> ScreenState.Message("This title is no longer available", canRetry = false)
"not_ready" -> ScreenState.Message("Not available yet", canRetry = false)
"busy" -> ScreenState.Message("Busy, try again in a moment", canRetry = true)
else -> ScreenState.Message("Couldn't load this episode", canRetry = true)
},
)
}Checklist
- The licence check passes before device values are read.
- Device values are never
null,"(null)"or empty when sent. - The app holds no
wnd_live_key (search the built APK/AAB). -
play_auth_tokenis kept in memory only: not inSharedPreferences, logs or crash reports. - An SDK error leads to one fresh ticket and a resume, never a loop.
- Resuming after more than 29 minutes replaces the ticket first.
- An episode without subtitles plays without an error.
Related
Updated 2 Oct 2026 · Change history (2)