Most AI chat products are companion-first: you open an app to talk to characters, and the rest of “social” is an afterthought — if it exists at all. Most social apps are the opposite: rich feeds and human DMs, with AI bolted on later as a novelty bot.
We wanted a third path.
Public is a social network where the feed, short video (Pulse), pages, and communities sit next to people chat and AI character chat — in the same Android app, behind the same realtime layer. Humans and characters both show up in the inbox. That product choice drove almost every architecture decision on native Android.
This post is a builder’s walkthrough of how we approached that on Android: Kotlin, Jetpack Compose, MVVM, and Socket.IO — and why AI replies do not stream token-by-token on the client.
Disclosure: We build Public (public.kim). The Android app is on Google Play as
in.publicapp.android. Product help lives on docs.public.kim.
What we shipped (product context)
On Android, the bottom bar is the product skeleton:
- Home — following / public feed, posts, notifications
- Characters — discover, create, favorite AI characters
- Pulse — short portrait video
- Chats — human DMs, groups, and character threads in one place
- Menu — pages, people, Public AI, vault, todos, settings, and the rest of the hub
Around that we added Google / email auth, App Links into public.kim URLs, FCM, personas (who you are when chatting with a character), optional inference settings on some plans, and on-device voice (STT + TTS) around the same character chat protocol.
The interesting engineering problem is not “draw another chat UI.” It is one transport, two chat protocols, one product feel.
Stack at a glance
| Layer | Choice |
|---|---|
| Language | Kotlin |
| UI | Jetpack Compose + Material 3 |
| Architecture | MVVM (@HiltViewModel + StateFlow) |
| DI | Hilt |
| REST | Retrofit + OkHttp (disk cache for GETs) |
| Realtime | Socket.IO (websocket + polling), auth token on connect |
| Local cache | Room (public_cache.db) — posts, conversations, messages, JSON blobs |
| Images / video | Coil, Media3 ExoPlayer |
| Auth / push | Firebase Auth, Google Sign-In / Credential Manager, FCM |
| Navigation | Navigation Compose — large NavHost from MainActivity
|
We kept a single Gradle module (:app). Packages split cleanly into data/, ui/, di/, plus cross-cutting bits for notifications, deep links, plans, and XP. For an app this size, one module keeps navigation and DI simple; we accept a larger :app in exchange for fewer wiring ceremony bugs.
Architecture: shared transport, divergent product paths
flowchart LR
App[Android_Compose_App]
REST[REST_API]
Sock[Socket_IO]
App --> REST
App --> Sock
Sock --> People[People_Chat]
Sock --> AI[Character_Chat]
- REST owns CRUD: profiles, feed pages, character definitions, conversation history bootstrap, media presigns, plans.
- Socket.IO owns “something just happened”: human messages, unread, presence, character replies, image-generation start events, notifications, XP.
ViewModels stay thin coordinators. Repositories and socket sessions own I/O. Compose screens collect UI state. Classic Android MVVM — boring on purpose, because chat is where things get interesting.
Dual chat design
People chat
Human threads live behind a shared ChatSocketSession that:
- Connects with the user token
- Joins a user room (
joinUserRoom) for unreads / presence - Joins / leaves conversation rooms when you open a thread
- Fans
receiveUserMessageinto aSharedFlowso the open chat screen, chat list, and unread badge all see the same event
Sending is emit-then-optimistically-update: upload media via POST upload/media when needed, then emit("sendUserMessage", …).
That pattern is familiar if you have built any realtime messenger.
AI character chat
Character chat reuses the same SocketManager, but a different event pair:
- Client → server:
sendMessageToCharacterSocketwithmessage,characterId, optionalconversationId, optionalpersonaId - Server → client:
receiveFullMessageFromCharacterSocketwith a fullCharacterMessage
While we wait, the UI shows a typing / waiting state. The user’s message is added optimistically with a temporary id. When the full bot message arrives, we clear waiting, append the reply, and sync conversation metadata if this was a new thread.
A simplified shape of the client send path:
fun sendMessage(text: String) {
val json = JSONObject().apply {
put("message", text)
put("characterId", characterId)
put("conversationId", conversationId) // may be null on first message
activePersonaId?.let { put("personaId", it) }
}
SocketManager.emit("sendMessageToCharacterSocket", json)
isWaitingForAiResponse = true
addOptimisticUserMessage(text)
}
And the receive side is intentionally not a token stream:
SocketManager.on("receiveFullMessageFromCharacterSocket") { data ->
isWaitingForAiResponse = false
val chatMessage = parseCharacterMessageFromSocket(data)
addMessage(chatMessage)
// bootstrap conversation id after first bot reply, reload history if needed
}
Why full messages instead of SSE / token streaming?
For v1 of the native client we prioritized:
- One mental model with people chat — events deliver complete messages
- Simpler markdown rendering — render a finished bubble once, not every half-token
-
Cleaner plan-limit / error handling — failure is a socket
error, not a mid-stream abort - Voice mode — on-device STT feeds the same emit API; TTS speaks the full reply
We may add streaming later for latency perception. Today, typing UI + full reply is the contract.
Personas and inference settings
Character chat is not only “user ↔ bot.” Users can pick a persona (who they are in the scene). That personaId rides on the socket payload so the backend can condition the prompt without the Android client assembling prompt text.
On eligible plans, we also expose inference settings (model / temperature / tokens) as a sheet over the same conversation. That stays a REST-backed preference surface; the socket path still carries the message itself. Separating “how the model runs” from “what was said” kept ViewModels readable.
One SocketManager, many consumers
The thorniest realtime bug we hit was not JSON parsing. It was listener ownership.
Several screens can initialize or replace the socket. If character chat creates a new Socket.IO instance, people-chat listeners that were attached to the previous instance go silent — unread badges freeze, notification sockets die.
We solved it with a generation counter on the process-wide SocketManager:
object SocketManager {
private var generation = 0
fun initializeSocket(url: String, token: String) {
// ... IO.Options with auth token, websocket + polling ...
socket = IO.socket(url, options)
generation++
}
fun generation(): Int = generation
}
ChatSocketSession.ensureConnected() compares attachedGeneration to SocketManager.generation(). On mismatch it tears off and reattaches receiveUserMessage, unread, group updates, and asks notification / XP coordinators to reattach too. Character features get a fresh socket when they need it; people chat stays live.
If you share one Socket.IO client across features, bake in reattach as a first-class protocol. Don’t assume listeners survive forever.
Supporting choices worth sharing
Room as cache-then-network. Conversations and messages observe Room first, then refresh from API / sockets. Schema changes use destructive migration today — acceptable while the product is moving fast; a real migration path comes when caches become user-critical.
Two upload paths. Feed posts: POST post/presign → direct PUT to object storage → POST post/add with keys. Chat attachments: multipart POST upload/media, then attach URLs on the socket send. Chat wanted fewer round-trips for small images; posts wanted large-file strength.
Compose navigation as product map. A large NavHost in MainActivity encodes auth, tabs, character profile/chat, Public AI, Pulse/Watch, vault, and deep links. The Menu tab is deliberately a hub of secondary destinations so the bottom bar stays five items humans can remember.
Voice is a shell, not a second chat stack. Android SpeechRecognizer + TextToSpeech, with barge-in while speaking, wraps the same sendMessageToCharacterSocket path. That kept web and Android protocol-aligned even when the voice UX is client-local.
i18n early. Roughly twenty locales and system-driven language keep the social surface usable outside English. UI consistency scripts and a small design-system doc keep Compose screens from drifting.
Lessons learned
- Put AI next to people in navigation — if character threads live in a separate silo, the app feels like two products glued together, no matter how shared the backend is.
- Shared Socket.IO needs a generation / reattach contract — multiple screens will reinvent the socket otherwise.
- Full-message AI replies are a valid v1 — streaming is a UX upgrade, not a requirement for shipping character chat.
- Personas belong on the wire — keep prompt assembly server-side; Android should send identity ids, not paragraph soup.
- Optimistic UI works the same for bots and humans — temporary ids, typing state, reconcile when the canonical message arrives.
- Single-module Compose apps still scale — until feature teams need hard boundaries, simpler DI beats premature multi-module charts.
What’s next (on our roadmap)
We’re iterating on latency perception for AI (streaming or chunked delivery), deeper Public AI agent actions on mobile with confirmable tool cards, and keeping the Chats tab honest as both human and character volume grows. The constraint stays the same: one social app, not a companion bolted onto a feed.
Try it
- Web / PWA: public.kim
- Android: Public on Google Play
- Help docs: docs.public.kim
If you’re building AI into a social client, we hope the dual-protocol + shared socket pattern saves you a week of “why did unread die when I opened character chat?” debugging.
Questions or war stories about Socket.IO + Compose? Drop them in the comments — we read them.

Top comments (0)