Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
gradlew text eol=lf
gradlew.bat text eol=crlf
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ build
.externalNativeBuild
.cxx
local.properties
*.jks
*.keystore
*.litertlm

124 changes: 50 additions & 74 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,70 @@
# HumNod - Local LLM Android Assistant & Framework
<p align="center">
<img src="docs/assets/humnod-banner.webp" alt="HumNod, offline AI for Android" width="100%">
</p>

Welcome to the official repository for **HumNod**, a cutting-edge, privacy-centric Android application that democratizes access to artificial intelligence by running advanced Large Language Models (LLMs) entirely on your local mobile hardware.
<h1 align="center">HumNod</h1>
<p align="center">Draft, ask, and work with files using AI that runs on your Android phone.</p>
<p align="center">
<a href="#quick-start">Quick start</a> ·
<a href="#models">Models</a> ·
<a href="#files-and-privacy">Files and privacy</a> ·
<a href="#build-from-source">Build from source</a> ·
<a href="https://github.com/arpanjot-coding/HumNod-Android/issues">Report an issue</a>
</p>

In an era where cloud computing raises severe data privacy concerns and requires constant internet connectivity, HumNod provides a robust alternative. By leveraging the Microsoft ONNX Runtime Generative AI API and native Android frameworks, this application transforms your smartphone into an offline powerhouse capable of context-aware chatting, physical document analysis (Local RAG), and real-time image text extraction, all without a single byte of your personal data ever leaving your device.
## A look inside

Whether you are an end-user looking for a secure AI companion or an Android developer seeking a highly structured foundational framework to build your own local AI apps, this repository provides everything you need.
<p align="center">
<img src="docs/screenshots/setup.png" alt="HumNod setup screen with three local model choices and their download sizes" width="29%">
&nbsp;
<img src="docs/screenshots/chat.png" alt="HumNod displaying a reply generated offline on Android" width="29%">
&nbsp;
<img src="docs/screenshots/attachment.png" alt="Preview of text HumNod extracted from a PDF" width="29%">
</p>
<p align="center"><sub>Actual HumNod screens captured on Android 16. The chat and PDF use sample content.</sub></p>

