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() }
}

| Property | Payload | Fired when | |---|---|---| | onChat | Chat | A new chat appears in delta.chats | | onChatDeleted | Chat | A chat appears in delta.deletedChats | | onReaction | Reaction | A reaction appears in delta.chatsReactions | | onReactionRemoved | Reaction | A reaction appears in delta.deletedReactions | | onTip | Tip | A tip appears in delta.tips | | onTipDeleted | Tip | A tip appears in delta.deletedTips | | onStreamUpdated | Stream | Every poll cycle | | onViewers | List<Participant> | Each presence refresh tick | | onEnded | () | Server sets stream.endVideo = true (fires once) | | onDeleted | () | Server sets stream.deleted = true (fires once) | | onError | Throwable | Any 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

| Feature | API | |---|---| | Sign in / sign up | client.signIn, client.signUp | | List streams | client.listStreams | | Get stream metadata | client.getStream | | Get community | client.getCommunity | | Create viewer session | client.joinStream | | End session | session.end | | Live chat / reactions / polls | session.onChat = { ... } etc. | | Send chat | session.sendChat | | React | session.react, session.removeReaction | | Delete / report chat | session.deleteChat, session.reportChat | | Block user in chat | session.blockUserInChat | | Submit poll answer | session.submitPollAnswer | | Create battle question | session.createQuestion | | Presence (heartbeat + viewer list) | Automatic via joinStream | | Anonymous viewing | Automatic — no accessToken needed |