> ## Documentation Index
> Fetch the complete documentation index at: https://docs.punchplay.tv/llms.txt
> Use this file to discover all available pages before exploring further.

# Android & TV Integration

> Build a Kotlin client against the PunchPlay platform API, with device authorization, token refresh, and library sync.

PunchPlay's JSON field names are stable and map directly onto Retrofit and
kotlinx.serialization. A TV or mobile client authenticates with the device
flow, then mirrors the library through [partner sync](/partner-sync).

```kotlin theme={null}
@Serializable
data class DeviceCodeRequest(
    @SerialName("client_id") val clientId: String,
    val scope: String,
)

@Serializable
data class SyncChanges(
    val changes: List<SyncChange>,
    val hasMore: Boolean,
    val nextCursor: String,
    val resetRequired: Boolean,
    val resources: List<String>,
)

interface PunchPlayApi {
    @POST("api/platform/v1/auth/device/code")
    suspend fun createDeviceCode(@Body body: DeviceCodeRequest): DeviceCode

    @POST("api/platform/v1/auth/refresh")
    suspend fun refresh(@Body body: RefreshRequest): Tokens

    @GET("api/platform/v1/me/sync/changes")
    suspend fun changes(@Query("cursor") cursor: String?): SyncChanges

    @GET("api/platform/v1/me/sync/snapshot")
    suspend fun snapshot(
        @Query("resource") resource: String,
        @Query("after") after: Long = 0,
        @Query("limit") limit: Int = 500,
    ): SyncSnapshot

    @POST("api/platform/v1/sync/history")
    suspend fun pushHistory(
        @Header("Idempotency-Key") idempotencyKey: String,
        @Body body: BulkHistoryRequest,
    ): BulkMutationResponse
}
```

## Keeping the token fresh

Access tokens last one hour, so refresh is not optional — a client that only
stores the access token stops working mid-session. Refresh tokens last a year
but **rotate on every use**: each refresh returns a new one and immediately
invalidates the old.

That rotation is the part worth care on Android. Several coroutines hitting the
API at once will each see `401` and each try to refresh, and those refreshes
invalidate one another — signing the user out. Serialise them behind a mutex so
only the first refreshes and the rest await its result:

```kotlin theme={null}
class TokenStore(private val api: PunchPlayApi, private val clientId: String) {
    private val mutex = Mutex()
    @Volatile private var tokens: Tokens = load()

    suspend fun accessToken(): String = tokens.accessToken

    /** Refreshes once even when several callers race a 401. */
    suspend fun refreshAfter(stale: String): String? = mutex.withLock {
        // Someone else already refreshed while this caller waited for the lock.
        if (tokens.accessToken != stale) return@withLock tokens.accessToken

        val next = runCatching {
            api.refresh(RefreshRequest(clientId = clientId, refreshToken = tokens.refreshToken))
        }.getOrElse { return@withLock null }

        // Persist before returning: losing a rotated refresh token means the
        // profile has to authorize again from scratch.
        tokens = Tokens(next.accessToken, next.refreshToken).also(::persist)
        tokens.accessToken
    }
}

class AuthInterceptor(private val store: TokenStore) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val used = runBlocking { store.accessToken() }
        val response = chain.proceed(chain.request().withBearer(used))
        if (response.code != 401) return response

        val refreshed = runBlocking { store.refreshAfter(used) } ?: return response
        response.close()
        return chain.proceed(chain.request().withBearer(refreshed))
    }
}
```

Retry a `401` exactly once. If the replay also fails, the refresh token is gone
and the profile must run device authorization again.

Recommended initial-sync ordering:

```kotlin theme={null}
val start = api.changes(cursor = null)
require(start.resetRequired)
val highWater = start.nextCursor

for (resource in start.resources) {
    var after = 0L
    do {
        val page = api.snapshot(resource, after)
        database.transaction { mirror(resource, page.items) }
        after = page.nextAfter ?: 0L
    } while (page.hasMore)
}

var page = api.changes(highWater)
while (true) {
    database.transaction {
        page.changes.forEach(::applyChangeOrTombstone)
        savePunchPlayCursor(page.nextCursor)
    }
    if (!page.hasMore) break
    page = api.changes(page.nextCursor)
}
```

Applying a change twice must be harmless: delivery is at-least-once, so
`applyChangeOrTombstone` should key on `resource` plus `resourceId` rather than
assume each change arrives once.

Use one encrypted token record and one sync cursor per Nuvio profile. Never
share an access token or cached mirror between profiles.
