External Integrations

Kotlin for Android

A fast-track reference for experienced developers. Every example is pulled directly from the TV-player app in this repo.

Why Kotlin

If you know TypeScript you already understand most of the ideas in Kotlin — the syntax is just different. The four things worth knowing upfront:

  • Null safety by design. The type system distinguishes nullable (String?) from non-nullable (String) at compile time — no more NullPointerException surprises.
  • Coroutines, not callbacks. Async code reads like sync code. suspend fun replaces Promise chains and RxJava.
  • Less boilerplate than Java. Data classes, extension functions, and smart casts remove 80% of Java ceremony.
  • Jetpack-native. Room, WorkManager, Hilt, and Compose are all designed for Kotlin-first APIs.

Variables, Types & Null Safety

val is immutable (like const). var is mutable. Types are inferred unless you need to be explicit.

val screenId: String = "bceb37ce-..."   // explicit type
val version = 42                         // inferred as Int
var retryCount = 0                       // mutable

// Nullable type — like TypeScript string | null
val deviceName: String? = null

// Safe call — short-circuits to null instead of throwing
val length = deviceName?.length          // Int? (null if deviceName is null)

// Elvis operator — like ?? in TypeScript
val display = deviceName ?: "Unknown"    // "Unknown" when null

// Non-null assertion — throws if null (avoid unless certain)
val raw = deviceName!!.trim()

From CredentialsManager.kt

The manager returns String? from SharedPreferences — the caller must handle the nullable case.

fun getScreenId(): String? = sharedPreferences.getString(KEY_SCREEN_ID, null)
fun getDeviceId(): String? = sharedPreferences.getString(KEY_DEVICE_ID, null)

From PlaybackViewModel.kt

The caller checks both IDs with isNullOrBlank() — an extension function that handles both null and empty string in one call.

val screenId = credentialsManager.getScreenId()
val deviceId = credentialsManager.getDeviceId()

if (screenId.isNullOrBlank() && deviceId.isNullOrBlank()) {
    _uiState.value = PlaybackUiState.Error("Device not provisioned.")
    return@launch
}

Data Classes

A data class is a plain value object. The compiler auto-generates equals(), hashCode(), toString(), and copy(). Think of it as TypeScript's interface but with built-in structural equality and a convenient clone helper.

From Repositories.kt domain models

// Primary constructor IS the class definition — no separate field declarations
data class Playlist(
    val id: String,
    val tenantId: String,
    val screenId: String,
    val status: String,
    val version: Int,
    val items: List<PlaylistItem>,
    val validUntil: Int,
)

// Optional fields use default values
data class PlaylistItem(
    val creativeId: String,
    val campaignId: String? = null,   // optional, defaults to null
    val creativeUrl: String,
    val creativeType: String,
    val duration: Int,
    val checksum: String?,
    val schedule: Schedule?,
)

// copy() creates a modified clone — great for immutable updates
val updated = playlist.copy(status = "inactive")

From ApiService.kt API request/response types

// Gson deserialises JSON into these exactly — no mapper needed
data class DeviceInfo(
    val manufacturer: String,
    val model: String,
    val osVersion: String,
    val appVersion: String,
    val macAddress: String,
    val ipAddress: String,
    val deviceName: String? = null,    // missing JSON key → null
    val locationHint: String? = null,
    val leanback: Boolean = true,      // default if absent
    val orientation: String = "landscape",
)

From Repositories.kt small value wrappers

// Typed progress steps — no stringly-typed Map<String, Any> needed
data class ProvisioningProgress(
    val stage: String,
    val detail: String? = null,
)

Sealed Classes & When Expressions

A sealed class is a closed type hierarchy — like TypeScript discriminated unions. All subclasses must be defined in the same file. The compiler then forces when branches to be exhaustive, and smart casts give you the subtype automatically inside each branch — no casting needed.

From PlaybackViewModel.kt UI state

sealed class PlaybackUiState {
    // Data subclass — holds a payload
    data class Loading(val message: String = "Loading...") : PlaybackUiState()

