SDKs

Android Headless SDK

Kotlin SDK for the HotMic public API. Headless — no UI, no player. Wraps auth, stream listing, the stream session lifecycle, and the delta-polling loop. JVM 17+ — usable from Android API 24+, Spring Boot, Ktor, or any Kotlin/JVM project.

io.hotmic:sdk is the headless Kotlin client for the HotMic public API. It targets JVM 17 — usable from Android (API 24+ with desugaring), Spring Boot, Ktor, or any Kotlin/JVM project. It wraps everything a viewer client needs — auth, stream listing, joining a stream, the 3-second delta-poll loop, presence, and all REST send actions for chat, reactions, and polls.

It does not ship a UI or video player. You get stream.hlsUrl and feed it to ExoPlayer / Media3 yourself. For HotMic's full player UI as a drop-in (player Fragment + chat sheet + tips sheet), use the Android Player SDK instead.

What's wrapped

  • Auth and token management (anonymous + email/password)
  • Stream listing (listStreams) and metadata (getStream)
  • Communities (getCommunity)
  • A StreamSession that runs the 3-second delta-poll loop and emits events for chat, reactions, polls, presence, and stream state changes via assignable lambdas
  • Send actions: send chat, react, moderate, answer polls, peer-block, create battle question

What's not wrapped

  • A video player (you get stream.hlsUrl — feed it to ExoPlayer / Media3)
  • UI of any kind (no Activities, Fragments, or Composables)
  • Google Play Billing tips, paid join, OpenTok A/V guest joining
  • Battle-question voting (voteForContestant, upvote/downvote/remove vote) — the Swift SDK wraps these; the Kotlin port hasn't landed yet
  • In-app currency / token packages — Swift-only today
  • Push notifications, password reset / token refresh

Install

kotlin
// app/build.gradle.kts
dependencies {
    implementation("io.hotmic:sdk:0.1.0")
}

Requires:

  • JVM 17+ at compile and runtime (or Android with coreLibraryDesugaring)
  • Kotlin 2.0+
  • Coroutines 1.8+

The SDK pulls in okhttp:4.12.0, kotlinx-serialization-json:1.7.3, and kotlinx-coroutines-core:1.8.1 as api dependencies, so you don't need to add them yourself.

Quickstart — anonymous viewer

kotlin
import io.hotmic.sdk.HotMicClient
import io.hotmic.sdk.HotMicClientConfig

val client = HotMicClient(HotMicClientConfig(
    apiKey = "your-api-key",
    baseUrl = "https://api.hotmic.io",
    deviceId = stableDeviceId(context),
))

val session = client.joinStream(streamId)

session.onStreamUpdated = { stream ->
    println("title: ${stream.title}, viewers: ${stream.viewers}")
    // stream.hlsUrl is what you feed to ExoPlayer
}

session.onChat = { chat ->
    println("${chat.userName}: ${chat.message}")
}

session.sendChat("hello world")

// When the user leaves:
session.end()

The session creates an anonymous user on the server, gets a JWT back, and uses it for every subsequent call — including sendChat. No login required to chat, react, or vote in polls.

joinStream, sendChat, end, and all other I/O are suspend functions — call them from a coroutine scope (on Android: lifecycleScope / viewModelScope).

Configuration

kotlin
HotMicClient(HotMicClientConfig(
    apiKey: String,                  // required — provisioned by HotMic
    baseUrl: String,                 // required — typically "https://api.hotmic.io"
    deviceId: String,                // required — stable per install (UUID)
    platform: String = "android",    // populates hm-platform header
    sdkVersion: String = "0.1.0",
    osVersion: String? = null,       // populates hm-os-version
    deviceModel: String? = null,     // populates hm-device-model
    accessToken: String? = null,     // pre-existing JWT (logged-in flow)
))

Persist deviceId across launches — the server uses it for moderation / rate-limiting heuristics. On Android:

kotlin
fun stableDeviceId(context: Context): String {
    val prefs = context.getSharedPreferences("hotmic", Context.MODE_PRIVATE)
    return prefs.getString("deviceId", null) ?: java.util.UUID.randomUUID().toString().also {
        prefs.edit().putString("deviceId", it).apply()
    }
}

For higher persistence (survives app reinstall) use the Android Keystore or BackupAgent.

Authentication

Anonymous (default)

