The Android app for Chat with Work: AI bots in Europe. They work. You just decide. Your team's bots do their chores across the tools you connect, and every change or question that needs you waits on the Decisions page for you to work through from your phone. It runs on our servers in Germany with every model in the EU, or on your own servers.
The app is built with Hotwire Native. Its screens are the web app's own pages, so every feature the web app gains reaches the app without a release, and it goes native wherever that makes it feel like an Android app: it opens on a chat, with Material 3's navigation drawer as a native sidebar (your chats, projects, what waits for you, bots), Material top app bars with the page's actions and overflow menu, a native composer (with the photo picker, camera, ML Kit's document scanner, and dictation recorded on the phone and transcribed on the server), full-screen modals, a welcome screen, native dialogs, snackbars, haptics, search, the share sheet (both ways: text and files shared into a new chat too), Live Updates that follow the steps of answers you leave running, a home screen widget, a Quick Settings tile, shortcuts on the icon (New chat, New bot, Decisions), Android's default digital assistant (a new chat with the composer focused), sign-in through Chrome's Auth Tab, push notifications that show the answer's start or what waits for you (fetched from the server as they arrive, never through Google), App Links, predictive back, edge to edge, and Live Wire's colors in light and dark.
What the server has to do for all of that is written down in docs/server-contract.md, next to the iOS app's contract it shares.
- Android 9 (API 28) or later; built for Android 17 (API 37)
- JDK 21 or later to build (CI uses 21; Android Studio's bundled JDK works), Android SDK 37
- Pinned dependencies, in
gradle/libs.versions.toml: Hotwire Native Android 1.3.1, Joe Masilotti's bridge-components v0.14.0 (from JitPack), Material Components 1.14, AndroidX, Firebase Cloud Messaging (BoM 34.19)
./gradlew assembleDebug # debug build, for the emulator and a local server
./gradlew installDebug -PbaseUrl=http://localhost:3000
./gradlew testDebugUnitTest lintDebug spotlessCheck
./gradlew assembleStaging assembleReleaseOr open the folder in Android Studio and run the app configuration.
./gradlew spotlessApply formats Kotlin (ktlint) and the bundled JSON.
Each build type points at one server and installs as its own app, so all three can sit on one phone:
| Build type | Server | Application id | Name on the home screen |
|---|---|---|---|
| debug | http://10.0.2.2:3000, or -PbaseUrl=... |
com.chatwithwork.app.debug |
CWW Dev |
| staging | https://staging.chatwithwork.com |
com.chatwithwork.app.staging |
CWW Staging |
| release | https://chatwithwork.com |
com.chatwithwork.app |
Chat with Work |
10.0.2.2 is your computer as the emulator sees it, so the default debug
build talks to bin/dev; Android 17 asks for the local network permission
before it can. For a phone, or to skip that, forward the port and point at
localhost:
adb reverse tcp:3000 tcp:3000
./gradlew installDebug -PbaseUrl=http://localhost:3000Debug builds allow plain http to 10.0.2.2, localhost and 127.0.0.1
only, enable web view debugging (chrome://inspect), and show the server's
host on the welcome screen, as staging builds do.
Push notifications need app/google-services.json from the Firebase
project (git-ignored; see docs/push-notifications.md).
Without it the app builds and runs, with push off.
| Permission | Why |
|---|---|
INTERNET |
the server |
POST_NOTIFICATIONS |
push notifications, offered once, the first time a change waits for approval or an answer takes a while, with a short explanation first, or from Settings › Notifications (docs/push-notifications.md) |
ACCESS_NETWORK_STATE |
whether there's a connection at launch: without one the app opens the chat last shown, whose copy the server's service worker may keep for reading offline (granted at install; Firebase already asked for it) |
RECORD_AUDIO, MODIFY_AUDIO_SETTINGS |
dictation: the native composer records (OGG/Opus, sent to the server to transcribe; Android 10 and later), and the page's microphone in pages that record themselves (getUserMedia) |
The web view gets the microphone only for pages on the build's own server,
and nothing else: AppWebChromeClient (fragments/AppWebChromeClient.kt)
denies the camera and any other site, such as an MCP App's frame, and
hands the rest to Hotwire Native, which asks Android for RECORD_AUDIO the
first time someone dictates. The microphone is optional hardware, so
devices without one can still install the app.
This repository is public: keystores, google-services.json and keys never
go in it. Release builds read their signing key from Gradle properties or
environment variables:
| Variable | What |
|---|---|
RELEASE_KEYSTORE_PATH |
the upload keystore file |
RELEASE_KEYSTORE_PASSWORD |
its password |
RELEASE_KEY_ALIAS |
the key's alias |
RELEASE_KEY_PASSWORD |
the key's password |
Without them, assembleRelease makes an unsigned APK and staging builds
are signed with the debug key. Google Play signs what it distributes with
the app signing key it holds (Play App Signing); the key here is the upload
key, and the app signing key's fingerprint is what
/.well-known/assetlinks.json names.
app/src/main/kotlin/com/chatwithwork/app/
ChatWithWorkApplication.kt Hotwire configuration, bridge components, path configuration
main/ MainActivity (the sidebar shell, welcome screen, sign-in, links), view model
sidebar/ the native sidebar: the server's JSON, its rows, the drawer's views
composer/ the native composer: its state, view, "+" sheet, files, and dictation
shortcuts/ the home screen widget, the Quick Settings tile, and where they and the icon's shortcuts lead
fragments/ the web screen (top app bar over the shared web view), page titles, error states
bridge/ the bridge components (native halves) and the action sheet they share
routing/ the app's URLs, Custom Tabs, route decisions for other sites and apps
push/ Firebase Cloud Messaging, the notification channels, the payload, Live Updates
app/src/main/assets/json/path-configuration.json the bundled path configuration
app/src/main/res/ Live Wire's colors, Geist, Material Symbols icons, layouts
app/src/debug/ plain-http config and the push simulator for development
app/src/test/ unit tests (JUnit, Truth)
docs/ the server contract and design notes
- MainActivity owns one navigator, whose root is always a place (a
chat, Decisions, Bots, Settings: the path configuration's
replace_root, and whatever the sidebar opens,Places), with only drill-downs pushed over it, the sidebar (aDrawerLayoutwhose contentSidebarDrawerdraws from the server's JSON, fetched with the web view's cookies), and the welcome screen. It watches the navigator's traffic for what the server says: a 401 or the sign-in page means no session, a recede while signed out means signed in, a link into another organization starts over there. - Web screens are
HotwireWebFragments with the app's own layout: a Material top app bar (the page's<title>, its bridge buttons and overflow menu) over the web view. Modals are full-screen fragments, not Hotwire's bottom sheets, where bridge components that need a top app bar crash. - A page before it paints shows a skeleton of its kind, not a blank
screen: the server's path configuration names the kind (
skeleton: a chat, New chat, a list, Settings, a form, or any other page; docs/server-contract.md, section 5),fragments/ChatSkeleton.ktandPageSkeleton.ktgive each its shape, andSkeletonView.ktdraws them: bones in Live Wire's colors, with one shimmer sweeping across them, still under Remove animations, read as "Loading" ("Loading chat" on a chat) by screen readers. The body crossfades to the page at its first paint: a cold boot's first contentful paint (FirstPaint), or a Turbo visit's render. The title's bone stays until the page names itself, and a chat's composer bone until the page has rendered and the real one can show under it. An error removes it all, and a timeout of 10 seconds does too. It shows only the first time a screen appears, never coming back to one. - Bridge components: the app's own
button,menu,form,share,alert,toast,haptic,theme,context-menu,auth-session,notification-token,sidebarandcomposer, with Joe Masilotti's messages where his component exists, plus hissearchandreview-promptas they are. All 15 are the same names and messages as the iOS app's. - Path configuration: bundled, and replaced at launch by the server's
/configurations/android_v2.json.
The iOS repository's Playground/server.py stands in for the Rails app as
it will be once it implements the server contract: fixture pages in the web
app's own markup, styled by its real stylesheet, with the bridge
controllers and the contract's authentication. With
crmne/chatwithwork-ios checked
out next to this repository:
python3 ../chatwithwork-ios/Playground/server.py --port 8765
adb reverse tcp:8765 tcp:8765
./gradlew installDebug -PbaseUrl=http://localhost:8765Sign in with the prefilled form; everything on its pages is made up.
Versions come from tags: for v1.2.3, build with
-PversionName=1.2.3 -PversionCode=10203 (MAJOR·10000 + MINOR·100 + PATCH),
signing variables set, and upload app/build/outputs/bundle/release/app-release.aab
to Google Play:
./gradlew bundleRelease -PversionName=1.2.3 -PversionCode=10203script/bundle-release v1.2.3 does both, with the upload key from
1Password (script/upload-key makes it once).
docs/google-play.md has the rest: the listing and
screenshots in fastlane/, the Data safety answers, the content rating,
and the demo login for review.
- Geist by Vercel, under the SIL Open Font
License 1.1 (
app/src/main/assets/licenses/geist-OFL.txt), subset to Latin. - Material Symbols by Google, under the Apache License 2.0: the icon drawables and, through bridge-components, the symbol font the bridge buttons draw from.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. Unless you explicitly state otherwise, any contribution you intentionally submit for inclusion in this project, as defined in the Apache-2.0 license, is dual licensed as above, without any additional terms or conditions.
"Chat with Work" and its logo are trademarks of Plenty UG. The license
covers the code, not the name or the logo in app/src/main/res.
See SECURITY.md for how to report a vulnerability.