    data class Playing(
        val creativeId: String,
        val creativeType: String,
        val creativeName: String,
        val duration: Int,
    ) : PlaybackUiState()

    // Object subclass — no payload, singleton
    object Stopped : PlaybackUiState()

    data class Error(val message: String) : PlaybackUiState()
}

Smart casts in when

Inside each branch Kotlin already knows the exact subtype — you access state.creativeId directly with no cast.

// In PlaybackScreen.kt — Compose renders different UI per state
when (val state = uiState) {
    is PlaybackUiState.Loading  -> LoadingView(message = state.message)
    is PlaybackUiState.Playing  -> PlayingView(state, viewModel)
    is PlaybackUiState.Error    -> ErrorView(message = state.message)
    is PlaybackUiState.Stopped  -> Text("Playback Stopped")
    // compiler enforces all branches are covered
}

Multi-condition when — from PlaybackViewModel.kt

when without an argument works like a chain of if/else — but reads much more cleanly.

private fun handlePlaybackStateChange(state: PlaybackState) {
    when (state) {
        is PlaybackState.Ended -> {
            onPlaybackEnded(state.creativeId)   // smart cast: state is Ended here
            playlistManager.advanceToNext()
            playNext()
        }
        is PlaybackState.Error -> {
            onPlaybackError(state.creativeId, state.exception)
            playlistManager.advanceToNext()
            playNext()
        }
        is PlaybackState.Playing -> onPlaybackStarted(state.creativeId)
        is PlaybackState.Paused  -> { /* nothing */ }
        else -> { /* Idle, Ready, Preparing */ }
    }
}

Functions: suspend, extension & higher-order

suspend functions

A suspend fun can be paused and resumed without blocking a thread. It's not async/await — it's more like a continuation. You can only call one from inside another suspend function or a coroutine scope.

// From Repositories.kt — the interface signals async intent via suspend
interface PlaylistRepository {
    suspend fun fetchPlaylist(screenId: String): Result<Playlist>
    suspend fun savePlaylist(playlist: Playlist): Result<Unit>
    suspend fun getCachedPlaylist(screenId: String): Result<Playlist?>
}

// From RepositoryImpls.kt — withContext switches the thread pool
override suspend fun fetchPlaylist(screenId: String): Result<Playlist> {
    return withContext(Dispatchers.IO) {  // run on IO thread pool
        try {
            val response = apiService.getPlaylist(screenId) // suspends, not blocks
            when {
                response.isSuccessful -> Result.success(mapResponseToDomain(response.body()!!))
                response.code() == 404 -> Result.failure(PlaylistException("Not found"))
                else -> Result.failure(PlaylistException("HTTP ${response.code()}"))
            }
        } catch (e: Exception) {
            Result.failure(PlaylistException("Network error: ${e.message}", e))
        }
    }
}

Extension functions

Add behaviour to an existing type without inheriting from it. The syntax is fun TypeName.methodName(). Used heavily throughout the standard library.

// From ApiResponseValidator.kt — extends Result<T> with a prefix-mapping helper
private fun <T> Result<T>.mapError(prefix: String): Result<T> {
    return fold(
        onSuccess = { Result.success(it) },
        onFailure = { Result.failure(ApiResponseValidationException("$prefix: ${it.message}", it)) },
    )
}

// Called like a method on any Result<T>
fun validatePlaylistResponse(response: PlaylistResponse): Result<PlaylistResponse> {
    return runCatching {
        require(response.version > 0) { "version must be > 0" }
        // ... more checks
        response
    }.mapError("Invalid playlist response")   // <-- the extension in action
}

Higher-order functions & lambdas

Functions are first-class. Pass a lambda as a parameter with the type syntax (ParamType) -> ReturnType.

// From Repositories.kt — callback lambda as optional parameter with default
suspend fun pollForActivation(
    pairingCode: String,
    deviceInfo: DeviceInfo,
    onProgress: (ProvisioningProgress) -> Unit = {},  // default = no-op lambda
): Result<RegistrationResult>

