A personal AI chat client for Android that talks directly to LLM provider APIs — pay per use instead of paying for a subscription you barely touch.
Built as a private app for personal use. The code is public so it can serve as a reference / portfolio piece, but there are no secrets in this repo — API keys are entered by the user at runtime and stored encrypted on-device.
Most AI chat subscriptions bundle a flat monthly fee regardless of actual usage. OneAgentArmy skips the middleman: it calls OpenAI, Google Gemini, and Anthropic Claude directly with your own API key, so you only ever pay for the tokens you actually use — while keeping one consistent chat UI across all three.
- Three AI providers, one app — OpenAI (Responses API), Google Gemini (Interactions API), and Anthropic Claude (Messages API), each with three model tiers (cheap / mid / flagship). Every conversation remembers its own model, independent of whichever provider is set as the app-wide default for new chats.
- Remotely updatable model catalog — model lists and prices live in models.json in this repo; the app fetches it on demand (Settings → AI providers → Refresh) and caches the result, so a deprecated model or a price change is fixed by editing one JSON file on GitHub — no new APK needed. Per-model rates for cached tokens, cache writes and hosted searches all live there too, so calibrating a price against a real provider dashboard never needs a release. Compiled-in defaults remain as the offline fallback. The same refresh also cross-checks every catalog model against each provider's live model listing and warns if one is no longer listed (warning-only — listings can lag for preview models, so nothing is ever auto-removed).
- Tool calling — two flavors:
- Client-side tools with a confirmation card: calendar events, alarms, timers, SMS drafts, navigation, notes. Nothing is sent until you tap confirm, and the tool-call turn itself never touches local storage.
- Transparent, provider-side round-trips: live weather (Open-Meteo) and web search, invisible to the rest of the app.
- Hosted web search toggle — switch between each provider's built-in web search and a Tavily-backed fallback, per your preference; not every model supports hosted search, so the app degrades gracefully where it doesn't. Hosted searches are billed per call on top of tokens, so the system prompt tells the model to search once, only when the question actually needs live information, and never more than twice for one message.
- Multimodal attachments — paste in text/CSV files inline, or attach real photos and PDFs (capped at 5 MB) as native multimodal blocks, images auto-scaled to keep costs sane. Photos can be shot in place with the device camera as well as picked from the gallery — the capture goes through the same downscale-and-store path, and delegating to the system camera app means the
CAMERApermission is never needed. - Rolling conversation context — every message carries recent history so the model remembers what was said: at least 40 messages by default, allowed to grow to 60 before being trimmed back in one batch. The batching is deliberate — dropping one message per turn would shift the start of every request and invalidate the provider's prompt cache (see below), so history is trimmed in chunks to keep the cached prefix stable. Configurable in Settings, with a per-conversation override. An attached file or photo is resent on every follow-up until it ages out of the window, though caching serves that repeated part at a steep discount.
- Cost tracking — every AI reply shows an estimated cost in EUR (daily ECB exchange rate, no API key required), with running totals per conversation, per month, and broken down per provider. Set a monthly spending threshold and the total turns red once you cross it. Hosted web searches are priced in alongside tokens: providers bill them per call as a separate line item, and counting only tokens understated a real month by ~20%. The count comes from the reply itself —
web_search_callitems on OpenAI,server_tool_useblocks on Anthropic — rather than from a flat markup, so a conversation that never searched is never inflated. A model with no rate of its own falls back to a deliberately pessimistic default instead of to free, on the principle that an unpriced charge must never make the app look cheaper than the bill. - Prompt caching — each turn resends the rolling context window, so that repeated prefix is served from the provider's cache at a steep discount (and an attachment inside the window stops being re-billed at full price on every follow-up). Claude requires an explicit opt-in, which the app sends and silently drops if the API ever stops accepting it; OpenAI and Gemini cache automatically. All three report cached tokens differently — Anthropic alongside the input count, the other two inside it — so each is normalized once at the API boundary and priced per bucket. Unknown or missing cache rates fall back to the full input price, so the estimate can never under-report a bill. Because the match is a contiguous prefix starting at token zero, the system prompt is kept strictly free of anything that varies between requests — it used to open with a sub-second timestamp, which broke the prefix on literally every call and made caching more expensive than not caching at all (writes are billed; Anthropic's at a 1.25× premium). The clock now rides on the messages instead: each user message is tagged with its own send time, so a replayed message reproduces byte-identical bytes, the newest tag tells the model what time it is, and there is no midnight boundary to trip over.
- Configurable request timeout — reasoning models can think for minutes; how long to wait before cancelling is settable (m:ss format, default 4:00, max 15:00). While waiting, the chat shows an elapsed timer, and a genuine timeout is reported as such — not misdiagnosed as a lost internet connection.
- Long replies open at the top — a chat list is anchored to its bottom, which lands a screen-and-a-half answer on its closing line and makes you scroll back up to read it. An arriving reply is pinned to its first line instead. Worth noting why that is harder than it sounds: markdown is parsed off the main thread and images decode asynchronously, so the bubble is near-empty on the frame it appears and inflates over the following ones — an offset computed from that first measurement puts you right back at the bottom. The alignment re-runs on every height change for a short window, and bails the moment you touch the list.
- Failures are recorded, not just announced — an error banner lives only as long as the screen is open, so a request that died left nothing behind but a message with no answer under it, indistinguishable from data loss. The reason is now written onto the message itself and survives restarts; a successful resend clears it. The catch around the request is deliberately wider than the provider exception type: a malformed response body throws a serialization error, which previously escaped it entirely and took the app down, producing exactly the same silent gap.
- App lock — optional biometric/PIN/pattern gate before the app opens; re-locks every time it goes to the background. Re-locking covers the UI rather than removing it from composition, which is subtler than it looks: "backgrounded" includes launching the camera or a file picker, and tearing that screen down would dispose the
rememberLauncherForActivityResultstill waiting on it —ActivityResultRegistry.unregister()discards the pending result outright, so a photo you had just shot was silently thrown away on the way back. A cold start still withholds the UI entirely until the first unlock; only an already-unlocked session gets covered. - Backup-aware — conversation history rides along with Android's normal automatic backup; saved API keys are deliberately excluded (a Keystore-backed key never migrates between devices anyway), so you just re-enter them after a restore instead of ending up with dead ciphertext.
- Pinning & smart sorting — pin the conversations you're actively using; everything else sorts by most recent message, not creation date.
- Full-text search across all conversations, with matches deep-linking straight to the message.
- Sharing — send a single reply or a whole conversation transcript through the native Android share sheet.
- Facts & personalization — save durable facts about yourself once, attach them to conversations that should know about them, and adjust the chat font size to taste.
- Persistent drafts & screen restore — an unsent message (and any staged attachment) is saved automatically and survives the app being backgrounded, locked, or killed by the system; reopening the app returns to whichever screen you were last on instead of the conversation list. For a conversation that hasn't sent its first message — and therefore has no row of its own yet — the draft also carries the model, context facts and context-window override chosen for it. Persisting the text alone turned out to be worse than persisting nothing: everything looked restored while the model had quietly reverted to the cheapest default, so the next send went to a different model than the one on screen when you walked away.
- Material 3 theming built entirely from the app icon's own palette, with a searchable in-app help screen covering all of the above.
- Kotlin + Jetpack Compose (Material 3), MVVM + Repository pattern
- Manual dependency wiring — no DI framework, composition root lives in
AppContainer.kt - Room for local persistence (conversations, messages, facts), with additive-only migrations
- OkHttp + kotlinx.serialization — raw HTTP against each provider's REST API rather than an official SDK, so all three providers follow one consistent internal pattern
- DataStore Preferences for settings; API keys encrypted with Android Keystore (AES-256-GCM)
androidx.biometricfor the optional app-lock gate- JUnit 4 + OkHttp MockWebServer for unit tests, AndroidX Test for instrumented tests
minSdk 33
app/src/main/java/com/parrotworks/oneagentarmy/
├── AppContainer.kt # composition root — manual DI
├── data/ # Room entities/DAOs, repositories, DataStore
├── model/ # domain models
├── provider/ai/ # one package per provider (openai/gemini/anthropic) + shared tool registry
├── tools/ # client-side confirmation-card tools (calendar, alarms, SMS, ...)
└── ui/ # Compose screens, per feature (chat, conversationlist, settings, search)
- Unit tests (
app/src/test) — pure JVM, no emulator or device needed. Each provider's HTTP client (OpenAiApiClient,GeminiApiClient,AnthropicApiClient) is exercised against OkHttp MockWebServer through a redirectingInterceptor(the clients hardcode their real endpoint, so a test-only interceptor rewrites the destination instead of touching production code) — covers request shape, response parsing, and every HTTP error path (401/403/429/500/no connectivity), with zero real network calls and zero API cost. Also covers pricing/model-registry invariants and small utility functions (cost formatting, search normalization). - Instrumented tests (
app/src/androidTest) — need a real Android runtime, but an emulator is enough; no physical device required. Currently covers the AES-GCM round-trip through the real Android Keystore.
Run from Android Studio (right-click the test/androidTest source folder → Run), or ./gradlew test / ./gradlew connectedAndroidTest from the command line.
This is a personal project, not a published app — there's no build pipeline or Play Store listing to point at. To run it yourself:
- Open the project in Android Studio (AGP 9 / Kotlin, no extra Kotlin plugin needed — it ships built into AGP 9).
- Build and install on a device or emulator running API 33+.
- On first launch, open Settings and paste in your own API key(s) for whichever provider(s) you want to use. Keys never leave the device except as Authorization headers to that provider's API.
Actively evolving, one small staged branch at a time. See commit history for what's shipped.
ParroT woRKs by Piotr Paterek







