Irbisa · Cheatsheet
AI Features in Mobile Apps
On-device vs server inference, token streaming UI, structured output, latency, cost, privacy
How do you choose between an on-device model (Gemini Nano, LiteRT) and a server model for a feature like "Summarise this thread"?
interface ThreadSummarizer { suspend fun summarize(text: String): String } class OnDevice(context: Context) : ThreadSummarizer { private val client = Summarization.getClient( SummarizerOptions.builder(context) .setInputType(InputType.ARTICLE)…This chat sends the whole history on every turn. What happens to cost and latency by turn 50, and how do you fix it?
class ChatViewModel(private val api: ChatApi) : ViewModel() { private val history = mutableListOf<Message>() fun send(text: String) = viewModelScope.launch { history += Message("user", text) val reply = api.complete(history) // the whole conversation, every turn history += Message("assistant", reply) }}This adapter turns an SSE token stream into a
Flow. What is wrong with it?fun streamAnswer(client: OkHttpClient, request: Request): Flow<String> = callbackFlow { val listener = object : EventSourceListener() { override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { trySend(data) } override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { close(t) } } EventSources.createFactory(client).newEventSource(request, listener)}Find the bug: English answers stream fine, but Russian ones sometimes show
�in the middle of a word.// The endpoint streams the answer as plain text over chunked HTTP.fun streamAnswer(body: ResponseBody): Flow<String> = flow { val buf = ByteArray(1024) body.byteStream().use { input -> while (true) { val n = input.read(buf) if (n == -1) break emit(String(buf, 0, n, Charsets.UTF_8)) } }}.flowOn(Dispatchers.IO)Your streaming answer stutters as tokens land, and the list keeps jumping while the text grows. What do you change?
// Deltas arrive far faster than a frame. Coalesce once, at the boundary.val answer: Flow<String> = flow { val buf = StringBuilder() api.streamAnswer(prompt).collect { delta -> buf.append(delta); emit(buf.toString()) }} .flowOn(Dispatchers.Default) // string building off the main thread…The user hits back halfway through a generation. What actually has to happen on the client, and what does the server still need from you?
class AnswerViewModel(private val repo: AnswerRepo) : ViewModel() { private var job: Job? = null fun ask(turnId: String, prompt: String) { job?.cancel() // a new question kills the old stream, and its cost job = viewModelScope.launch { // dies with the screen, not with the process…What time to first token should a streaming AI screen aim for, and which ways of hiding the wait are honest?
// TTFT is a different metric from total duration. Time the first chunk, not the last.suspend fun ask(prompt: String, sink: (String) -> Unit) { val started = SystemClock.elapsedRealtime() var firstAt: Long? = null var tokens = 0 try {…How do you run your own LiteRT (TensorFlow Lite) model on Android, and what does a GPU delegate change?
class Classifier(context: Context) : Closeable { private val compat = CompatibilityList() private val gpu: GpuDelegate? = if (compat.isDelegateSupportedOnThisDevice) GpuDelegate(compat.bestOptionsForThisDevice) else null private val interpreter = Interpreter(…What do Google Play and the App Store require from an app with a generative AI feature?
How does function calling (tool use) work in a mobile assistant, and which tools should run on the device?
@Serializable data class CreateEvent(val title: String, val startEpochMs: Long, val minutes: Int) sealed interface ToolOutcome { data class Result(val json: String) : ToolOutcome data class NeedsConfirmation(val prompt: String, val run: suspend () -> String) : ToolOutcome}…Why must LLM calls go through your backend instead of the app holding the vendor key, and what must that proxy own beyond hiding the key?
// WRONG: the client builds the prompt and carries the vendor key.// Anyone with the APK has the key; anyone with the key has your bill.val body = ChatBody( model = "vendor-large", messages = listOf(Message("system", SYSTEM_PROMPT), Message("user", userText)),)…Your assistant summarises emails and can call tools. How do you defend it against prompt injection?
You ask the model for JSON and want fields to appear as they arrive. What do you do with a half-written object, and what does an unfinished field look like on screen?
@Serializable data class Recipe(val title: String, val minutes: Int, val steps: List<String>) // The streaming mirror: every field nullable, because every field may not exist yet.@Serializable data class RecipeDraft( val title: String? = null, val minutes: Int? = null, val steps: List<String>? = null,)…Find the bug: a user switches apps mid-summary, comes back, and the summary starts over on a blank screen; the bill shows two generations.
class SummaryViewModel(api: AssistantApi, threadId: String) : ViewModel() { val summary: StateFlow<String> = api.stream(SummarizeRequest(threadId)) .runningFold("") { text, delta -> text + delta } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")} @Composablefun SummaryScreen(vm: SummaryViewModel) { val summary by vm.summary.collectAsStateWithLifecycle() MarkdownText(summary)}The app is killed mid-answer and the user comes back an hour later. What do you restore, what do you re-send, and what must never go twice?
@Entitydata class TurnEntity( @PrimaryKey val id: String, // minted on the client, reused by every retry val conversationId: String, val role: String, // "user" | "assistant" val text: String,…The on-device model weighs 1.2 GB and your app download cannot. How do those bytes get onto the phone, and when do you delete them?
// Play Asset Delivery: the model is an on-demand asset pack, not part of the APK.class ModelStore(private val packs: AssetPackManager) { fun localPath(): String? = packs.getPackLocation(PACK)?.assetsPath() // null = not here yet fun ensure(activity: Activity, onReady: (String) -> Unit) { localPath()?.let { return onReady(it) }…The model answers differently on every run. How do you test an AI feature before release and know it still works after?