Skip to content
Merged
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
66 changes: 66 additions & 0 deletions .github/scripts/check_16kb_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Check that 64-bit native libraries in an APK support 16 KB memory pages.

Google Play requires this for apps targeting Android 15 or newer. A library passes when
every PT_LOAD segment is aligned to at least 16 KB and, if stored uncompressed, its data
starts at a 16 KB boundary inside the APK.
"""
import struct
import sys
import zipfile

PAGE = 16 * 1024
ABIS = ("arm64-v8a", "x86_64")


def load_alignments(data):
if data[:4] != b"\x7fELF" or data[4] != 2:
return None
endian = "<" if data[5] == 1 else ">"
phoff = struct.unpack_from(endian + "Q", data, 0x20)[0]
phentsize, phnum = struct.unpack_from(endian + "HH", data, 0x36)
aligns = []
for index in range(phnum):
base = phoff + index * phentsize
p_type = struct.unpack_from(endian + "I", data, base)[0]
if p_type == 1: # PT_LOAD
aligns.append(struct.unpack_from(endian + "Q", data, base + 0x30)[0])
return aligns


def data_offset(apk_path, info):
with open(apk_path, "rb") as handle:
handle.seek(info.header_offset)
header = handle.read(30)
name_len, extra_len = struct.unpack_from("<HH", header, 26)
return info.header_offset + 30 + name_len + extra_len


def main(apk_path):
failures = []
checked = 0
with zipfile.ZipFile(apk_path) as apk:
for info in apk.infolist():
parts = info.filename.split("/")
if len(parts) != 3 or parts[0] != "lib" or parts[1] not in ABIS or not parts[2].endswith(".so"):
continue
checked += 1
aligns = load_alignments(apk.read(info))
if aligns is None:
failures.append(f"{info.filename}: not a 64-bit ELF file")
continue
too_small = [a for a in aligns if a < PAGE]
if too_small:
failures.append(f"{info.filename}: LOAD segment alignment {min(too_small)} < {PAGE}")
if info.compress_type == zipfile.ZIP_STORED and data_offset(apk_path, info) % PAGE:
failures.append(f"{info.filename}: stored uncompressed but not 16 KB aligned in the APK")
print(f"Checked {checked} native libraries in {apk_path}")
for failure in failures:
print(f"::warning title=16 KB page size::{failure}")
if not failures:
print("All 64-bit native libraries support 16 KB pages.")
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
33 changes: 25 additions & 8 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
Expand All @@ -16,7 +15,7 @@ permissions:

jobs:
build:
name: Unit tests and debug APK
name: Unit tests and debug APKs
runs-on: ubuntu-latest
timeout-minutes: 45
defaults:
Expand All @@ -42,10 +41,14 @@ jobs:
echo "org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8" >> ~/.gradle/gradle.properties

- name: Run unit tests
run: bash ./gradlew testDebugUnitTest --stacktrace
run: bash ./gradlew testGithubDebugUnitTest --stacktrace

- name: Build debug APKs (GitHub, F-Droid, Play)
run: bash ./gradlew assembleGithubDebug assembleFdroidDebug assemblePlayDebug --stacktrace

- name: Build debug APK
run: bash ./gradlew assembleDebug --stacktrace
- name: Check 16 KB page support of native libraries
continue-on-error: true
run: python3 ../.github/scripts/check_16kb_pages.py app/build/outputs/apk/play/debug/*.apk

- name: Upload test reports
if: failure()
Expand All @@ -54,9 +57,23 @@ jobs:
name: unit-test-reports
path: pocket_llm_src/app/build/reports/tests/

- name: Upload debug APK
- name: Upload GitHub debug APK
uses: actions/upload-artifact@v4
with:
name: pocket-llm-github-debug-apk
path: pocket_llm_src/app/build/outputs/apk/github/debug/*.apk
if-no-files-found: error

- name: Upload F-Droid debug APK
uses: actions/upload-artifact@v4
with:
name: pocket-llm-fdroid-debug-apk
path: pocket_llm_src/app/build/outputs/apk/fdroid/debug/*.apk
if-no-files-found: error

- name: Upload Play debug APK
uses: actions/upload-artifact@v4
with:
name: pocket-llm-debug-apk
path: pocket_llm_src/app/build/outputs/apk/debug/*.apk
name: pocket-llm-play-debug-apk
path: pocket_llm_src/app/build/outputs/apk/play/debug/*.apk
if-no-files-found: error
22 changes: 22 additions & 0 deletions docs/legal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Legal texts

The app ships two documents and shows them on first launch and under **About**:

- [Privacy Policy](../../pocket_llm_src/app/src/main/assets/legal/privacy_policy.md)
- [Terms of Use](../../pocket_llm_src/app/src/main/assets/legal/terms_of_use.md)

Both read the developer's name, address, contact email and effective date from
[`legal_contact.xml`](../../pocket_llm_src/app/src/main/res/values/legal_contact.xml).
Release builds fail while any of those values is still `TODO`. Debug builds still work.

The contact email is also where the in-app "Report response" button sends reports. While it is `TODO`, reports open a pre-filled GitHub issue instead.

## Before publishing

1. Fill in `legal_contact.xml`.
2. The postal address may stay empty in the public repo; the Play build sets it in the private repo.
3. Publish the filled-in Privacy Policy at a public URL (for example GitHub Pages). Google Play asks for this URL.
4. Fill in [`impressum.md`](impressum.md) and publish it next to the policy. German law requires an Impressum for apps offered commercially.
5. When either document changes materially, bump `LegalDocuments.CURRENT_VERSION` so users accept the new version.

These texts are a careful starting point written for an offline app with no accounts. They have not been reviewed by a lawyer.
18 changes: 18 additions & 0 deletions docs/legal/impressum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Impressum

Angaben gemäß § 5 DDG

Dinesh Soudagar
{{Optional: Geschäftsbezeichnung, z. B. "Dicha"}}
{{Straße und Hausnummer}}
{{PLZ Ort}}
Deutschland

**Kontakt**
E-Mail: dsoudagar.dev@gmail.com

**Umsatzsteuer**
Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung).

**Verbraucherstreitbeilegung**
Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
74 changes: 74 additions & 0 deletions docs/store/PLAY_CONSOLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Google Play release checklist

The `play` build flavor is the one uploaded to Google Play.

## Build

```bash
cd pocket_llm_src
bash ./gradlew bundlePlayRelease
```

- Upload `app/build/outputs/bundle/playRelease/app-play-release.aab`. Google splits it per CPU type, so phones download only their own native libraries instead of the 300 MB universal APK.
- Use **Play App Signing**: Play keeps the app signing key, and you sign uploads with your own upload key.
- Release builds fail until `src/main/res/values/legal_contact.xml` is filled in (see [docs/legal](../legal/README.md)).

| Setting | Value |
|---|---|
| Package name | `io.github.dineshsoudagar.pocketllm` |
| Target SDK | 36 (Android 16), as required since 2026-08-31 |
| Min SDK | 24 |

## App content (Play Console → Policy → App content)

**Privacy policy URL:** the public URL of the filled-in `privacy_policy.md`.

**App access:** all features are available without login.

**Ads:** answer "No", until an ads build exists.

**Target audience:** 18 and over. This keeps the app out of the Families policy.

**Content rating (IARC questionnaire):**
- Category: Reference, News or Educational / Utility.
- Users do not interact or share content with each other.
- Answer "Yes" to the question about AI-generated content.

**Generative AI:** the app has an in-app report button on every AI answer. It opens a pre-filled email to the contact address, or a GitHub issue as a fallback. Mention this if asked.

**Data safety**, for the current `play` build: no ads, ML Kit text recognition included.

| Question | Answer |
|---|---|
| Does the app collect or share user data? | Yes, collected (ML Kit diagnostics) |
| App info and performance → Diagnostics | Collected, not shared. Purpose: app functionality and analytics (by Google ML Kit). Not linked to the user |
| Device or other IDs | Collected, not shared. Purpose: same as above. Per-installation ID from ML Kit |
| Data encrypted in transit | Yes |
| Can users request deletion? | No. The developer holds no user data, and chats stay on the phone |
| Everything else (messages, photos, audio, files, location, contacts…) | Not collected. They stay on the device and are never transmitted |

If ML Kit is replaced by the open-source OCR used in the F-Droid build, the answer becomes "No data collected".

## Foreground service declarations (Policy → App content → Foreground service permissions)

Each declaration needs a short description and a link to a video (an unlisted YouTube video is fine) showing the feature.

| Service | Type | Justification to paste |
|---|---|---|
| `LanServerService` | `specialUse` | The user starts a local network server from the LAN Server dialog so their own computer on the same Wi-Fi can send prompts to the on-device model. It must keep running while the screen is off. It shows a persistent notification with a Stop action and stops when the user taps Stop. |
| `ModelDownloadService` | `dataSync` | Downloads the AI model files the user selected (0.5–4 GB) from Hugging Face. The download must continue when the user leaves the app, and shows a progress notification with a Cancel action. |
| WorkManager `SystemForegroundService` | `dataSync` | User-started extraction of text from large PDFs (up to 500 pages) and transcription of long audio files, with progress and Cancel. |

Known risk: Play may consider `dataSync` the wrong type for local document/audio processing. If the WorkManager declaration is rejected, move those jobs to the `mediaProcessing` type on Android 15+.

## Testing track

New **personal** developer accounts must run a closed test with at least **12 testers for 14 days** before publishing to production. Recruit testers from GitHub users with a pinned issue or Discussion.

**Organization** accounts, which need a free D-U-N-S number, don't have this requirement.

## Store listing

- Short description (80 chars), for example: "Private AI chat that runs offline on your phone. Turn it into a LAN AI server."
- Screenshots: at least 2 phone screenshots, plus a 512×512 icon and a 1024×500 feature graphic.
- Contact email and website: your Impressum/privacy page.
53 changes: 50 additions & 3 deletions pocket_llm_src/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,37 @@ val liteRtLmVersion = "0.17.1"

android {
namespace = "com.example.local_llm"
compileSdk = 35
compileSdk = 36

defaultConfig {
applicationId = "com.example.local_llm"
applicationId = "io.github.dineshsoudagar.pocketllm"
minSdk = 24
targetSdk = 35
targetSdk = 36
versionCode = 15
versionName = "1.6.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "LITERT_LM_RUNTIME_VERSION", "\"$liteRtLmVersion\"")
manifestPlaceholders["appLabel"] = "Pocket LLM"
}

// One codebase, three distribution channels:
// github = free APK on GitHub Releases, fdroid = F-Droid/IzzyOnDroid (no proprietary libraries),
// play = Google Play.
flavorDimensions += "store"
productFlavors {
create("github") {
dimension = "store"
buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"github\"")
}
create("fdroid") {
dimension = "store"
buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"fdroid\"")
}
create("play") {
dimension = "store"
buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"play\"")
}
}

buildTypes {
Expand All @@ -50,6 +70,15 @@ android {
}
}

// Debug builds of each channel install side by side so all three can be tested on one phone.
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
val channel = variant.flavorName ?: return@onVariants
variant.applicationId.set("io.github.dineshsoudagar.pocketllm.debug.$channel")
variant.manifestPlaceholders.put("appLabel", "Pocket LLM ($channel)")
}
}

val sherpaOnnxSource by configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
Expand Down Expand Up @@ -203,3 +232,21 @@ tasks.named("preBuild").configure {
// Android Studio may request this legacy Kotlin model task during sync. AGP 9
// provides Kotlin support directly, so keep this as a no-op compatibility task.
tasks.register("prepareKotlinBuildScriptModel")

// The in-app Privacy Policy and Terms of Use read their contact details from legal_contact.xml.
val legalContactFile = layout.projectDirectory.file("src/main/res/values/legal_contact.xml")
val verifyLegalContactFilled by tasks.registering {
group = "verification"
description = "Fails release builds while legal contact details are still TODO."
inputs.file(legalContactFile)
doLast {
val text = legalContactFile.asFile.readText()
check(!Regex(">\\s*TODO\\s*<").containsMatchIn(text)) {
"Fill in src/main/res/values/legal_contact.xml before building a release."
}
}
}

tasks.matching { it.name.startsWith("pre") && it.name.endsWith("ReleaseBuild") }.configureEach {
dependsOn(verifyLegalContactFilled)
}
2 changes: 1 addition & 1 deletion pocket_llm_src/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher_2"
android:label="Pocket LLM"
android:label="${appLabel}"
android:roundIcon="@mipmap/ic_launcher_2_round"
android:supportsRtl="true"

Expand Down
59 changes: 59 additions & 0 deletions pocket_llm_src/app/src/main/assets/legal/privacy_policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Privacy Policy

**Pocket LLM** · Effective date: {{EFFECTIVE_DATE}}

Pocket LLM runs AI models on your own phone. It has no user accounts, no ads, no analytics and no server operated by the developer. The developer does not receive your chats, prompts, files, recordings or model outputs.

## 1. Who is responsible

{{DEVELOPER_NAME}}{{POSTAL_ADDRESS}}

Email: {{CONTACT_EMAIL}}

## 2. Data that stays on your phone

Everything you create in the app is stored only in the app's private storage on your phone:

- chats, prompts and model responses
- attached text files, PDFs, images, photos and audio recordings, and text extracted from them
- downloaded or imported model files
- settings, including the LAN server password

You can delete single chats in the app. Uninstalling the app deletes all of it. The developer cannot access, recover or delete this data for you.

Android's own backup feature can include chat history and settings in your device backup (for example your Google account backup), depending on your phone's backup settings. Attachments are excluded from backups.

## 3. Permissions

- **Camera:** only when you take a photo to send to the model or to read text from it.
- **Microphone:** only when you record audio or use dictation. Speech is transcribed on your phone.
- **Notifications:** to show model download progress and that the LAN server is running.
- **Internet:** to download models and to run the LAN server when you start it.
- **Foreground service and wake lock:** to keep downloads and the LAN server running while the screen is off.

## 4. Network connections

The app connects to the internet only in these cases:

- **Model downloads:** when you download a built-in model or the speech-to-text model, the file is downloaded from Hugging Face (huggingface.co). Hugging Face receives the usual technical request data, such as your IP address. See the Hugging Face privacy policy: https://huggingface.co/privacy
- **LAN server:** when you start it, other devices on your local network can connect to the phone with the password you set. Requests and responses stay inside your network and are not sent to the developer.

Chat, image, document and audio processing happens on your phone and works offline once the models are installed.

## 5. Text recognition (GitHub and Google Play versions)

The GitHub and Google Play versions read text from images and scanned PDFs with Google ML Kit, which runs on your phone. Google states that ML Kit sends limited diagnostic data to Google: device information (manufacturer, model, Android version), the app's package name and version, performance metrics, API settings and event types, with a per-installation identifier that is not meant to identify you. Your images and the recognized text are not sent. See https://developers.google.com/ml-kit/android-data-disclosure

The F-Droid version does not include ML Kit.

## 6. Your rights

Under the GDPR you have the right to access, rectification, erasure, restriction, data portability and objection, and the right to lodge a complaint with a data protection supervisory authority. Because the developer does not hold any of your data, most requests can be fulfilled by you directly in the app. Contact {{CONTACT_EMAIL}} with any question.

## 7. Children

The app is not directed at children under 16.

## 8. Changes

If this policy changes, the new version is shown in the app and published with the app's source code.
Loading
Loading