// Caller passes a trailing lambda — brace goes outside parens
val result = repo.pollForActivation(code, info) { progress ->
    Log.d("TAG", "Stage: ${progress.stage}")
}

// Standard library higher-order functions you'll see everywhere
val urls = items.map { it.creativeUrl }          // transform
val videos = items.filter { it.creativeType == "video" }
val total = items.sumOf { it.duration }
val first = items.firstOrNull { it.campaignId != null }

Coroutines & Flow

Coroutines are the async runtime. Flow is the reactive stream primitive — like an RxJS Observable or an async generator. StateFlow is a hot Flow that always holds its latest value and replays it to new collectors.

viewModelScope — from PlaybackViewModel.kt

// MutableStateFlow holds state; asStateFlow() exposes a read-only view
private val _uiState = MutableStateFlow<PlaybackUiState>(PlaybackUiState.Loading())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()

init {
    // launch a coroutine tied to the ViewModel's lifecycle
    viewModelScope.launch {
        contentPlayer.observePlaybackState().collect { state ->
            handlePlaybackStateChange(state)
        }
    }
}

fun startPlayback() {
    viewModelScope.launch {           // suspends without blocking the main thread
        val screenId = credentialsManager.getScreenId()
        val playlistData = loadPlaylistForPlayback(screenId, null)
        if (playlistData == null) {
            _uiState.value = PlaybackUiState.Error("No playlist available.")
            return@launch             // return from the lambda, not the outer function
        }
        // ...
    }
}

Flow from Room — from DAOs.kt

Room emits a new value whenever the underlying table changes. The ViewModel listens and auto-retries playback when a playlist finally appears.

// In PlaylistDao — returns Flow, not a one-shot value
@Query("SELECT * FROM playlists WHERE deviceId = :deviceId ORDER BY updatedAt DESC LIMIT 1")
fun observePlaylist(deviceId: String): Flow<PlaylistEntity?>

// In PlaybackViewModel — collect the flow and restart playback on new data
private fun schedulePlaybackRetryOnSync(screenId: String?) {
    if (screenId.isNullOrBlank()) return
    playbackRetryJob = viewModelScope.launch {
        playlistDao.observePlaylist(screenId).collect { playlist ->
            if (playlist != null && _uiState.value is PlaybackUiState.Error) {
                playbackRetryJob?.cancel()
                startPlayback()
            }
        }
    }
}

collectAsState in Compose — from PlaybackScreen.kt

@Composable
fun PlaybackScreen(viewModel: PlaybackViewModel = hiltViewModel()) {
    // collectAsState() subscribes to the StateFlow and triggers recomposition on change
    val uiState by viewModel.uiState.collectAsState()

    Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
        when (val state = uiState) {
            is PlaybackUiState.Loading  -> LoadingView(state.message)
            is PlaybackUiState.Playing  -> PlayingView(state, viewModel)
            is PlaybackUiState.Error    -> ErrorView(state.message)
            is PlaybackUiState.Stopped  -> { /* nothing */ }
        }
    }
}

Object Declarations & Companion Objects

