Mobile · On-device AI · For beginners

Building a private AI chat app for Android: local models, online models, and shipping to Google Play

Khairul Alam·October 2026·~25 min read
AndroidKotlinLLMllama.cppGoogle Play

You type a message, a few seconds later an AI answers. It feels like magic, but it is a handful of simple ideas stacked together. I built an Android chat app that can answer from a model running on the phone itself, switch to an online model when you want a bigger brain, and quietly fall back to the phone when the internet disappears. This article explains every idea in plain words, with pictures, so you can follow it even if you have never touched AI before.

The whole post in 60 seconds
  • An AI chat model is a very fancy autocomplete: it keeps guessing the next small piece of text.
  • It does not remember anything between messages. The app re-sends the whole conversation every time.
  • A model is just a big file of numbers. A small, shrunk-down one fits on a phone and runs with no internet.
  • An online model does the same job on someone else's computer. The phone only sends text and shows the answer.
  • Hide both behind one “give me text” interface, and you can switch or fall back freely.
  • Most of what makes it feel good is handling the waiting: instant echo, a “thinking” dot, streaming text, a Stop button.
  • Publishing needs a signed app bundle, a privacy policy and honest disclosures. A big bundled model needs special handling.

1. The big picture

From the outside, the app is just a chat screen. Behind it there is a “brain” that turns your message into a reply. The trick is that the brain can come from three different places, and the screen does not need to know which one is in use.

Hello! Hi, how canI help? Ask anything Chat screen A model on the phonePrivate, works offline, warms the phone An online modelSmarter, cool phone, needs internet A simple rule-based helperInstant, no AI, knows only a few words
One chat screen, three possible brains. The app picks one for each message.

2. How an AI chat actually works

A large language model (LLM) is a program that has read an enormous amount of text and learned its patterns. Give it some text and it predicts what should come next. A chat is built on top of that one ability.

Think of it like phone autocomplete

When you type “see you”, your keyboard suggests “later”. An LLM is the same idea, trained on far more text and able to keep going for whole paragraphs. It writes an answer one small piece at a time, each piece chosen to fit everything before it.

Tokens: the pieces it reads and writes

Models do not read letters or whole words. They read tokens, which are short chunks of text. Roughly, one token is three-quarters of an English word. Speed, memory limits and price are all counted in tokens.

The sentenceHello, how are you? Hel lo , how are you ? = 7 tokens
The model never sees “words”, only these chunks. Even a short sentence is several tokens.

It has no memory, so the app re-sends the chat

This is the idea that surprises most people. A model does not remember your previous message. Each time you press send, the app packs the whole conversation so far into one big block of text and hands it over. The model reads it all and continues it.

The model can only look at a limited amount of text at once, called the context window. It is measured in tokens (for example 4,096 on a phone). When the conversation grows past the window, the oldest messages have to be dropped, and the model effectively forgets them.

Conversation, oldest to newest msg 1 msg 2 msg 3 msg 4 msg 5 msg 6 your new one The context window: what the model can read right now Too old for the window: forgotten (the app drops the oldest messages first)
The window is like a whiteboard of fixed size. New text goes on the right; once it is full, the oldest text is wiped from the left.

The chat template: labels the model understands

A raw model only continues text, so the app marks who is speaking using special labels the model was trained on. Different model families use different labels, and using the wrong ones is the number one reason a small model produces nonsense.

system“You are a helpful assistant.” (how to behave) user“What is a token?” (what you typed) assistantthe model writes here, one token at a time ▌ The model stops when it writes its own “end of turn” marker. Then the reply is finished.
A chat prompt is three labelled parts. The last one is left open for the model to fill in.

The first part, the system prompt, is a standing instruction such as “be concise” or “you are a code reviewer”. It is just text at the top, which makes it a handy place to slip in anything else the model should know. We will use that for memory.

The dials: why answers differ each time

At every step the model rates every possible next token, and the app has to pick one. A few dials control that pick:

Streaming

Because tokens appear one by one, the app shows them as they arrive instead of waiting for the whole answer. That is why replies seem to be “typed” in front of you, both for on-device and online models.

3. How the app is organised

The most useful design decision is to hide where the answer comes from behind one small interface. The screens ask for text and get a stream of text back; they never need to know whether it came from the phone, a server, or a rule list.

Screens (Jetpack Compose) ViewModelbuilds the prompt: instructions + memory + chat Databasechats, memories,models, settings Model on the phonellama.cpp Online modelHTTPS, streaming Simple ruleskeywords Each one gives back the same thing: a stream of text pieces.
The ViewModel builds the prompt and picks one of three interchangeable text sources. Swapping or falling back becomes easy.

