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
35 changes: 35 additions & 0 deletions web/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,41 @@ when the bridge methods are absent, so the Android shell omits them for now:
- **Native floating server switcher** and **Chat/Terminal bar.** Rendered
in-page by the SPA.

## Databricks workspaces

A Databricks workspace serves its own landing page at the root and mounts the
Omnigent SPA at `/omnigent`, so the shell rewrites a **bare** workspace root to
that mount (`Origins.databricksWorkspaceUiUrl`):

- `https://dbc-a5d4177a-49dc.cloud.databricks.com` →
`https://dbc-a5d4177a-49dc.cloud.databricks.com/omnigent`
- `?o=<org>` and any fragment are preserved; a URL that already carries a path
(a deep link, or `/omnigent` itself) is left alone.

The rewrite happens when the pinned server URL is read
(`ServerStore.currentServerUrl`), and in all three `OmnigentWebViewClient`
callbacks that can observe the WebView reaching the root, because no single one
sees every case:

- `shouldOverrideUrlLoading` — link/redirect navigations. Not called for loads
the shell starts itself, nor for POST-driven ones.
- `onPageStarted` — every committed main-frame load, including the login chain's
POST hand-back.
- `doUpdateVisitedHistory` — in-page routing (`pushState`/`replaceState`,
back/forward), which loads nothing and so fires neither of the above.

Bounces are budgeted at one per app-page load (`MAX_ROOT_BOUNCES`): if a
workspace answers `/omnigent` with a redirect back to the root, the user stays
on the root instead of looping, and a successful app page load re-arms the
budget. They're also posted to the main looper — a `loadUrl` issued while
WebView is committing a navigation can be dropped.

Host matching is by domain (`*.databricks.com`, `*.azuredatabricks.net`) — no
probe request. `*.databricksapps.com` is excluded: Apps serve their own app at
the root and have no workspace mount. Note the desktop and iOS shells still
expand to `/ml/omnigents` after a `server: databricks` probe; that divergence is
intentional for now (see the comment in `web/electron/src/url.js`).

## Managed configuration (org-preset servers)