Kotlin has no static keyword.

  • object Foo — a process-wide singleton, lazily initialised on first use.
  • companion object — a singleton scoped to its enclosing class. Used for constants and factory functions (like Java's static).

object — from ApiResponseValidator.kt

// Declared once, used everywhere: ApiResponseValidator.validatePlaylistResponse(...)
object ApiResponseValidator {
    fun validatePlaylistResponse(response: PlaylistResponse): Result<PlaylistResponse> {
        return runCatching {
            requireNotBlank(response.id, "id")
            requireNotBlank(response.screenId, "screenId")
            require(response.version > 0) { "version must be > 0" }
            require(response.validUntil > 0) { "validUntil must be > 0" }
            response.items.forEachIndexed { index, item ->
                requireNotBlank(item.creativeId, "items[$index].creativeId")
                require(item.creativeType == "video" || item.creativeType == "image") {
                    "items[$index].creativeType must be 'video' or 'image'"
                }
            }
            response
        }.mapError("Invalid playlist response")
    }

    // private helper — accessible only inside this object
    private fun requireNotBlank(value: String, field: String) {
        require(value.isNotBlank()) { "$field must not be blank" }
    }
}

companion object — from PlaylistSyncWorker.kt

class PlaylistSyncWorker @AssistedInject constructor(...) : CoroutineWorker(...) {
    override suspend fun doWork(): Result { /* ... */ }

    companion object {
        private const val TAG = "PlaylistSyncWorker"
        const val WORK_NAME = "playlist_sync"         // public constant
        const val DEFAULT_SYNC_INTERVAL_MINUTES = 5L

        // Factory function — called as PlaylistSyncWorker.createWorkRequest(10L)
        fun createWorkRequest(intervalMinutes: Long = DEFAULT_SYNC_INTERVAL_MINUTES): WorkRequest {
            return PeriodicWorkRequestBuilder<PlaylistSyncWorker>(intervalMinutes, TimeUnit.MINUTES)
                .setConstraints(
                    Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
                )
                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS)
                .build()
        }
    }
}

Lazy Delegation

by lazy { ... } defers a property's initialisation until first access. The block runs once and the result is cached. It's thread-safe by default. Use it for anything expensive that you don't always need — crypto key construction, database clients, SharedPreferences.

From CredentialsManager.kt

@Singleton
class CredentialsManager @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    // SharedPreferences is created on first access, never earlier
    private val sharedPreferences by lazy {
        EncryptedSharedPreferences.create(
            context,
            "device_credentials",
            MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
        )
    }

    fun getScreenId(): String? = sharedPreferences.getString(KEY_SCREEN_ID, null)
}

Lazy with fallback — from ProvisioningRepositoryImpl.kt

The lazy block can contain full control flow. Here, a failed encryption init falls back to plain SharedPreferences gracefully.

private val encryptedPrefs: SharedPreferences by lazy {
    try {
        val masterKey = MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()
        EncryptedSharedPreferences.create(context, PREFS_FILE_NAME, masterKey, ...)
    } catch (e: Exception) {
        Timber.w(e, "Falling back to plain SharedPreferences")
        context.getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE)
    }
}

Dependency Injection with Hilt

Hilt is Google's DI framework built on top of Dagger. You annotate things and Hilt wires them together at compile time — no manual factory classes, no service locators. The key annotations:

@HiltAndroidApp

On Application subclass — bootstraps the DI container

@AndroidEntryPoint

On Activity/Fragment — injects fields annotated @Inject

@HiltViewModel

On ViewModel — lets hiltViewModel() inject it in Compose

@Inject constructor

Tells Hilt how to construct the class

@Singleton

One instance per application

@Module + @InstallIn

Manual bindings for types you don't own (Retrofit, Room)

@Provides

Factory function inside a Module

@ApplicationContext

Qualifier — inject Context without Activity leaks

Module — from RepositoryModule.kt

Interfaces can't be constructed directly, so a module tells Hilt which concrete class to use and how to build it.

@Module
@InstallIn(SingletonComponent::class)   // live as long as the app
object RepositoryModule {

    @Provides
    @Singleton
    fun providePlaylistRepository(
        apiService: ApiService,           // Hilt provides this from NetworkModule
        playlistDao: PlaylistDao,          // Hilt provides this from DatabaseModule
        @ApplicationContext context: Context,
        connectivityMonitor: ConnectivityMonitor,
        playlistSyncStatusStore: PlaylistSyncStatusStore,
    ): PlaylistRepository {               // return type = what gets injected everywhere
        return PlaylistRepositoryImpl(apiService, playlistDao, context,
                                      connectivityMonitor, playlistSyncStatusStore)
    }
}

HiltViewModel — from PlaybackViewModel.kt

