<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Esdras Souza</title>
    <description>The latest articles on DEV Community by Esdras Souza (@thalzeronlabs).</description>
    <link>https://dev.to/thalzeronlabs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4220008%2F917ea0f4-80de-4441-bff9-7e1b19a49bce.png</url>
      <title>DEV Community: Esdras Souza</title>
      <link>https://dev.to/thalzeronlabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thalzeronlabs"/>
    <language>en</language>
    <item>
      <title>Running an LLM fully offline on Android with Kotlin coroutines</title>
      <dc:creator>Esdras Souza</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:26:27 +0000</pubDate>
      <link>https://dev.to/thalzeronlabs/running-an-llm-fully-offline-on-android-with-kotlin-coroutines-2ii9</link>
      <guid>https://dev.to/thalzeronlabs/running-an-llm-fully-offline-on-android-with-kotlin-coroutines-2ii9</guid>
      <description>&lt;p&gt;A 0.5B-parameter chat model, 4-bit quantized, is about 490 MB. That fits on any recent phone, and llama.cpp runs it on the CPU with no GPU, no server and no API key. The hard part is not the inference. It's making it feel like a normal Kotlin API inside a normal Android app.&lt;/p&gt;

&lt;p&gt;This post is about the three problems that took most of the time when I built &lt;a href="https://github.com/ThalzeronLabs/llama-kotlin" rel="noopener noreferrer"&gt;llama-kotlin&lt;/a&gt;, a small Kotlin library over llama.cpp, and a fully offline &lt;a href="https://github.com/ThalzeronLabs/android-on-device-llm" rel="noopener noreferrer"&gt;chat app&lt;/a&gt; on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API I wanted
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlamaModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filesDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"qwen2.5-0.5b-instruct-q4_k_m.gguf"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newSession&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"You are a concise assistant."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Explain coroutines in one sentence."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;piece&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n${session.lastStats?.tokensPerSecond} tokens/s"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chat()&lt;/code&gt; returns a cold &lt;code&gt;Flow&amp;lt;String&amp;gt;&lt;/code&gt;. In Compose, that means the answer streams into the UI with a plain &lt;code&gt;collect&lt;/code&gt;, and the ViewModel never touches JNI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: native code is not thread-safe, coroutines jump threads
&lt;/h2&gt;

&lt;p&gt;A llama.cpp context (the KV cache plus the sampler) must not be used from two threads at the same time. Coroutines, on the other hand, can resume on any thread of a dispatcher. If two screens call the same session, or one call starts while another is still generating, you get corrupted state or a crash deep in native code.&lt;/p&gt;

&lt;p&gt;The fix is boring, and that's the point: each session owns &lt;strong&gt;one dedicated thread&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;newSessionDispatcher&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;ExecutorCoroutineDispatcher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newSingleThreadExecutor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"llama-session"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;isDaemon&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asCoroutineDispatcher&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every native call goes through &lt;code&gt;flowOn(dispatcher)&lt;/code&gt; or &lt;code&gt;withContext(dispatcher)&lt;/code&gt;, and a &lt;code&gt;Mutex&lt;/code&gt; makes a second generation wait for the first instead of interleaving with it. The caller can be on &lt;code&gt;Dispatchers.Main&lt;/code&gt;, &lt;code&gt;IO&lt;/code&gt;, anything: the session is safe to call from any coroutine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: "Stop" has to actually stop
&lt;/h2&gt;

&lt;p&gt;Users tap Stop. On a phone, a generation that keeps running in the background after Stop is burning battery and holding the CPU.&lt;/p&gt;

&lt;p&gt;Because the generation is a &lt;code&gt;flow { }&lt;/code&gt; builder, cancellation comes almost for free. The loop checks it before every token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generated&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;maxTokens&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;closing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;currentCoroutineContext&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ensureActive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="n"&gt;generated&lt;/span&gt;&lt;span class="p"&gt;++&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancel the coroutine that collects the flow and the loop throws at the next token, the &lt;code&gt;finally&lt;/code&gt; block still records the stats, and the mutex is released for the next call. In the app, Stop keeps whatever was already written on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: tokens are bytes, not characters
&lt;/h2&gt;

&lt;p&gt;This one surprised me. The first version printed &lt;code&gt;�&lt;/code&gt; in the middle of answers, mostly with emoji and accented words.&lt;/p&gt;

&lt;p&gt;A model token is a piece of UTF-8, not a character. A 4-byte emoji can be split across two tokens. If you decode each token on its own, the first half is invalid UTF-8 and becomes &lt;code&gt;�&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is a tiny streaming decoder: keep the unfinished tail of a character until the rest of its bytes arrive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;all&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;completePrefixLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// where the last complete character ends&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyOfRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Charsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTF_8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;completePrefixLength&lt;/code&gt; walks back over at most three continuation bytes (&lt;code&gt;10xxxxxx&lt;/code&gt;), reads the lead byte to know how long that character should be, and cuts before it if it's not complete yet. It's 40 lines and it's fully unit tested on the JVM, no device needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chat templates come from the model
&lt;/h2&gt;

&lt;p&gt;Every model family formats a conversation differently (Qwen, Llama, Gemma, Phi each have their own special tokens). GGUF files carry their chat template inside, so &lt;code&gt;chat()&lt;/code&gt; formats the messages with the template of the model you loaded. You keep a plain &lt;code&gt;List&amp;lt;ChatMessage&amp;gt;&lt;/code&gt;, append the user's turn, call &lt;code&gt;chat()&lt;/code&gt;, append the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without a phone
&lt;/h2&gt;

&lt;p&gt;The JNI layer sits behind a small &lt;code&gt;Engine&lt;/code&gt; interface. The session logic (streaming, limits, cancellation, stats, the UTF-8 decoder) runs against a fake engine in plain JVM unit tests, so CI checks it on every push without an emulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Library: &lt;a href="https://github.com/ThalzeronLabs/llama-kotlin" rel="noopener noreferrer"&gt;ThalzeronLabs/llama-kotlin&lt;/a&gt; (Apache-2.0)&lt;/li&gt;
&lt;li&gt;App: &lt;a href="https://github.com/ThalzeronLabs/android-on-device-llm" rel="noopener noreferrer"&gt;ThalzeronLabs/android-on-device-llm&lt;/a&gt;: the APK is in Releases. On first launch (on Wi-Fi) it downloads Qwen2.5-0.5B-Instruct, checks the SHA-256, and then works with no internet. Phones with more RAM can switch to SmolLM2 1.7B or Qwen2.5 1.5B, or import any &lt;code&gt;.gguf&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run it, I'd like to know which phone you used and the tokens/s the app shows. There are a few "good first issue" tickets open if you want to contribute.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>llm</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