Organizations can preconfigure server URLs so users don't type one. The app
Expand Down
4 changes: 2 additions & 2 deletions web/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ android {
applicationId = "ai.omnigent.android"
minSdk = 28
targetSdk = 36
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 7
versionName = "0.1.2"
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 9
versionName = "0.1.3"

// Instrumented (androidTest) runner — required for UI Automator / Espresso
// screenshot tests. Mirrors the androidx.test stable line pinned below.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ package ai.omnigent.android
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

/**
* Signals [onPageReady] once a pinned-origin page finishes loading and decides
* where the login flow runs: inline for servers matching [usesInWebViewAuth],
* otherwise handed to the system browser via [onLoginRequired].
* otherwise handed to the system browser via [onLoginRequired]. A landing on a
* bare Databricks workspace root is bounced to the workspace's `/omnigent`
* mount (see [workspaceRootTarget]).
*
* The facade is normally registered with `addDocumentStartJavaScript` in
* `MainActivity`. Older WebViews that support the message listener but not
Expand All @@ -22,6 +26,12 @@ class OmnigentWebViewClient(
private val onPageReady: (url: String?) -> Unit,
private val onLoginRequired: () -> Unit,
) : WebViewClient() {
// Bare-root -> /omnigent bounces since the last app page loaded; see
// workspaceRootTarget for why they're capped.
private var rootBounces = 0

private val mainHandler = Handler(Looper.getMainLooper())

override fun onPageStarted(
view: WebView,
url: String?,
Expand Down Expand Up @@ -51,14 +61,47 @@ class OmnigentWebViewClient(
onLoginRequired()
return
}

// Workspace roots are caught here too, not only in
// shouldOverrideUrlLoading: that callback is skipped for loads the shell
// starts itself and for POST-driven navigations — which is how the
// Databricks login chain hands the session back (a form POST landing on
// the workspace root). onPageStarted sees every main-frame load.
if (origin == pinned) {
val target = workspaceRootTarget(url) ?: return
view.stopLoading()
bounce(view, target)
}
}

/**
* In-page navigation: the SPA swapped the URL with `pushState` /
* `replaceState`, or the user moved through history. No page is loaded, so
* neither [shouldOverrideUrlLoading] nor [onPageStarted] runs — this is the
* only callback that observes it, and the only way to catch the user routing
* client-side back to the workspace root.
*/
override fun doUpdateVisitedHistory(
view: WebView,
url: String?,
isReload: Boolean,
) {
super.doUpdateVisitedHistory(view, url, isReload)
if (originOf(url) != pinnedOrigin()) return
val target = workspaceRootTarget(url) ?: return
bounce(view, target)
}

override fun onPageFinished(
view: WebView,
url: String?,
) {
super.onPageFinished(view, url)
if (originOf(url) == pinnedOrigin() && shouldInjectBridgeAtPageReady()) {
val onPinnedOrigin = originOf(url) == pinnedOrigin()
// An app page loaded, so the mount works: re-arm the bounce budget for
// the next time the user lands back on the workspace root.
if (onPinnedOrigin && databricksWorkspaceUiUrl(url) == null) rootBounces = 0
if (onPinnedOrigin && shouldInjectBridgeAtPageReady()) {
view.evaluateJavascript(NativeBridgeScript.source) { onPageReady(url) }
return
}
Expand All @@ -82,10 +125,15 @@ class OmnigentWebViewClient(
return true
}

// Same-origin app pages load in the WebView.
// Same-origin app pages load in the WebView, except a landing on the bare
// workspace root, which belongs to Databricks rather than the app.
val origin = originOf(url.toString())
val pinned = pinnedOrigin()
if (origin == pinned) return false
if (origin == pinned) {
val target = workspaceRootTarget(url.toString()) ?: return false
bounce(view, target)
return true
}

authLog("off-origin nav $origin gesture=${request.hasGesture()}")

Expand All @@ -112,4 +160,38 @@ class OmnigentWebViewClient(
}
return true
}

/**
* The `/omnigent` URL to bounce to when [url] is a bare Databricks workspace
* root — the workspace's own landing page rather than the app — or null when
* there's nothing to do.
*
* Budgeted, and spent by the caller that acts on it: a workspace that answers
* `/omnigent` with a redirect back to the root (e.g. the mount isn't enabled
* there) would otherwise loop forever. One bounce per app page load, so a
* failed bounce leaves the user on the workspace root and [onPageFinished]
* re-arms the budget as soon as an app page loads.
*/
private fun workspaceRootTarget(url: String?): String? {
val target = databricksWorkspaceUiUrl(url) ?: return null
if (rootBounces >= MAX_ROOT_BOUNCES) return null
rootBounces++
return target
}

/**
* Posted, never loaded inline: a loadUrl issued while WebView is committing a
* navigation can be dropped. Not view.post() — that queues until the view is
* attached.
*/
private fun bounce(
view: WebView,
target: String,
) {
mainHandler.post { view.loadUrl(target) }
}

private companion object {
const val MAX_ROOT_BOUNCES = 1
}
}
40 changes: 40 additions & 0 deletions web/android/app/src/main/java/ai/omnigent/android/Origins.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,46 @@ fun usesInWebViewAuth(origin: String?): Boolean {
return IN_WEBVIEW_AUTH_DOMAINS.any { host == it || host.endsWith(".$it") }
}

/** Path the Omnigent SPA is mounted at inside a Databricks workspace. */
const val WORKSPACE_UI_PATH = "/omnigent"

/**
* Databricks domains that serve a workspace, and therefore mount the SPA at
* [WORKSPACE_UI_PATH]. `databricksapps.com` is deliberately absent: Apps share
* the workspace login story (see [IN_WEBVIEW_AUTH_DOMAINS]) but serve their own
* app at the root, with no workspace mount to redirect to.
*/
private val WORKSPACE_DOMAINS = listOf("databricks.com", "azuredatabricks.net")

/** True when [host] is, or sits under, a Databricks workspace domain. */
private fun isDatabricksWorkspaceHost(host: String?): Boolean {
val normalized = host?.lowercase() ?: return false
return WORKSPACE_DOMAINS.any { normalized == it || normalized.endsWith(".$it") }
}

/**
* The workspace-UI URL for a bare Databricks workspace root, or null when [url]
* is anything else — a non-workspace host, or a URL that already carries a path
* (a deliberate deep link we must not override).
*
* A bare workspace root shows the Databricks landing page, not Omnigent, so the
* shell rewrites it to [WORKSPACE_UI_PATH]. Query and fragment survive because
* `?o=<org>` selects which workspace the request lands in.
*/
fun databricksWorkspaceUiUrl(url: String?): String? {
val uri = url?.let(Uri::parse) ?: return null
if (!isHttpScheme(uri.scheme)) return null
if (!isDatabricksWorkspaceHost(uri.host)) return null
val path = uri.path.orEmpty()
if (path.isNotEmpty() && path != "/") return null
val origin = originOf(url) ?: return null
return buildString {
append(origin).append(WORKSPACE_UI_PATH)
uri.encodedQuery?.let { append('?').append(it) }
uri.encodedFragment?.let { append('#').append(it) }
}
}

/**
* Normalize user-entered server text into a loadable URL, or null if it isn't a
* usable http(s) address. Adds a default `https://` scheme when omitted and
Expand Down
12 changes: 10 additions & 2 deletions web/android/app/src/main/java/ai/omnigent/android/ServerStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,16 @@ class ServerStore(
*/
fun hasServer(): Boolean = !storedServerUrl().isNullOrBlank()

/** The current server, or the emulator-loopback debug default if unset. */
fun currentServerUrl(): String = storedServerUrl() ?: DEFAULT_DEBUG_SERVER
/**
* The current server, or the emulator-loopback debug default if unset. A bare
* Databricks workspace root resolves to its `/omnigent` mount so the shell
* lands on the app instead of the workspace landing page. Expanded on read,
* not on write, so the stored/offered entry stays what the user typed.
*/
fun currentServerUrl(): String {
val stored = storedServerUrl() ?: return DEFAULT_DEBUG_SERVER
return databricksWorkspaceUiUrl(stored) ?: stored
}

/**
* The servers to offer in the UI: organization presets first, then recents
Expand Down
Loading
Loading