Don't pass accessToken. The first client.joinStream(...) mints a JWT for an anonymous user; the SDK stores it and uses it for every other call.

Authenticated

kotlin
val auth = client.signIn(email = "pat@example.com", password = "...")
// SDK now uses auth.accessToken automatically.

val auth = client.signUp(name = "Pat", email = "pat@example.com", password = "...")

Pre-seed an existing token from your own auth backend:

kotlin
val client = HotMicClient(HotMicClientConfig(
    ...,
    accessToken = existingJwt
))
// or later:
client.setAccessToken(existingJwt)

There's no SDK-level password reset or refresh in v1. On HotMicError with status == 401/403, surface to your auth layer and re-run sign-in.

Listing streams

kotlin
val live      = client.listStreams(ListStreamsQuery(state = "LIVE",      limit = 25))
val scheduled = client.listStreams(ListStreamsQuery(state = "SCHEDULED", limit = 25))
val past      = client.listStreams(ListStreamsQuery(state = "VOD",       limit = 25))

Returns List<Stream>. Most-useful fields: id, state, title, user?.displayName, viewers, hlsUrl, vodUrl.

getStream for a single stream's metadata:

kotlin
val stream = client.getStream(streamId)

Communities

client.getCommunity(communityId) fetches a community by id. Public endpoint — works with or without an access token. When the SDK has a JWT, the response includes isJoined for that user.

kotlin
val community = client.getCommunity(communityId)
// Community(id, name, topic, description, tone, thumbnailImage, emotes,
//           memberCount, streamCount, isJoined?, config: JsonObject?)

config is left as a raw JsonObject in Kotlin because the wire mixes camelCase (questionGen) with snake_case (max_words). Read manually:

kotlin
val tone = community.config?.get("questionGen")?.let { it as? kotlinx.serialization.json.JsonObject }
    ?.get("tone")?.let { it as? kotlinx.serialization.json.JsonPrimitive }?.contentOrNull

Joining a stream

