diff --git a/android-client/app/src/main/java/com/navi/client/MainActivity.kt b/android-client/app/src/main/java/com/navi/client/MainActivity.kt index 29dbb48..e13fd85 100644 --- a/android-client/app/src/main/java/com/navi/client/MainActivity.kt +++ b/android-client/app/src/main/java/com/navi/client/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.Build import android.os.Bundle import android.os.Environment +import android.os.Message import android.provider.MediaStore import android.view.KeyEvent import android.webkit.* @@ -83,25 +84,20 @@ private fun setupWebView(prefs: android.content.SharedPreferences) { webView.settings.apply { javaScriptEnabled = true + javaScriptCanOpenWindowsAutomatically = true domStorageEnabled = true cacheMode = WebSettings.LOAD_NO_CACHE mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW mediaPlaybackRequiresUserGesture = false allowFileAccess = true allowContentAccess = true + setSupportMultipleWindows(true) userAgentString = "$userAgentString NaviAndroid/1.0" } webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - val host = request.url.host ?: return false - if (host == serverHost) return false - return try { - startActivity(Intent(Intent.ACTION_VIEW, request.url)) - true - } catch (e: Exception) { - false - } + return if (shouldOpenExternally(request)) openExternal(request.url) else false } override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { @@ -114,6 +110,29 @@ } webView.webChromeClient = object : WebChromeClient() { + override fun onCreateWindow( + view: WebView, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message + ): Boolean { + if (!isUserGesture) return false + + val popup = WebView(this@MainActivity) + popup.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + openExternal(request.url) + view.destroy() + return true + } + } + + val transport = resultMsg.obj as WebView.WebViewTransport + transport.webView = popup + resultMsg.sendToTarget() + return true + } + override fun onShowFileChooser( view: WebView, callback: ValueCallback>, @@ -147,6 +166,39 @@ } } + private fun shouldOpenExternally(request: WebResourceRequest): Boolean { + val uri = request.url + val scheme = uri.scheme?.lowercase(Locale.US) ?: return false + + if (scheme != "http" && scheme != "https") { + return request.hasGesture() + } + + val host = uri.host ?: return false + if (host != serverHost) return true + + if (!request.hasGesture()) return false + + val path = uri.encodedPath ?: return false + if (path.startsWith("/content-viewers/")) return true + if (Regex("^/sessions/[^/]+/files/[^/]+").containsMatchIn(path)) return true + + return uri.getQueryParameter("download") == "1" + } + + private fun openExternal(uri: Uri): Boolean { + return try { + startActivity( + Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + ) + true + } catch (e: Exception) { + false + } + } + private fun openFileChooser(callback: ValueCallback>) { fileChooserCallback = callback diff --git a/docs/android-client.md b/docs/android-client.md index 241d2a4..b5f8d66 100644 --- a/docs/android-client.md +++ b/docs/android-client.md @@ -44,11 +44,16 @@ ## External link handling -`shouldOverrideUrlLoading` intercepts all navigation: -- URL host matches the Navi server → handled by WebView (returns `false`) -- Any other host (external link) → opened via `Intent.ACTION_VIEW` in the system browser (returns `true`) +`shouldOverrideUrlLoading` intercepts navigation: +- Normal Navi app URLs on the configured server host → handled by WebView. +- API, iframe, and artifact preview loads without a user gesture → handled by WebView. +- External hosts → opened via `Intent.ACTION_VIEW` in the system browser. +- User-clicked same-host artifact/file URLs → opened via `Intent.ACTION_VIEW` in the system browser: + - `/content-viewers/...` + - `/sessions/{session_id}/files/{filename}` + - URLs with `download=1` -This means links Navi produces in chat (URLs to external sites, generated HTML pages served by nginx, etc.) open in the user's browser, not inside the app. +This means inline preview cards still render inside the app, but when the user explicitly opens a preview, raw file, download link, or external URL, it leaves the app and opens in the user's browser. ## Platform detection in web client diff --git a/navi/core/planning.py b/navi/core/planning.py index 360b16d..bf5dda8 100644 --- a/navi/core/planning.py +++ b/navi/core/planning.py @@ -271,6 +271,12 @@ "- TOOL: — a single tool call is enough; use exact tool names from the list above\n" "- AGENT: — a bounded subtask needing 3+ tool calls; one subagent handles this ONE step\n" "- SELF — final synthesis or a context-dependent single action only\n\n" + "Planning boundary (critical):\n" + "The plan is an execution contract, not an implementation. It may describe intent, order, executor, " + "inputs, expected outputs, and verification. It must NOT contain implementation code, source snippets, " + "function bodies, CSS/HTML/SQL/Python/JS, patches, exact file contents, or detailed command scripts. " + "Implementation belongs later in tool calls, file edits, terminal/code execution, or final artifacts. " + "A valid plan says what to change and how to verify it, not the code that performs the change.\n\n" "Plan depth:\n" "- simple: 1-3 steps\n" "- medium: 5-9 steps\n"