diff --git a/web/android/README.md b/web/android/README.md index a79ae1db32..4123ae0cae 100644 --- a/web/android/README.md +++ b/web/android/README.md @@ -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=` 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 diff --git a/web/android/app/build.gradle.kts b/web/android/app/build.gradle.kts index eb1359018f..97ac2a704d 100644 --- a/web/android/app/build.gradle.kts +++ b/web/android/app/build.gradle.kts @@ -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. diff --git a/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt b/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt index 2712f334b6..d1b5722fd4 100644 --- a/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt +++ b/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt @@ -3,6 +3,8 @@ 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 @@ -10,7 +12,9 @@ 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 @@ -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?, @@ -51,6 +61,35 @@ 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( @@ -58,7 +97,11 @@ class OmnigentWebViewClient( 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 } @@ -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()}") @@ -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 + } } diff --git a/web/android/app/src/main/java/ai/omnigent/android/Origins.kt b/web/android/app/src/main/java/ai/omnigent/android/Origins.kt index 266fb53b9f..c5c534095f 100644 --- a/web/android/app/src/main/java/ai/omnigent/android/Origins.kt +++ b/web/android/app/src/main/java/ai/omnigent/android/Origins.kt @@ -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=` 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 diff --git a/web/android/app/src/main/java/ai/omnigent/android/ServerStore.kt b/web/android/app/src/main/java/ai/omnigent/android/ServerStore.kt index 775dfdb233..f640e6ae87 100644 --- a/web/android/app/src/main/java/ai/omnigent/android/ServerStore.kt +++ b/web/android/app/src/main/java/ai/omnigent/android/ServerStore.kt @@ -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 diff --git a/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt b/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt index f099541c00..676e739b4f 100644 --- a/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt +++ b/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt @@ -2,6 +2,7 @@ package ai.omnigent.android import android.content.Context import android.net.Uri +import android.os.Looper import android.webkit.ValueCallback import android.webkit.WebResourceRequest import android.webkit.WebView @@ -13,6 +14,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf @RunWith(RobolectricTestRunner::class) class OmnigentWebViewClientTest { @@ -128,6 +130,126 @@ class OmnigentWebViewClientTest { assertEquals(1, logins) } + @Test + fun `a login chain landing on the workspace root bounces to the mount`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + // The SSO hand-back is a POST navigation, which never reaches + // shouldOverrideUrlLoading — only onPageStarted sees it. + client.onPageStarted(webView, "$DATABRICKS_ORIGIN/", null) + idleMainLooper() + + assertTrue(webView.stopLoadingCalled) + assertEquals("$DATABRICKS_ORIGIN/omnigent", webView.loadedUrl) + } + + @Test + fun `in-page routing to the workspace root bounces to the mount`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + // pushState/replaceState loads nothing, so this is the only callback the + // shell gets for a client-side route change. + client.doUpdateVisitedHistory(webView, "$DATABRICKS_ORIGIN/?o=123", false) + idleMainLooper() + + assertEquals("$DATABRICKS_ORIGIN/omnigent?o=123", webView.loadedUrl) + } + + @Test + fun `in-page routing inside the app is left alone`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + client.doUpdateVisitedHistory(webView, "$DATABRICKS_ORIGIN/omnigent/c/abc", false) + // A foreign origin mid-login must not be treated as an app route either. + client.doUpdateVisitedHistory(webView, IDP_URL, false) + idleMainLooper() + + assertNull(webView.loadedUrl) + } + + @Test + fun `landing on an app page is left alone`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + client.onPageStarted(webView, "$DATABRICKS_ORIGIN/omnigent", null) + idleMainLooper() + + assertFalse(webView.stopLoadingCalled) + assertNull(webView.loadedUrl) + } + + @Test + fun `landing on the workspace root bounces to the omnigent mount`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + val handled = + client.shouldOverrideUrlLoading(webView, request("$DATABRICKS_ORIGIN/?o=123")) + idleMainLooper() + + assertTrue(handled) + assertEquals("$DATABRICKS_ORIGIN/omnigent?o=123", webView.loadedUrl) + } + + @Test + fun `a workspace that bounces the mount back to the root does not loop`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + client.shouldOverrideUrlLoading(webView, request("$DATABRICKS_ORIGIN/")) + idleMainLooper() + webView.loadedUrl = null + + // /omnigent answered with a redirect back to the root: no app page loaded + // in between, so the budget is spent and the root now loads normally. + val handled = client.shouldOverrideUrlLoading(webView, request("$DATABRICKS_ORIGIN/")) + idleMainLooper() + + assertFalse(handled) + assertNull(webView.loadedUrl) + } + + @Test + fun `the budget is re-armed once an app page loads`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(pinnedOrigin = DATABRICKS_ORIGIN) + + client.doUpdateVisitedHistory(webView, "$DATABRICKS_ORIGIN/", false) + idleMainLooper() + client.onPageFinished(webView, "$DATABRICKS_ORIGIN/omnigent") + webView.loadedUrl = null + + // Back to the workspace root later in the session: bounce again. + client.doUpdateVisitedHistory(webView, "$DATABRICKS_ORIGIN/", false) + idleMainLooper() + + assertEquals("$DATABRICKS_ORIGIN/omnigent", webView.loadedUrl) + } + + @Test + fun `app pages and non-databricks roots load untouched`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val databricks = client(pinnedOrigin = DATABRICKS_ORIGIN) + + assertFalse( + databricks.shouldOverrideUrlLoading( + webView, + request("$DATABRICKS_ORIGIN/omnigent/c/a"), + ), + ) + assertFalse( + client().shouldOverrideUrlLoading(webView, request("$PINNED_ORIGIN/")), + ) + assertNull(webView.loadedUrl) + } + + /** Run posted bounces (see the client's mainHandler) before asserting. */ + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + private fun client( shouldInjectBridgeAtPageReady: Boolean = false, pinnedOrigin: String = PINNED_ORIGIN, @@ -163,10 +285,15 @@ class OmnigentWebViewClientTest { var evaluatedScript: String? = null var stopLoadingCalled = false var currentUrl: String? = null + var loadedUrl: String? = null private var callback: ValueCallback? = null override fun getUrl(): String? = currentUrl + override fun loadUrl(url: String) { + loadedUrl = url + } + override fun stopLoading() { stopLoadingCalled = true } diff --git a/web/android/app/src/test/java/ai/omnigent/android/OriginsWorkspaceUiUrlTest.kt b/web/android/app/src/test/java/ai/omnigent/android/OriginsWorkspaceUiUrlTest.kt new file mode 100644 index 0000000000..5ac51cc488 --- /dev/null +++ b/web/android/app/src/test/java/ai/omnigent/android/OriginsWorkspaceUiUrlTest.kt @@ -0,0 +1,68 @@ +package ai.omnigent.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Bare Databricks workspace roots resolve to the `/omnigent` mount. */ +@RunWith(RobolectricTestRunner::class) +class OriginsWorkspaceUiUrlTest { + @Test + fun `expands a bare workspace root`() { + assertEquals( + "https://dbc-a5d4177a-49dc.cloud.databricks.com/omnigent", + databricksWorkspaceUiUrl("https://dbc-a5d4177a-49dc.cloud.databricks.com"), + ) + assertEquals( + "https://gtm-ai-agent.cloud.databricks.com/omnigent", + databricksWorkspaceUiUrl("https://gtm-ai-agent.cloud.databricks.com/"), + ) + assertEquals( + "https://adb-123.azuredatabricks.net/omnigent", + databricksWorkspaceUiUrl("https://adb-123.azuredatabricks.net"), + ) + } + + @Test + fun `keeps the workspace selector and fragment`() { + assertEquals( + "https://ws.cloud.databricks.com/omnigent?o=123#/c/abc", + databricksWorkspaceUiUrl("https://ws.cloud.databricks.com/?o=123#/c/abc"), + ) + } + + @Test + fun `normalizes casing and the default port`() { + assertEquals( + "https://ws.cloud.databricks.com/omnigent", + databricksWorkspaceUiUrl("https://WS.Cloud.Databricks.COM:443"), + ) + } + + @Test + fun `leaves a url that already carries a path alone`() { + // Already on the mount, or a deliberate deep link — never override it. + assertNull(databricksWorkspaceUiUrl("https://ws.cloud.databricks.com/omnigent")) + assertNull(databricksWorkspaceUiUrl("https://ws.cloud.databricks.com/omnigent/c/abc")) + assertNull(databricksWorkspaceUiUrl("https://ws.cloud.databricks.com/ml/dashboard")) + } + + @Test + fun `leaves non-workspace hosts alone`() { + // Databricks Apps serve their own app at the root: no workspace mount. + assertNull(databricksWorkspaceUiUrl("https://my-app.aws.databricksapps.com")) + assertNull(databricksWorkspaceUiUrl("https://omnigent.example.com")) + // Lookalike hosts must not match, same dot-boundary rule as the auth list. + assertNull(databricksWorkspaceUiUrl("https://databricks.com.evil.tld")) + assertNull(databricksWorkspaceUiUrl("https://notdatabricks.com")) + } + + @Test + fun `leaves unusable input alone`() { + assertNull(databricksWorkspaceUiUrl(null)) + assertNull(databricksWorkspaceUiUrl("about:blank")) + assertNull(databricksWorkspaceUiUrl("omnigent://ws.cloud.databricks.com")) + } +} diff --git a/web/android/app/src/test/java/ai/omnigent/android/ServerStoreTest.kt b/web/android/app/src/test/java/ai/omnigent/android/ServerStoreTest.kt index 614bb352aa..38e56d469f 100644 --- a/web/android/app/src/test/java/ai/omnigent/android/ServerStoreTest.kt +++ b/web/android/app/src/test/java/ai/omnigent/android/ServerStoreTest.kt @@ -78,6 +78,32 @@ class ServerStoreTest { ) } + @Test + fun `a databricks workspace connects to its omnigent mount`() { + val store = storeWithPresets() + + store.connect("https://dbc-a5d4177a-49dc.cloud.databricks.com") + + assertEquals( + "https://dbc-a5d4177a-49dc.cloud.databricks.com/omnigent", + store.currentServerUrl(), + ) + // The offered entry stays what the user typed. + assertEquals( + listOf("https://dbc-a5d4177a-49dc.cloud.databricks.com"), + store.recentServers(), + ) + } + + @Test + fun `a databricks url with a path is used as given`() { + val store = storeWithPresets() + + store.connect("https://ws.cloud.databricks.com/omnigent") + + assertEquals("https://ws.cloud.databricks.com/omnigent", store.currentServerUrl()) + } + @Test fun `presets are read from managed configuration by default`() { setApplicationRestrictions(