@HiltViewModel
class PlaybackViewModel @Inject constructor(
    private val playlistDao: PlaylistDao,
    private val playlistRepository: PlaylistRepository,
    private val contentPlayer: ContentPlayer,
    private val credentialsManager: CredentialsManager,
    private val workManager: WorkManager,
    // ... more deps
) : ViewModel() { ... }

// In PlaybackScreen.kt — hiltViewModel() resolves the whole tree automatically
@Composable
fun PlaybackScreen(viewModel: PlaybackViewModel = hiltViewModel()) { ... }

HiltWorker (WorkManager) — from PlaylistSyncWorker.kt

Workers use @AssistedInject because WorkManager injects context and params at runtime — Hilt handles the rest.

@HiltWorker
class PlaylistSyncWorker @AssistedInject constructor(
    @Assisted context: Context,              // injected by WorkManager at runtime
    @Assisted params: WorkerParameters,
    private val playlistRepository: PlaylistRepository,  // injected by Hilt
    private val credentialsManager: CredentialsManager,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val screenId = credentialsManager.getScreenId() ?: return Result.failure()
        val fetchResult = playlistRepository.fetchPlaylist(screenId)
        if (fetchResult.isFailure) return Result.retry()
        playlistRepository.savePlaylist(fetchResult.getOrThrow())
        return Result.success()
    }
}

Room Database

Room is an ORM over SQLite. The three pieces are Entity (table schema), DAO (query interface), and Database (the connection). Annotations replace boilerplate SQL schema definitions.

Entities — from Entities.kt

// Simple entity — annotation creates the table
@Entity(tableName = "playlists")
data class PlaylistEntity(
    @PrimaryKey val id: String,
    val version: Int,
    val deviceId: String,     // queries filter on this
    val updatedAt: Long,
    val createdAt: Long,
)

// Entity with foreign key and cascade delete
@Entity(
    tableName = "playlist_items",
    foreignKeys = [ForeignKey(
        entity = PlaylistEntity::class,
        parentColumns = ["id"],
        childColumns = ["playlistId"],
        onDelete = ForeignKey.CASCADE,     // delete parent → delete all children
    )],
    indices = [Index(value = ["playlistId"])],  // speed up JOIN/filter queries
)
data class PlaylistItemEntity(
    @PrimaryKey val id: String,
    val playlistId: String,
    val creativeId: String,
    val campaignId: String?,               // nullable column
    val creativeUrl: String,
    val creativeType: String,
    val duration: Int,
    val orderIndex: Int,
    // ...
)

DAO — from DAOs.kt

@Dao
interface PlaylistDao {
    // Insert — REPLACE strategy handles upsert
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPlaylist(playlist: PlaylistEntity)

    // Query — suspend for one-shot async read
    @Query("SELECT * FROM playlists WHERE deviceId = :deviceId ORDER BY updatedAt DESC LIMIT 1")
    suspend fun getPlaylistByDeviceId(deviceId: String): PlaylistEntity?

    // Flow query — emits a new value every time the table changes
    @Query("SELECT * FROM playlists WHERE deviceId = :deviceId ORDER BY updatedAt DESC LIMIT 1")
    fun observePlaylist(deviceId: String): Flow<PlaylistEntity?>

    // Transaction — both writes succeed or both roll back
    @Transaction
    suspend fun replacePlaylistWithItems(
        playlist: PlaylistEntity,
        items: List<PlaylistItemEntity>,
    ) {
        deletePlaylistsByDeviceId(playlist.deviceId)
        insertPlaylist(playlist)
        insertPlaylistItems(items)
    }

    @Query("DELETE FROM playlists WHERE deviceId = :deviceId")
    suspend fun deletePlaylistsByDeviceId(deviceId: String)
}

Jetpack Compose

Compose is a declarative UI framework — like React but for Android. A @Composable function describes what the UI looks like for a given state. When state changes, Compose re-runs only the affected composables (recomposition).

Core hooks — from PlaybackScreen.kt

