Thin Kotlin/WebView shell for Omnigent. Like the Electron app and the iOS
shell (web/ios), this target loads the server-served web UI instead of
shipping a duplicate copy of the SPA. It is a native shell, not a rewrite.
Open web/android in Android Studio Meerkat (AGP 9.1+) and run the app
configuration on an API 36 emulator. Requires JDK 17 and the Android SDK
(compileSdk 36, targetSdk 36, minSdk 28).
Debug builds permit cleartext (http://) to localhost and private-range hosts
via res/xml/network_security_config.xml for local development; release builds
keep the platform default (HTTPS only), mirroring the iOS
NSAllowsArbitraryLoadsInWebContent debug-only posture.
The same web/ bundle runs in a browser tab, the Electron shell, the iOS
WKWebView shell, and this Android WebView. Detection is feature-based at
runtime via window.omnigentNative — see web/src/lib/nativeBridge.ts. This
shell injects that object with kind: "android"; the web layer needs no
per-feature branching beyond the kind discriminator (isAndroidShell()).
The web→native transport is a WebViewCompat.addWebMessageListener channel
(OmnigentBridgeListener) origin-allowlisted to the pinned server and
gated on isMainFrame, rather than addJavascriptInterface. This is the
structural equivalent of the iOS bridge's frame-origin + isMainFrame check:
the transport object is never delivered to a sandboxed / cross-origin
agent-HTML iframe, so an injected artifact can't reach the native surface.
Provides native setup chrome (server entry + recent servers via
ConnectActivity), WebView loading, foreground local notifications with tap
routing back into the SPA, a best-effort app badge, edge-to-edge inset plumbing
(measured insets injected as --omnigent-android-safe-area-*, consumed by the
web inset system), correct system-back / predictive-back handling, file
downloads — including blob: / data: exports via a fetch→base64→MediaStore
bridge, which closes omnigent-ai#969 (the iOS shell drops these) —
file uploads (<input type=file> via WebChromeClient.onShowFileChooser),
and microphone capture for voice input (onPermissionRequest, granted to
the pinned origin only, with a runtime RECORD_AUDIO request).
These are iOS-only native chrome; the SPA already renders its own equivalents when the bridge methods are absent, so the Android shell omits them for now:
- Interactive sidebar edge-swipe drawer. Not portable: on Android 10+ the
system back gesture owns both screen edges, and
View.setSystemGestureExclusionRects()does not apply to it. The sidebar opens from the in-page hamburger, exactly as in a browser tab. - Native floating server switcher and Chat/Terminal bar. Rendered in-page by the SPA.
Organizations can preconfigure server URLs so users don't type one. The app
publishes an Android managed
configuration
(app/src/main/res/xml/app_restrictions.xml) with a single key, which any EMM
(Intune, Jamf, Workspace ONE, Google Workspace, Android Management API) can push
to enrolled devices:
| Key | Type | Value |
|---|---|---|
serverUrls |
string | Server URLs, comma- or newline-separated, most preferred first. |
{ "serverUrls": "https://omnigent.corp.example.com" }Behaviour (ManagedConfig + ServerStore):
- The URLs are offered, listed ahead of the user's recent servers on the connect screen and in the server switcher. The user still taps one to connect — this is true for a single URL as much as for several.
- The app never auto-connects to a preset and never skips the connect screen, so a policy can't silently move someone onto a different server.
- Presets are not a lock either: a user can still type any other server, and the one they picked stays current.
- A preset is never written to the app's prefs, so an admin's later edit is picked up the next time the list is shown.
- Unparseable entries are dropped; an entry without a scheme gets
https://; same-origin duplicates collapse; the list is capped at 8.
To test without an EMM, use Google's Test DPC on an emulator with no accounts (a wiped AVD):
adb install -r TestDPC_<ver>.apk # github.com/googlesamples/android-testdpc releases
adb shell dpm set-device-owner com.afwsamples.testdpc/.DeviceAdminReceiverThen Test DPC → Managed configurations → pick Omnigent → Load manifest
restrictions (this renders our schema, confirming the manifest wiring) → set
serverUrls → Save. Verify the policy actually landed with:
adb shell dumpsys device_policy | grep serverUrlsA physical device that already has a corporate work profile can't be used for this: Test DPC can't take over an existing managed profile, and device-owner mode requires a device with no accounts.
iOS has no equivalent yet; when it lands it should reuse the serverUrls key
verbatim via Managed App Configuration.
- App badge count. Android has no universal numeric badge API. We set
NotificationCompat.setNumber()(shown by some launchers; AOSP/Pixel shows only a dot) and treat the notification dot as the guaranteed surface.setBadgeCount(0)is a no-op — we do not cancel notifications to clear a badge.
Gradle assembles a release APK/AAB. Google Play restricts "WebView of a website" apps, so the initial channel is direct APK / F-Droid; a user-configured server client is a stronger Play case but review is unpredictable for this category.
bundleRelease signs the artifact when signing credentials are available;
without them the release build is left unsigned so debug builds still work.
Credentials come from either a gitignored keystore.properties (copy
keystore.properties.example) or, for CI, these environment variables:
OMNIGENT_KEYSTORE_FILE— path to the upload keystoreOMNIGENT_KEYSTORE_PASSWORDOMNIGENT_KEY_ALIASOMNIGENT_KEY_PASSWORD
Create the upload keystore once and back it up (Play App Signing then manages the app signing key):
keytool -genkeypair -v -keystore omnigent-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias omnigent-uploadBuild the Play-ready App Bundle (Play requires an .aab, not an APK); bump
versionCode in app/build.gradle.kts before each upload:
./gradlew bundleRelease # → app/build/outputs/bundle/release/app-release.aabAfter the first release is uploaded manually (Google blocks the Play API until
an app has one human upload), ./gradlew publishReleaseBundle builds the signed
AAB and uploads it to the internal track. It needs a Google Play
service-account key:
- In Google Cloud, create a service account and a JSON key.
- In Play Console → Users & permissions, invite that service account and grant it release permissions.
- Point
PLAY_SERVICE_ACCOUNT_JSONat the JSON, or drop it atweb/android/play-credentials.json(both gitignored).
export PLAY_SERVICE_ACCOUNT_JSON=/path/to/play-credentials.json
./gradlew publishReleaseBundle # signs + uploads to the internal trackThe publish tasks are inert when no credentials file is present, so ordinary
builds are unaffected. Bump versionCode before each publish (Play rejects a
reused code). Change the target track via track.set(...) in
app/build.gradle.kts (internal → alpha → beta → production).
Status: builds clean —
gradlew :app:assembleDebug :app:lintDebugproduces a debug APK with 0 lint errors (JDK 17, Gradle 9.3 wrapper,compileSdk 36). Implementation for omnigent-ai#1604; not yet exercised on a device (no runtime/instrumented testing here), so treat device behavior as unverified.