client.joinStream(streamId) returns a StreamSession:

  1. Calls POST /streams/{id}/session/ (anonymous unless you've set a token).
  2. Backfills chat / tip / poll / reaction history via GET /streams/{id}/feed/. The reaction snapshot keeps the viewer's local state in sync when they leave and come back.
  3. Starts the 3-second delta-poll loop on its own coroutine scope.
  4. Starts presence coroutines (POST /presence every ~30s, GET .../viewers every ~10s).
  5. Begins firing event handlers.
kotlin
val session = client.joinStream(streamId)
val stream = session.currentStream
val user = session.currentUser

Events

Set lambdas on the session — invocations happen on the SDK's Dispatchers.Default context, so marshal to the main thread (Android: Dispatchers.Main) before touching UI:

kotlin
session.onChat = { chat ->
    lifecycleScope.launch(Dispatchers.Main) {
        chatAdapter.append(chat)
    }
}

session.onStreamUpdated = { stream ->
    lifecycleScope.launch(Dispatchers.Main) { viewModel.update(stream) }
}

session.onEnded = {
    lifecycleScope.launch(Dispatchers.Main) { dismissPlayer() }
}
PropertyPayloadFired when
onChatChatA new chat appears in delta.chats
onChatDeletedChatA chat appears in delta.deletedChats
onReactionReactionA reaction appears in delta.chatsReactions
onReactionRemovedReactionA reaction appears in delta.deletedReactions
onTipTipA tip appears in delta.tips
onTipDeletedTipA tip appears in delta.deletedTips
onStreamUpdatedStreamEvery poll cycle
onViewersList<Participant>Each presence refresh tick
onEnded()Server sets stream.endVideo = true (fires once)
onDeleted()Server sets stream.deleted = true (fires once)
onErrorThrowableAny background HTTP error from the loops

Read current state synchronously:

kotlin
val snapshot: Stream? = session.currentStream
val chats: List<Chat> = session.chats           // sorted ascending by createdAt
val reactions: List<Reaction> = session.reactions
val me: User? = session.currentUser

Send actions

All suspend, throw HotMicError on failure:

kotlin
session.sendChat("hello world")
session.react(chatId = chatId, type = ReactionType.FIRE)
session.removeReaction(chatId = chatId, type = ReactionType.FIRE)
session.deleteChat(chatId)            // sender, mod, or broadcaster
session.reportChat(chatId)
session.blockUserInChat(userId)       // mod/broadcaster only
session.submitPollAnswer(pollId, optionId)

// Audience Q&A — create a new question on this session's stream.
// Returns the persisted Question with a server-assigned `id`.
val q = session.createQuestion("Who wins this round?")

Reaction types: LIKE, FIRE, LAUGH, ANGER, SADNESS.

Lifecycle

kotlin
session.end()  // POSTs /end_session/ with watched-ms, cancels timers

After end(), the session is dead — handlers stop firing, send actions throw. Tie this to your screen's lifecycle:

kotlin
override fun onDestroy() {
    super.onDestroy()
    lifecycleScope.launch { session?.end() }
}

Video playback (ExoPlayer / Media3 example)

The SDK gives you stream.hlsUrl; bring your own player. Below is a sketch using AndroidX Media3:

kotlin
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.datasource.DefaultHttpDataSource

class PlayerHolder(context: Context) {
    val player: ExoPlayer = ExoPlayer.Builder(context).build()
    private var currentUrl: String? = null

    fun apply(stream: Stream) {
        val url = stream.hlsUrl ?: stream.vodUrl ?: return
        if (url == currentUrl) return
        val source = HlsMediaSource.Factory(DefaultHttpDataSource.Factory())
            .createMediaSource(MediaItem.fromUri(url))
        player.setMediaSource(source)
        player.prepare()
        player.playWhenReady = true
        currentUrl = url
    }

    fun release() {
        player.release()
        currentUrl = null
    }
}

// Wiring:
session.onStreamUpdated = { stream ->
    lifecycleScope.launch(Dispatchers.Main) {
        playerHolder.apply(stream)
    }
}
session.onEnded = {
    lifecycleScope.launch(Dispatchers.Main) {
        playerHolder.player.pause()
    }
}

hlsUrl can change mid-session (state transitions like SCHEDULED → LIVE, or LIVE → VOD). Compare against the player's current URL on every onStreamUpdated and reload when different — the example above does this.

stream.endVideo == true or stream.deleted == true are surfaced via the onEnded / onDeleted events.

Error handling

All HTTP failures throw HotMicError:

kotlin
try {
    session.sendChat("hi")
} catch (err: HotMicError) {
    when (err.status) {
        401, 403 -> { /* re-auth */ }
        429 -> { /* rate-limited */ }
        else -> Log.w("hotmic", "${err.status} ${err.message}")
    }
}

Background errors (poll loop / presence coroutines) surface via the onError lambda:

kotlin
session.onError = { err ->
    Log.w("hotmic-bg", err)
}

Polling rule

GET /streams/{id}/poll-data is called every 3 seconds with the to query param floored to the nearest whole second ((System.currentTimeMillis() / 1000) * 1000). The server's delta cache is keyed on second boundaries, so all viewers hitting the same second share one cache lookup. Enforced inside StreamPollManager and not configurable — a hard contract shared across all HotMic SDKs.

Concurrency model

  • HotMicClient is thread-safe — share it across coroutines freely. HTTP token state is held in @Volatile fields.
  • StreamSession runs its poll loop and presence timers on a private CoroutineScope(SupervisorJob() + Dispatchers.Default). The scope is cancelled when you call session.end().
  • Event lambdas are invoked from Dispatchers.Default. Marshal to Dispatchers.Main before touching Android UI.
  • All public suspend functions are cancellable — if your lifecycleScope is cancelled mid-sendChat, the request is cancelled cleanly.
  • HotMicError is a RuntimeException so it propagates through coroutine exception handlers normally.

Feature coverage

FeatureAPI
Sign in / sign upclient.signIn, client.signUp
List streamsclient.listStreams
Get stream metadataclient.getStream
Get communityclient.getCommunity
Create viewer sessionclient.joinStream
End sessionsession.end
Live chat / reactions / pollssession.onChat = { ... } etc.
Send chatsession.sendChat
Reactsession.react, session.removeReaction
Delete / report chatsession.deleteChat, session.reportChat
Block user in chatsession.blockUserInChat
Submit poll answersession.submitPollAnswer
Create battle questionsession.createQuestion
Presence (heartbeat + viewer list)Automatic via joinStream
Anonymous viewingAutomatic — no accessToken needed