In Kotlin that boundary can be tiny:

interface LlmEngine {
    suspend fun load(modelPath: String, config: EngineConfig)
    fun generate(prompt: String, params: GenerationParams): Flow<String>
    suspend fun unload()
}

A Flow<String> is Kotlin's way of saying “a stream of text pieces that arrive over time”. The screen collects them and shows each one as it comes. Cancel the collector and generation stops, which gives you a working Stop button for free. Chats and messages live in Room (a friendly layer over SQLite), so history survives restarts.

4. Adding a model that runs on the phone

What is a model file?

A model is a big collection of numbers, called weights, learned during training. Think of it as a very large recipe card of what the AI “knows”. To fit on a phone you need two tricks:

Typical 4-bit file size ~0.3B270 MB · very fast, simple chat ~0.5B490 MB · fast, basic Q&A ~1B800 MB · good chat, ~3 GB RAM ~1.5B1.1 GB · smarter, slower, warmer
Bigger usually means smarter, but also bigger downloads, more memory, slower replies and more heat.

A handy rule of thumb: a 4-bit model needs about 0.6 GB of storage per billion parameters, and about that much free RAM, plus extra for the conversation.

The GGUF file format

The common format for phone-sized models is GGUF. It is a single file that holds the weights, the tokenizer (the thing that splits text into tokens) and some notes about the model. Tools create it; nobody writes one by hand (more in section 10).

Running it: llama.cpp, a bridge, and Kotlin

llama.cpp is an open-source engine written in C++ that can run GGUF models on an ordinary phone processor. Android apps are written in Kotlin, so we need a bridge between the two languages.

Model file.gguf(the numbers) Enginellama.cpp(C++) BridgeJNI(translator) KotlinFlow oftext pieces Screenstreamedreply Built once with the Android NDK; shipped inside the app as a .so library
The model file is only data. The C++ engine does the maths. A thin bridge lets Kotlin ask for one piece of text at a time.
  1. Compile the engine with the Android NDK. Add llama.cpp as a git submodule and a CMakeLists.txt that builds it into a shared library for each processor type (arm64-v8a for phones, x86_64 for the emulator).
  2. Write a thin bridge with JNI (Java Native Interface). Keep it tiny: load, start a reply, get next piece, stop, free.
  3. Wrap it in the same LlmEngine interface, turning “get next piece” into a Flow.
object LlamaBridge {
    init { System.loadLibrary("native_llm") }
    external fun nativeLoad(path: String, contextSize: Int, threads: Int): Long
    external fun nativeStart(handle: Long, prompt: String /* + sampling args */): Int
    external fun nativeNext(handle: Long): ByteArray?   // null when finished
    external fun nativeAbort(handle: Long)
    external fun nativeFree(handle: Long)
}

Two practical rules: run all native calls on one dedicated thread, so freeing the model can never collide with a running reply; and treat native crashes as serious, because there is no Kotlin error message to catch (see section 11).

An alternative is Google's MediaPipe / LiteRT LLM runtime, which uses its own .task files and avoids writing C++. Both can sit behind the same interface.

Getting the model onto the phone

Loading a model reads hundreds of megabytes into memory and takes seconds. Load it lazily on the first message, keep it while the app is open, free it when the app closes, and always show “Loading…” so the first reply does not look frozen.

5. Why the first word takes time (and how to speed it up)

When you press send, two different jobs happen on the phone:

  1. Reading the whole prompt (instructions + chat + your message). The model has to process every token once. Developers call this prefill.
  2. Writing the answer, one token at a time.

Nothing appears on screen until the reading is done. On a fast phone that takes a blink. On an older phone, with a long prompt, it can take most of a minute. On a test phone from 2018 I measured about 16 tokens per second of reading: a 900-token prompt meant a 55-second wait before the first word, even though the phone was working the whole time.

Fix 1: don't re-read what hasn't changed (the KV cache)

While reading, the model builds notes about the text it has seen, called the KV cache. Next time you send a message, the start of the prompt (instructions and the earlier chat) is identical, so those notes are still valid. The engine keeps them and only reads the new part.

Without the cache: read everything again read 600 tokens With the cache: reuse the unchanged start reused from last time (580 tokens) ↑ only the 20 new tokens (green) need reading The more of the chat stays the same, the faster each follow-up gets.
The first message in a chat pays the full price. Follow-ups are much cheaper.

