# Player SDK on Android

> **TL;DR** — Add `com.bytedanceapi:ttsdk-player_standard:1.52.100.5` with 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](https://docs.wandura.ai/developers/player-sdk#licence). |
| 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](https://docs.wandura.ai/developers/guides/playback-flow). |

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
1. **Start the SDK** at launch with your App ID and licence file, and check the licence before anything else.
2. **Read the device values** (device ID → `union_info`, device public key → `drm_kek`). Never send `null`, `"(null)"` or an empty string: throw instead.
3. **Ask your backend** to play the episode. Your backend applies your paywall and asks Wandura; your app never holds an API key.
4. **Play**: give `vid` and `play_auth_token` to the SDK; add the subtitles.
5. **Preload** the next episodes with the prefetched tickets your backend returns.
6. **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.
7. **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](https://docs.wandura.ai/developers/guides/playback-flow#code).

### The SDK behind one interface
```kotlin
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
```kotlin
@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
```kotlin
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_token` is kept in memory only: not in `SharedPreferences`, 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
- [Player SDK overview and licence](https://docs.wandura.ai/developers/player-sdk)
- [Smooth playback under load](https://docs.wandura.ai/developers/guides/smooth-playback#in-the-app)
- [Subtitles](https://docs.wandura.ai/developers/guides/subtitles)
