A native Jetpack Compose chat client for the AI models you already pay for.
English · العربية · Deutsch · Español · Français · हिन्दी · Indonesia · 日本語 · 한국어 · Português · Русский · ไทย · Türkçe · Tiếng Việt · 简体中文 · 繁體中文
The Oriveo Android client is a bring-your-own-key AI chat app. You add API keys you already own, and the app talks to each provider directly from the phone. Conversations, notes, folders, and skills are stored on the device in Room; API keys are encrypted with a key held in the Android Keystore. There is no account and no sign-in.
It is part of Oriveo Community Edition — three clients that share one definition of how to talk to a model provider.
flowchart TB
subgraph ui ["Compose UI"]
direction LR
nav["OriveoNavHost<br/>type-safe @Serializable routes"]
screens["Chat · Home · Providers<br/>Notes · Skills · Settings"]
end
vms["ViewModels · Koin<br/>ChatViewModel and its coordinators"]
streaming["ChatStreamingManager<br/>one session per conversation, application-scoped"]
subgraph data ["On the device"]
direction LR
room[("oriveo.db<br/>10 entities · FTS4")]
cont[("message_continuations.db")]
keys[["EncryptedSharedPreferences<br/>keys · tokens · archive keys"]]
end
subgraph net ["Provider layer"]
direction LR
repo["ProviderRepository<br/>serviceFor(kind)"]
svc["16 ProviderService<br/>OpenAI-compatible base + bespoke"]
sse["SseParser<br/>+ TransportRegistry"]
end
nav & screens <--> vms
vms --> streaming
streaming --> repo
repo --> svc --> sse
streaming <--> room & cont
svc <--> keys
sse ==>|"your key"| up["Model provider"]
Three things in this diagram are deliberate design decisions rather than incidental structure.
Streaming lives above the screen. ChatStreamingManager keeps one StreamingSession per
conversation id in a ConcurrentHashMap, each running as its own Job on a single
application-scoped CoroutineScope(SupervisorJob() + Dispatchers.IO) — the supervisor is the point,
so one stream failing does not take the others down. Navigating away from a chat does not cancel the
answer, and ChatRepository flushes partial text to SQLite whenever StreamingTokenBuffer says
enough has accumulated (4,000 characters or 60 seconds), so killing the app mid-answer does not lose
what already arrived.
Two databases, not one. oriveo.db holds conversations, messages, attachments, notes,
folders, skills, and the model-catalog cache. message_continuations.db is a physically separate
file holding opaque provider continuation state, precisely so backup_rules.xml and
data_extraction_rules.xml can exclude it from cloud backup and device transfer — a continuation
token restored onto another device is meaningless at best.
A catalog newer than the binary degrades, it does not break. TransportKind is a closed enum
with a lenient deserializer: an unknown transport string decodes to null, TransportRegistry
returns no strategy, and the model is filtered out of the picker. The alternative — a strict enum —
would fail the whole catalog parse and take every other model down with it.
The client never guesses a model's capabilities from its name. It reads a capability runtime from
the catalog: recipes describing, for a given provider, transport, and capability, exactly which
JSON pointers to write into the request. ProviderRecipeRequestCompiler validates the recipe
against the provider, capability, and transport before compiling it into an owned body delta, and
rejects with a named reason (recipe_not_found, transport_mismatch, model_route_must_not_patch_body)
rather than silently producing a request nobody reviewed.
Coming back, CapabilityEvidenceFacade ranks what is actually known about a capability by source —
operator_override > server_typed > server_profile > model_facts > relay_verification >
relay_declaration > legacy_metadata. Only the stream parser may mark a capability observed;
intent, recipes, an HTTP 200, and a tool declaration explicitly do not count. The per-message
result is persisted, so the UI can distinguish requested from confirmed.
Overrides resolve last-write-wins across seven scopes, in priority order: single_send >
conversation_connection_model > skill_agent > connection_model > connection >
provider_recipe > provider_default.
| What | Where |
|---|---|
| Conversations, messages, attachments, notes, folders, skills | Room, oriveo.db |
| Full-text search over notes | FTS4 virtual table |
| Model catalog cache | a single row in oriveo.db, read back in chunks |
| Provider continuation state | message_continuations.db, excluded from backup |
| Provider API keys | EncryptedSharedPreferences, AES-256-GCM, Keystore-held master key |
| Subscription OAuth tokens | a second, separate encrypted preferences file |
| Backup archive keys | a third |
| MCP sign-in tokens, and server addresses that carry a key | a fourth |
| Attachment blobs | files on disk, referenced by id |
The four encrypted preference files are separated by lifetime and blast radius rather than merged
for convenience. Each has a recovery path: a corrupted file (AEADBadTagException,
VERIFICATION_FAILED) is detected, deleted, and recreated instead of crashing the app on every
launch.
All four, and the continuation database, are excluded from Android cloud backup and device transfer. That is a consequence of binding them to the Keystore rather than an oversight — the ciphertext would be undecryptable on the new device anyway. After moving to a new phone you re-enter your API keys and sign in again to any provider subscription and any MCP server; conversations and notes come across normally.
An archive you export yourself is a zip holding data.json plus the attachment files. The password
you choose protects only the provider API keys inside it: they are encrypted with
PBKDF2-HMAC-SHA256 at 600,000 iterations and AES-GCM and stored as one field of data.json.
Conversations, messages, notes, folders, skills, preferences and attachments are written as plain
JSON and plain files either way, so treat an archive as readable by anyone who has the file. Export
without keys if you only want your history.
The manifest sets android:usesCleartextTraffic="true", deliberately: local model servers —
llama.cpp, Ollama, LM Studio, vLLM — speak plain HTTP on your own machine or LAN, and generally have
no certificate.
The real boundary is in code, not in the manifest, because it has to be. RelayEndpointPolicy
resolves the host, requires every resolved address to be private (loopback, RFC 1918, link-local,
unique-local, and the CGNAT range in VPN mode), rejects a host that resolves to a mix of public and
private addresses, pins the resolved address set against DNS rebinding, and re-verifies it at send
time. It refuses any cleartext request carrying credential material. Redirects are not followed at
all on the discovery and local-engine clients, with that address pin as the backstop.
An Android network security config cannot express that set: it matches on hostname only, has no syntax for address ranges, and the addresses here come from the user's own network at runtime. A config would also be strictly weaker, since it never sees the address a name resolved to.
The app reads model capabilities and prices from a public catalog so a model released today works
without an app update. It is a plain HTTPS GET with no credentials and no identifier attached,
and chat requests never go near it. Only two endpoints are requested:
GET {base}/api/metadata?view=lean
GET {base}/api/metadata/model-facts
The base URL is a build-time property, defaulting to https://api.oriveoai.com:
./gradlew :app:assembleDebug -PORIVEO_METADATA_BASE_URL=https://your.hostResponses are ETag-revalidated and cached in oriveo.db, so once a fetch has succeeded the app
keeps working from the cached copy when the catalog is later unreachable.
Important
Building with an empty value (-PORIVEO_METADATA_BASE_URL=) disables catalog fetching entirely,
and there is no snapshot bundled in the APK. On a fresh install of such a build:
- none of the 15 built-in providers gets a model list, and the app does not ask the provider for one — the catalog is the only source;
- the provider detail screen shows an "Unable to load official models" banner, but adding the key still reports success and the model picker is simply empty;
- OpenAI becomes unusable, because manual model entry is blocked for that provider;
- Relay endpoints and local model servers still work fully, and are the only intact path.
If you want an offline build, serve the catalog yourself and point the build at it rather than emptying the value.
android/
app/src/main/java/ai/oriveo/community/
core/
provider/ every provider service, transports, relay, capability recipes
data/ Room entities, DAOs, repositories, backup, catalog client
model/ domain models and the capability/preference resolvers
attachments/ routing, budgets, per-format text extraction
security/ SecureKeyStore, BackupCrypto, external-URL policy
mcp/ remote MCP client: protocol, sign-in, tool bridge
streaming/ ChatStreamingManager
navigation/ AppRoute, OriveoNavHost
feature/ one package per screen
ui/ shared components, Markdown + LaTeX renderer, theme
di/ Koin modules
benchmark/ macrobenchmark suite (cold start, model picker)
Requirements: JDK 21 and the Android SDK. The build uses AGP 9.3, Gradle 9.5 and Kotlin 2.3, so Android Studio has to be a release that can sync AGP 9.3; from the command line only the JDK and SDK are needed.
./gradlew :app:assembleDebug
./gradlew :app:testDebugUnitTestThe build targets minSdk 26, targetSdk 36, compileSdk 37. local.properties (your SDK path)
is generated by Android Studio and is not committed. Release signing is described in
SIGNING.md.
Note
The Gradle daemon runs on a Java 21 toolchain (gradle/gradle-daemon-jvm.properties), and the
match is on 21 exactly, not "21 or newer". With any other JDK installed, Gradle downloads a JDK 21
for itself on the first build, which needs network access; installing JDK 21 yourself avoids it.
If you have set org.gradle.java.installations.auto-download=false, that download cannot happen
and the build fails with Toolchain auto-provisioning is not enabled. — that is the one case
where JDK 17 alone is genuinely not enough. Compilation targets Java 17 either way.
Unit-test parallelism is derived from the machine's CPU count and physical memory rather than hard-coded, so the suite behaves on both a laptop and a large workstation.
| Library | Version | Used for |
|---|---|---|
| Jetpack Compose BOM | 2026.08.00 | UI, Material 3 |
| Room | 2.8.4 | SQLite, DAOs, FTS4 |
| Koin | 4.2.2 | dependency injection |
| Ktor client (OkHttp engine) | 3.5.2 | provider HTTP and SSE |
| kotlinx.serialization | 1.11.0 | JSON |
| navigation-compose | 2.9.6 | type-safe routes |
| androidx.security-crypto | 1.1.0 | EncryptedSharedPreferences |
| haze | 1.7.3 | background blur |
| PDFBox-Android, jsoup | 2.0.27.0, 1.23.2 | attachment text extraction |
| jlatexmath-android | 0.2.0 | LaTeX rendering |
Exact versions are pinned in gradle/libs.versions.toml.
./gradlew :app:testDebugUnitTestRoughly 3,000 unit tests across 318 files, using JUnit 4, MockK, Robolectric,
kotlinx-coroutines-test and Ktor's mock engine. Coverage is heaviest where mistakes are most
expensive: request shape per provider, SSE parsing, transport selection, relay probing and security
modes, capability recipe execution, catalog caching and contract-version handling, Room persistence,
and backup round-trips.
Important
Around 38 suites load contract fixtures by walking up from the working directory until they find
shared/, so the tests only pass in a full checkout — copying android/ out on its own will
not work.
There are also three instrumented tests — a local-engine release matrix, a cleartext-socket test,
and a keystore isolation test. They are not self-contained: the local-engine ones need
instrumentation arguments naming a real running model server on your network, so
connectedAndroidTest does not pass out of the box. The unit suite is the gate for a pull request.
The :benchmark module holds macrobenchmarks for cold start and the model picker. It is a separate
Gradle module using com.android.test with self-instrumentation, and it drives a dedicated
benchmark build type of :app.
Both databases are at version = 1 with no migrations yet; schemas are exported to app/schemas/
and committed, which is where the first migration's 2.json will land.
Sixteen languages: values/ (English, the source) plus fifteen locale directories — alongside
values-night, which carries no strings — about 1,570 strings each, with every locale holding an
identical key set. In-app language switching goes through AppLanguageManager and
android:localeConfig. Language splits are disabled in the bundle so a single artifact carries
every translation.
See CONTRIBUTING.md. The working language of the project is English: source,
comments, tests and commit messages. UI strings are translated — add a new string to values/
first and leave the other locales to follow. Run the unit tests before opening a pull request.