In the bridge this is a small change: remember which tokens are in the cache, find how many match the new prompt from the start, delete the rest, and decode only the difference. If nothing matches (for example old messages were dropped), it falls back to reading everything.

Fix 2: send less

Fix 3: pick a file the phone is fast at

Quantized models come in several flavours. Newer phones have special instructions that make the popular “Q4_K_M” type quick; older phones do not, and a plainer type called Q4_0 reads prompts much faster there. Offering both in the download list lets people on older phones have a better time.

6. Adding an online model

An online model does the heavy computing on a server. Many providers, and many self-hosted gateways, speak the same language, the OpenAI-compatible chat completions API. One small client therefore covers a lot of services.

Phonechat app Serveronline model 1. sends the conversation (JSON) + your API key 2. streams back small pieces as they are written "Hel" "lo!" [DONE] If the connection drops mid-way, the app keeps what already arrived.
The request is one message out; the answer comes back as a stream of small pieces.
POST {baseUrl}/chat/completions
Authorization: Bearer <api key>

{ "model": "some-model-name", "stream": true,
  "messages": [ {"role": "system", "content": "..."},
                {"role": "user",   "content": "Hello"} ] }

The reply arrives as Server-Sent Events: lines starting with data:, each holding a tiny JSON chunk, ending with data: [DONE].

data: {"choices":[{"delta":{"content":"Hel"}}]}
data: {"choices":[{"delta":{"content":"lo!"}}]}
data: [DONE]

Read each line, pull out choices[0].delta.content, and emit it into the same Flow<String> the local engine uses. To the screen, nothing changed.

Details that matter

7. Offline vs online, heat, and falling back

Neither mode is better. They trade different things.

Offline (on the phone)Online (remote model)
PrivacyNothing leaves the deviceMessages go to the provider
InternetNot neededRequired
Answer qualitySmall models, limitedLarge models, much stronger
Heat & batteryCPU works hard: warm phone, faster drainLight: mostly network traffic
CostFree after the downloadPer-use fees or a subscription

Why the phone gets warm

Writing each token means doing a huge pile of arithmetic on the processor, using several cores at once, a bit like running a demanding game. It happens in bursts: the phone warms while it writes and cools when idle. If it gets too hot, Android slows the processor down on its own, which makes replies slower but does not damage the phone. Smaller models, fewer threads and shorter replies reduce the heat; Online mode avoids it, because the work happens elsewhere.

The automatic fallback

If the user chose Online but the connection is gone, the app should not just fail. It should quietly use the phone instead.

You send a message Online modeturned on? no yes Internet reallyreachable? yes Ask the online modelstream the reply no Use the phone's modeltell the user why fails before any text arrives
Online is tried first when it can work. Any early failure falls back to the phone; a failure part-way keeps the text already received.

One detail: check that the network can really reach the internet. Android reports a VALIDATED capability for that; being “connected to Wi-Fi” is not enough.

8. Remembering across chats

Since the model has no memory, “remembering” is something the app does by adding text to the prompt. Two simple mechanisms:

1. Base instructions"be helpful, be concise" + 2. Saved facts"vegetarian, dog = Rex" + 3. Recap of other chatsonly if you ask + 4. This chat so far(newest kept) + 5. Your new message The modelreads it all,then replies
“Memory” is just extra text placed in front of the question. The dashed box is optional.

Two cautions. First, keep this extra text small, or it eats the context window of a small model and slows it (section 5). Second, give users control: toggles to switch each part off, a screen to view and delete facts, and an honest note about what an online model would receive. Reopening the last conversation on launch completes the feeling that the app “remembers”.

9. Making it feel like a real chat app

Much of what people like in popular chat apps is not the model. It is how the waiting is handled. An app that goes silent for 30 seconds feels broken, even if it is working hard.

1. Sent your messageappears instantly 2. Thinking pulsing dot +"Still thinking… 8s" 3. Streaming The capital ofFrance is Par ●Send becomes Stop 4. Done CopyContinue (if cut off)speed shown Each stage tells the user something is happening.
The four stages of one reply. Every stage gives visible feedback.

10. Making a very simple model

People often ask: can I make my own model? There are two very different answers.

Option A: a rule-based “model” (an afternoon)

If the app only needs a small vocabulary, you do not need a neural network. A list of keywords and canned replies is instant, uses almost no battery, needs no file and behaves the same way every time. It is not AI, but behind the interface from section 3 the screen cannot tell the difference.