@Composable
fun PlaybackScreen(viewModel: PlaybackViewModel = hiltViewModel()) {
    // collectAsState() — subscribes to StateFlow, triggers recomposition on change
    val uiState by viewModel.uiState.collectAsState()

    // remember {} — survives recomposition; destroyed when composable leaves the tree
    val focusRequester = remember { FocusRequester() }
    var showAdminOverlay by remember { mutableStateOf(false) }

    // LaunchedEffect(key) — runs a coroutine once per unique key value
    // Use for side effects: starting playback, requesting focus, etc.
    LaunchedEffect(Unit) {
        viewModel.startPlayback()
        focusRequester.requestFocus()
    }

    // DisposableEffect — runs setup on enter, onDispose{} runs on leave
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_PAUSE  -> viewModel.pausePlayback()
                Lifecycle.Event.ON_RESUME -> viewModel.resumePlayback()
                else -> {}
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
    }

    Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
        when (val state = uiState) {
            is PlaybackUiState.Loading -> Text(state.message, color = Color.White)
            is PlaybackUiState.Playing -> PlayingView(state, viewModel)
            is PlaybackUiState.Error   -> ErrorView(state.message)
            is PlaybackUiState.Stopped -> { /* nothing */ }
        }
    }
}

AndroidView — embedding native Views (ExoPlayer)

When a library only provides a classic Android View (like ExoPlayer's PlayerView), AndroidView bridges it into the Compose tree.

@Composable
private fun VideoPlayerView(viewModel: PlaybackViewModel) {
    AndroidView(
        // factory — creates the View once
        factory = { ctx ->
            PlayerView(ctx).apply {
                useController = false
                keepScreenOn = true
                resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
            }
        },
        // update — called on every recomposition to sync state
        update = { playerView ->
            viewModel.getPlayer()?.let { player ->
                playerView.player = player
            }
        },
        modifier = Modifier.fillMaxSize(),
    )
}

Image creative with timed advance

LaunchedEffect(key) re-launches whenever the key changes — here, a new creativeId automatically cancels the previous countdown and starts a fresh one.

@Composable
private fun ImageCreativeView(
    creativeId: String,
    duration: Int,
    viewModel: PlaybackViewModel,
) {
    var imagePath by remember { mutableStateOf<String?>(null) }

    // Fetch the cached file path as a side-effect
    LaunchedEffect(creativeId) {
        imagePath = viewModel.getCachedFilePath(creativeId)
    }

    // Advance to next creative after duration expires
    LaunchedEffect(creativeId) {
        delay(duration * 1000L)
        viewModel.skipToNext()
    }

    imagePath?.let { path ->
        Image(
            painter = rememberAsyncImagePainter(
                ImageRequest.Builder(LocalContext.current).data(File(path)).build()
            ),
            contentDescription = null,
            contentScale = ContentScale.Fit,
            modifier = Modifier.fillMaxSize(),
        )
    }
}

Quick Reference

KotlinTypeScript equivalentWhere in the app
val / varconst / leteverywhere
String?string | nullCredentialsManager, Entities
?. ?:?. ??PlaybackViewModel, RepositoryImpls
data classinterface + structural equalityRepositories.kt, ApiService.kt
sealed classdiscriminated union typePlaybackUiState, PlaybackState
when (x) { is Foo -> }switch / if-else chain + narrowingPlaybackViewModel, PlaybackScreen
suspend funasync functionAll Repository interfaces
withContext(Dispatchers.IO)await inside worker threadRepositoryImpls.kt
Flow<T>Observable / AsyncGeneratorPlaylistDao, ConfigRepository
StateFlow<T>BehaviorSubject / signalPlaybackViewModel._uiState
by lazy { }lazy initialiser patternCredentialsManager, ProvisioningRepositoryImpl
object Fooconst singleton exportApiResponseValidator
companion objectstatic membersPlaylistSyncWorker
@Composable funReact function componentPlaybackScreen, AdminOverlay
remember { }useState / useMemoPlaybackScreen
LaunchedEffect(key)useEffect(() => {}, [key])PlaybackScreen
collectAsState()useSelector / useAtomPlaybackScreen