[![HumNod Demo Video](https://img.youtube.com/vi/yTYgDmDwdxM/0.jpg)](https://www.youtube.com/watch?v=yTYgDmDwdxM "HumNod - Offline AI Assistant Demo")
## Quick start

*(Click the image above to watch the HumNod demo on YouTube)*
1. [Build and install the debug APK](#build-from-source) on a 64-bit phone running Android 9 or later.
2. Open HumNod, choose a model, and confirm the download. **Qwen3** is the starting point for everyday use. **SmolLM2** takes up the least space.
3. Once the model is ready, send a prompt or tap **Summarize a file**. You can use HumNod in airplane mode after setup.

---
The composer offers concise and detailed replies in Assistant and Academic styles. Each prompt starts a fresh exchange. Use **Copy reply** to save a result elsewhere.

## 🌟 Comprehensive Feature Set
## Models

### 🔒 Zero-Trust Privacy & 100% Offline Inference
HumNod does not rely on API keys, cloud servers, or external internet connections for text generation. Once the initial LLM weights are downloaded, your device's CPU/NPU handles all the computational heavy lifting locally.
- **SmolLM2 135M · 143 MB.** The smallest option for simple prompts. Use Qwen3 or Gemma 4 for detailed instructions or work with files. [Model card](https://huggingface.co/litert-community/SmolLM2-135M-Instruct)
- **Qwen3 0.6B · 343 MB.** The recommended choice for everyday questions, edits, and drafts. [Model card](https://huggingface.co/litert-community/Qwen3-0.6B)
- **Gemma 4 E2B · 2.59 GB.** The more capable choice for demanding prompts on phones with storage and memory to spare. [Model card](https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm)

### 🧠 On-Device Model Hub
The app features a built-in model management system (`HubActivity`). Users can easily browse, download, and seamlessly switch between highly optimized, quantized state-of-the-art open-source models (such as Microsoft's Phi-3 or Meta's Llama 3) directly to their device storage.
The figures are download sizes. Models run on the CPU, and speed and memory use vary by device. Tap the active model name to switch models, resume a download, or remove a model. [LiteRT-LM runtime](https://github.com/google-ai-edge/LiteRT-LM)

### 📄 Local Retrieval-Augmented Generation (RAG)
Chatting with your documents has never been more secure. Using the custom `DocumentProcessor.java` module, users can upload `.txt`, `.pdf`, and `.md` files directly into the chat interface. HumNod automatically extracts the text, cleans the formatting, and injects it into the LLM's context window, allowing you to ask questions about your specific offline data.
The ARM64 debug APK is about 62 MB. See the [performance notes](docs/performance.md) for emulator timings, package sizes, and file processing changes.

### 📸 Seamless Image OCR Integration
Need to analyze a screenshot or a photo of a physical document? Attach images directly to your prompt. HumNod utilizes Google ML Kit's on-device Vision libraries via `OCRHelper.java` to read the text inside the image and feed it to the AI for instant analysis and summarization.
## Files and privacy

### ⚙️ Granular Generation Controls
Take complete control over how the AI responds using the intuitive `BottomSheet` UI. You can tweak generation parameters in real-time without interrupting your workflow:
* **Max Response Length:** Control token output limits.
* **Length Penalty:** Force the model to be concise or highly descriptive.
* **Agent Type:** Alter the system prompt to change the persona of the AI (e.g., Academic, Casual, Coding Assistant).
**Attach** accepts plain text, Markdown, PDFs with selectable text, and photos of text. Tap the attached filename to review the exact excerpt before you send it. HumNod uses up to 1,800 characters and searches the first 12 pages of a PDF for text. Photos are read with on-device text recognition.

---
Prompts, extracted text, and replies are processed on your device. HumNod keeps no chat history and requires no account or API key. An internet connection is used to download model files from Hugging Face. Each download is verified against a pinned SHA-256.

## 🏗️ Technical Architecture & Codebase Deep Dive
## Build from source

HumNod is built entirely in Java, prioritizing performance, stability, and clean architectural patterns.
Use Android Studio with JDK 17 or later and Android SDK Platform 36.

* **`MainActivity.java`**: The nerve center of the application. It acts as the bridge between the Android UI and the underlying C++ inference engine. It manages the active chat session, handles token streaming UI updates, and orchestrates user inputs.
* **ONNX Runtime Engine (`onnxruntime-genai-android-0.4.0-dev.aar`)**: Located in `app/libs/`, this pre-compiled AAR library provides the highly optimized execution environment for `.onnx` models, utilizing JNI to interface with the device's hardware.
* **`ModelDownloader.java`**: A robust background service responsible for safely fetching heavy model artifacts (`.onnx` weights, `.bin` configurations, tokenizers) from remote servers to local storage, complete with interrupt-resume capabilities.
* **Dynamic UI Rendering**: Found in `app/src/main/res/`, the UI uses optimized XML layouts and dynamic custom drawables (like `ic_animation_bear.gif` and dynamic corner radii) to provide a fluid, modern user experience while the model is actively streaming tokens.
```sh
git clone https://github.com/arpanjot-coding/HumNod-Android.git
cd HumNod-Android
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
```

---
On Windows, run `.\gradlew.bat :app:assembleDebug` in PowerShell. For an x86-64 emulator, install `app-x86_64-debug.apk` from the same output folder. Run `:app:lintDebug` with the Gradle wrapper to check the Android project.

## 🚀 Getting Started
## Troubleshooting

### System Requirements
Running local LLMs requires modern mobile hardware. To ensure smooth generation speeds (Tokens Per Second):
* **RAM:** Minimum 4GB (6GB+ highly recommended for 3B+ parameter models).
* **Storage:** At least 2GB to 5GB of free space depending on the quantized model downloaded.
* **OS:** Android 10 (API Level 29) or higher.
- **Download interrupted.** Tap **Try again**, or select the same model and tap **Resume**.
- **A model is slow or fails to load.** Try Qwen3 or SmolLM2 and close other memory-intensive apps.
- **A file has no readable text.** Use a PDF with a text layer or a clear photo of the relevant section. HumNod accepts PDFs up to 32 MB and photos up to 24 MB.
- **Upgrading from Phi-3.** Choose a new model in HumNod. Clearing HumNod's storage from Android Settings removes old model files, current downloads, and saved preferences.

### Developer Setup & Build Instructions

1. **Clone the Source Code:**
Open your terminal and clone the repository:
```bash
git clone [https://github.com/yourusername/Local_LLM_AndroidApp.git](https://github.com/yourusername/Local_LLM_AndroidApp.git)
cd Local_LLM_AndroidApp
```

2. **Open in Android Studio:**
Launch Android Studio (latest stable release recommended). Select **File > Open** and navigate to your cloned directory.

3. **Verify Dependencies:**
Allow Android Studio to sync the Gradle build files. **Crucial step:** Ensure that the local library `app/libs/onnxruntime-genai-android-0.4.0-dev.aar` is properly recognized, as the project will not compile without the ONNX GenAI bindings.

4. **Compile and Run:**
Connect a physical Android device with USB Debugging enabled. Emulators are generally too slow for local LLM inference testing unless hardware acceleration is heavily optimized. Click **Run (Shift + F10)**.

> **Note:** On your very first launch, you will need an active Wi-Fi connection to download your preferred LLM package via the Hub dashboard. Once the download is verified, you can safely turn on Airplane Mode and use the app indefinitely.

---

## 🤝 Contributing
We welcome contributions from the Android and Open-Source AI community! Whether you want to add support for new file types in the `DocumentProcessor`, optimize the UI with Jetpack Compose, or add support for newer ONNX execution providers, please feel free to fork the repository and submit a pull request.

1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request

---

## 🛡️ License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. This open-source framework is intended to help developers build secure, local-first applications.
```c
Key: humnod
Pass Both: 245632
```
When filing an issue, include your Android version, phone model, selected model, and steps to reproduce. [Open an issue](https://github.com/arpanjot-coding/HumNod-Android/issues)
59 changes: 19 additions & 40 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,29 @@ plugins {

android {
namespace = "ai.humnod.genai.app"
compileSdk = 34
compileSdk = 36

defaultConfig {
applicationId = "ai.humnod.genai.app"
minSdk = 27
targetSdk = 34
versionCode = 6
versionName = "1.8"

minSdk = 28
targetSdk = 36
versionCode = 8
versionName = "2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

ndk {
//noinspection ChromeOsAbiSupport
abiFilters += listOf("arm64-v8a", "x86_64")
}
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "x86_64")
isUniversalApk = false
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

buildFeatures {
Expand All @@ -42,26 +35,12 @@ android {
}

dependencies {

implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")

// ONNX Runtime with GenAI
implementation("com.microsoft.onnxruntime:onnxruntime-android:latest.release")
implementation(files("libs/onnxruntime-genai-android-0.4.0-dev.aar"))

//MarkDown
implementation ("io.noties.markwon:core:4.6.2")

implementation("com.google.ai.edge.litertlm:litertlm-android:0.19.1")
implementation("io.noties.markwon:core:4.6.2")
implementation("com.google.mlkit:text-recognition:16.0.1")

implementation ("com.itextpdf:itext7-core:7.2.3")

implementation ("pl.droidsonroids.gif:android-gif-drawable:1.2.24")


}
implementation("com.itextpdf:kernel:7.2.3")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
}
Binary file removed app/libs/onnxruntime-genai-android-0.4.0-dev.aar
Binary file not shown.
148 changes: 148 additions & 0 deletions app/src/androidTest/java/ai/humnod/genai/app/AttachmentReaderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package ai.humnod.genai.app;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.net.Uri;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import com.itextpdf.io.font.constants.StandardFonts;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.io.File;
import java.io.FileOutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;

@RunWith(AndroidJUnit4.class)
public class AttachmentReaderTest {
private Context context;
private File fixtures;

@Before
public void createFixtureDirectory() throws Exception {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
fixtures = Files.createTempDirectory(context.getCacheDir().toPath(), "attachment-test-").toFile();
}

@After
public void removeFixtures() throws Exception {
File[] files = fixtures.listFiles();
if (files != null) {
for (File file : files) Files.deleteIfExists(file.toPath());
}
Files.deleteIfExists(fixtures.toPath());
}

@Test
public void markdownFallbackPreservesIndentationAndHardLineBreaks() throws Exception {
File fixture = new File(fixtures, "notes.md");
String original = "\ufeff indented code\r\nA hard break \r\nRésumé ✅\r";
Files.write(fixture.toPath(), original.getBytes(StandardCharsets.UTF_8));

AttachmentReader result = AttachmentReader.read(context, Uri.fromFile(fixture));

assertEquals("notes.md", result.name);
assertEquals(" indented code\nA hard break \nRésumé ✅\n", result.text);
assertFalse(result.excerpt);
}

@Test
public void readsBomMarkedUtf16() throws Exception {
File fixture = new File(fixtures, "notes.txt");
Files.write(fixture.toPath(), "\ufeffCafé 🥐\r\n".getBytes(StandardCharsets.UTF_16LE));

AttachmentReader result = AttachmentReader.read(context, Uri.fromFile(fixture));

assertEquals("Café 🥐\n", result.text);
assertFalse(result.excerpt);
}

@Test
public void excerptDoesNotSplitCombiningCharacters() throws Exception {
File fixture = new File(fixtures, "long.txt");
char[] prefix = new char[1799];
Arrays.fill(prefix, 'a');
String expected = new String(prefix);
Files.write(fixture.toPath(), (expected + "e\u0301 remaining").getBytes(StandardCharsets.UTF_8));

AttachmentReader result = AttachmentReader.read(context, Uri.fromFile(fixture));

assertEquals(expected, result.text);
assertTrue(result.excerpt);
}

@Test
public void pdfExtractsTextFromBoundedPageRange() throws Exception {
File fixture = new File(fixtures, "agenda.pdf");
try (PdfDocument document = new PdfDocument(new PdfWriter(fixture))) {
PdfFont font = PdfFontFactory.createFont(StandardFonts.HELVETICA);
for (int page = 1; page <= 13; page++) {
new PdfCanvas(document.addNewPage()).beginText().setFontAndSize(font, 20)
.moveText(40, 700).showText("Agenda page " + page).endText();
}
}

AttachmentReader result = AttachmentReader.read(context, Uri.fromFile(fixture));

assertTrue(result.text.startsWith("Agenda page 1\n\nAgenda page 2"));
assertTrue(result.text.endsWith("Agenda page 12"));
assertFalse(result.text.contains("Agenda page 13"));
assertTrue(result.excerpt);
}

@Test
public void oversizedPdfIsRejectedBeforeParsing() throws Exception {
File fixture = new File(fixtures, "oversized.pdf");
try (RandomAccessFile file = new RandomAccessFile(fixture, "rw")) {
file.setLength(32_000_001);
}

AttachmentReader.ReadFailure failure = assertThrows(AttachmentReader.ReadFailure.class,
() -> AttachmentReader.read(context, Uri.fromFile(fixture)));

assertEquals("Choose a PDF no larger than 32 MB.", failure.getMessage());
}

@Test
public void bundledOcrReadsPhotoText() throws Exception {
File fixture = new File(fixtures, "label.png");
Bitmap image = Bitmap.createBitmap(900, 320, Bitmap.Config.ARGB_8888);
try (FileOutputStream output = new FileOutputStream(fixture)) {
Canvas canvas = new Canvas(image);
canvas.drawColor(Color.WHITE);
Paint ink = new Paint(Paint.ANTI_ALIAS_FLAG);
ink.setColor(Color.BLACK);
ink.setTextSize(80);
canvas.drawText("HUMNOD OFFLINE", 36, 160, ink);
assertTrue(image.compress(Bitmap.CompressFormat.PNG, 100, output));
} finally {
image.recycle();
}

AttachmentReader result = AttachmentReader.read(context, Uri.fromFile(fixture));

assertTrue(result.text.contains("HUMNOD OFFLINE"));
assertFalse(result.excerpt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("ai.onnxruntime.genai.demo", appContext.getPackageName());
assertEquals("ai.humnod.genai.app", appContext.getPackageName());
}
}
}
Loading