"hello there"your message words{hello, there} Rule list hi, hello → greetingtime → clockthanks → "welcome!"(none) → "don't know" "Hello!"canned reply
No learning involved: just match words and answer from a list.
private val rules = listOf(
    Rule(setOf("hi", "hello", "hey")) { "Hello! How can I help?" },
    Rule(setOf("time", "clock"))      { "It's ${now.format("h:mm a")}." },
    Rule(setOf("thanks", "thank"))    { "You're welcome!" },
)

fun reply(input: String): String {
    val words = Regex("[\\p{L}\\p{N}']+").findAll(input.lowercase()).map { it.value }.toSet()
    return rules.firstOrNull { r -> r.keywords.any { it in words } }?.reply?.invoke(ctx)
        ?: "I don't know that one yet."
}

It also makes a graceful last resort: a fresh install with no model downloaded can still answer, and can point the user to a real model. Show its text word by word with a short delay so it feels like typing.

Option B: train a real tiny model (a project)

A GGUF file holds learned numbers, not words or rules, which is why it cannot be written by hand. Making one has three stages:

Your dataQ&A pairs Traintiny network Convertto .gguf Shrinkquantize Q4 Load in appsame as others A tiny model mostly memorises its examples: great on those, nonsense on anything else.
Making a real model file is a pipeline, and the training stage is the hard one.
python convert_hf_to_gguf.py ./my-tiny-model --outfile tiny-f16.gguf --outtype f16
llama-quantize tiny-f16.gguf tiny-q4_k_m.gguf Q4_K_M

The result loads through exactly the same code as any downloaded model, so it is a good way to test your loading path. For real quality it is far easier to start from an existing small open model and, if needed, fine-tune it.

11. A pitfall: the crash that only some phones had

The first test build died instantly on one phone with SIGILL, short for “illegal instruction”, deep inside the native library.

The workshop analogy

Imagine writing a recipe that says “use the electric mixer”. It works in kitchens that have one. In a kitchen without one, the cook simply stops. The compiler had been told to assume a newer processor feature (called dotprod) existed. On a phone whose processor lacked it, the first time the program used that instruction, the system killed the app.

To make it worse, the engine chose its fast routines based on how it was compiled, not on what the phone could actually do. The fix was to compile for plain armv8-a, which every 64-bit ARM phone understands. The price is some speed on newer phones; the proper solution is to build several variants and choose one at runtime. The general lesson: native crashes are not ordinary errors, so test on a real, ideally older, phone, and read logcat for the signal name and the top of the stack trace.

12. Publishing on Google Play

Buildsigned .aab Listing+ policies Internaltesting Closedtesting Productionreview Watch the size:The base app is limited to about 200 MB.A bigger bundled model needs an install-time asset pack, or a download after install.
The path to the store, and the one size trap that matters for AI apps.

Account and format

Native code specifics

The size problem

Listing and policy

13. Limitations

14. Takeaways and glossary

  1. An AI chat is “re-send the conversation, stream back the continuation”. Learn tokens, the context window and the chat template and the rest follows.
  2. Put every source of text behind one interface. Local, online and rule-based answers then become interchangeable, and fallbacks are easy.
  3. For local models, use a small quantized GGUF through llama.cpp and the NDK, with a very thin bridge and one native thread.
  4. Speed on the phone is mostly about reading the prompt: reuse the cache, send less, and pick a file type the phone is fast at.
  5. For online models, an OpenAI-compatible streaming client covers many providers. Treat API keys and user text with care.
  6. The experience is mostly about the waiting: instant echo, a thinking dot, streaming, a working Stop button and a Continue button.
  7. Plan distribution early: the model's size, native-library requirements and privacy disclosures all shape how you publish.
TermIn plain words
LLMA program that predicts the next piece of text, trained on huge amounts of writing.
TokenA short chunk of text (about three-quarters of a word) that models read and write.
Context windowHow much text the model can look at at once. Older text falls off.
Chat templateLabels like “system / user / assistant” that tell the model who said what.
System promptA standing instruction at the top of the prompt.
WeightsThe numbers a model learned; the contents of a model file.
QuantizationStoring those numbers with fewer bits to make the file smaller.
GGUFA file format for phone-sized models.
llama.cppAn open-source engine that runs GGUF models on ordinary processors.
NDK / JNIAndroid's tools for running C++ code from Kotlin; JNI is the translator between them.
PrefillThe model reading the whole prompt before it writes the first word.
KV cacheThe model's notes about text it already read, reused to skip re-reading.
Streaming / SSESending a reply in small pieces as it is written.
AABThe app bundle format Google Play requires.
← Back to all writing