From b01e1eb6bc603c0e351db861aec8cea713cb88a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:20:54 +0000 Subject: [PATCH] feat(napplet): show top loading bar, keep splash until first paint, log load errors to console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The napplet/nsite host (NappletHostActivity) removed its loading splash the moment the index probe succeeded and then mounted a WebView with no progress tracking at all, so during the seconds the shell + bundle take to load (notably over Tor) the user saw only the WebView's dark colorBackground — a black screen with no sign anything was happening, especially in dark theme. - Add a thin browser-style determinate progress bar pinned to the top edge, driven by WebChromeClient.onProgressChanged and hidden at 100%, to both the napplet/nsite host and the URL browser (NappletBrowserActivity). - Mount the WebView under the loading splash and keep the splash (now opaque) until first paint (onPageCommitVisible) instead of removing it on mount, so there is never a blank/dark gap between probe-success and the shell's first frame. This mirrors the pattern the URL browser already used. - Add a developer console (NappletConsolePanel) to the napplet/nsite host, wired through the existing onConsole hook in NappletControlSheet, and forward the page's console.* output to it. - Surface failed resource fetches (onReceivedError / onReceivedHttpError) as ERROR lines in the console on both hosts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D4iYA4Qf5guWZyKexkcmhb --- .../napplethost/NappletBrowserActivity.kt | 59 +++++++- .../napplethost/NappletHostActivity.kt | 130 +++++++++++++++++- nappletHost/src/main/res/values/strings.xml | 4 + 3 files changed, 188 insertions(+), 5 deletions(-) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index bfc3c5e812..5eef258f37 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -24,6 +24,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.res.ColorStateList import android.graphics.Bitmap import android.net.Uri import android.os.Bundle @@ -40,6 +41,7 @@ import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -94,6 +96,10 @@ class NappletBrowserActivity : ComponentActivity() { private var controlSheet: NappletControlSheet? = null private var consolePanel: NappletConsolePanel? = null + // A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome + // client's onProgressChanged; hidden at 100%. + private val topProgressBar by lazy { buildTopProgressBar() } + // Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/ // unresolved address never enters history. Reset on each main-frame page start. private var pendingMainFrameUrl: String? = null @@ -200,6 +206,8 @@ class NappletBrowserActivity : ComponentActivity() { Gravity.BOTTOM, ), ) + // Added last so the thin loading bar paints above the content (and over the grabber's top edge). + addView(topProgressBar) } setContentView(root) // Pad by the system bars + cutout, but NOT the IME — windowSoftInputMode=adjustResize shrinks the @@ -315,8 +323,15 @@ class NappletBrowserActivity : ComponentActivity() { wv.webChromeClient = BrowserChromeClient() } - /** Captures favicon and console output; the only source of both is the WebChromeClient. */ + /** Captures favicon and console output, and drives the top loading bar; all come from the WebChromeClient. */ private inner class BrowserChromeClient : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + updateLoadProgress(newProgress) + } + override fun onReceivedIcon( view: WebView, icon: Bitmap?, @@ -377,6 +392,15 @@ class NappletBrowserActivity : ComponentActivity() { // A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this // navigation from history. Sub-resource errors are irrelevant to whether the page opened. if (request.isForMainFrame) mainFrameLoadFailed = true + logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty())) + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) } override fun onPageCommitVisible( @@ -658,6 +682,39 @@ class NappletBrowserActivity : ComponentActivity() { addView(ProgressBar(this@NappletBrowserActivity)) } + /** + * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by + * [BrowserChromeClient.onProgressChanged]: visible while the page loads and gone at 100%. + */ + private fun buildTopProgressBar(): ProgressBar = + ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + isIndeterminate = false + visibility = View.GONE + progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP) + } + + /** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */ + private fun updateLoadProgress(progress: Int) { + if (progress >= 100) { + topProgressBar.visibility = View.GONE + } else { + topProgressBar.progress = progress + topProgressBar.visibility = View.VISIBLE + } + } + + /** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */ + private fun logConsoleError( + request: WebResourceRequest, + message: String, + ) { + val panel = consolePanel ?: return + panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) + controlSheet?.updateConsoleCount(panel.entryCount) + } + private fun resolveThemeColor(attr: Int): Int { val tv = android.util.TypedValue() theme.resolveAttribute(attr, tv, true) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index cbd602b5b7..d47f9974e7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -24,6 +24,7 @@ import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection +import android.content.res.ColorStateList import android.net.Uri import android.os.Bundle import android.os.Handler @@ -37,6 +38,9 @@ import android.view.Gravity import android.view.KeyEvent import android.view.View import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -147,6 +151,18 @@ class NappletHostActivity : ComponentActivity() { private val contentFrame by lazy { FrameLayout(this) } private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + // The loading splash (monogram + spinner). Kept on top of the mounted WebView and removed only on + // first paint, so there's never a blank/dark gap between the index probe and the shell's first frame. + private var loadingView: View? = null + + // A thin determinate progress bar pinned to the top edge (browser-style), driven by the + // WebChromeClient's onProgressChanged; hidden at 100%. + private val topProgressBar by lazy { buildTopProgressBar() } + + // Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors. + private var consolePanel: NappletConsolePanel? = null + private var controlSheet: NappletControlSheet? = null + // Set once the WebView has begun loading the shell, so a retry doesn't reload it. private var started = false @@ -268,6 +284,18 @@ class NappletHostActivity : ComponentActivity() { Gravity.TOP, ), ) + addView( + buildConsolePanel(), + FrameLayout + .LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + // Added last so the thin loading bar paints above the content (and over the grabber's top + // edge); it's GONE except while loading, so it never obscures the trusted chrome. + addView(topProgressBar) } setContentView(root) // Activities are edge-to-edge by default on recent Android; pad by the system bar and @@ -295,22 +323,26 @@ class NappletHostActivity : ComponentActivity() { */ private fun probeAndMount() { contentFrame.removeAllViews() - contentFrame.addView(buildLoadingView()) + loadingView = buildLoadingView().also { contentFrame.addView(it) } uiScope.launch { val available = withContext(Dispatchers.IO) { contentServer.resolve("/") is StaticSiteResolution.Resolved } if (available) { mountWebView() } else { contentFrame.removeAllViews() + loadingView = null contentFrame.addView(buildErrorView { probeAndMount() }) } } } private fun mountWebView() { - contentFrame.removeAllViews() (webView.parent as? ViewGroup)?.removeView(webView) - contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + // Mount the WebView UNDER the loading splash (index 0) instead of replacing it: the shell + applet + // bundle still take time to paint (seconds over Tor), and the WebView shows only its dark + // colorBackground until then. The splash stays until the first frame paints (onPageCommitVisible), + // so the user never sees a blank/black screen with no sign that anything is loading. + contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (!started) { started = true webView.loadUrl(NappletWebContract.SHELL_URL) @@ -484,6 +516,7 @@ class NappletHostActivity : ComponentActivity() { webView.overScrollMode = View.OVER_SCROLL_NEVER WebView.setWebContentsDebuggingEnabled(false) webView.webViewClient = NappletWebViewClient() + webView.webChromeClient = NappletWebChromeClient() } /** @@ -529,6 +562,34 @@ class NappletHostActivity : ComponentActivity() { syncBackState() } + // The shell has painted its first frame — drop the loading splash so the running app shows + // through. Null-safe so a later in-app navigation/reload (splash already gone) is a no-op. + override fun onPageCommitVisible( + view: WebView, + url: String, + ) { + loadingView?.let { contentFrame.removeView(it) } + loadingView = null + } + + // Surface failed resource fetches (a missing blob, a verify miss, an off-origin request the + // default-deny CSP blocked) in the console so an nsite/napplet developer can see what broke. + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty())) + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) + } + override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, @@ -550,6 +611,43 @@ class NappletHostActivity : ComponentActivity() { } } + /** Drives the top loading bar and forwards the applet/site's `console.*` output to the console panel. */ + private inner class NappletWebChromeClient : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + updateLoadProgress(newProgress) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val panel = consolePanel ?: return false + panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) + controlSheet?.updateConsoleCount(panel.entryCount) + return true + } + } + + /** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */ + private fun updateLoadProgress(progress: Int) { + if (progress >= 100) { + topProgressBar.visibility = View.GONE + } else { + topProgressBar.progress = progress + topProgressBar.visibility = View.VISIBLE + } + } + + /** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */ + private fun logConsoleError( + request: WebResourceRequest, + message: String, + ) { + val panel = consolePanel ?: return + panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) + controlSheet?.updateConsoleCount(panel.entryCount) + } + // ---- bridge: shell <-> native ---- private fun onShellMessage( @@ -663,6 +761,9 @@ class NappletHostActivity : ComponentActivity() { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER setPadding(dp(32), dp(32), dp(32), dp(32)) + // Opaque so the splash/error screen fully covers the WebView it now overlays (mounted beneath + // it until first paint) instead of letting the dark, not-yet-painted page show through. + setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) } @@ -741,7 +842,28 @@ class NappletHostActivity : ComponentActivity() { torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null, onInfo = { showAccessDialog() }, - ) + onConsole = { consolePanel?.toggle() }, + ).also { controlSheet = it } + + private fun buildConsolePanel(): View = + NappletConsolePanel(this).also { + it.onClearCallback = { controlSheet?.updateConsoleCount(0) } + consolePanel = it + } + + /** + * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by + * [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone + * at 100%, so a slow load (e.g. a large bundle over Tor) shows progress instead of a blank dark WebView. + */ + private fun buildTopProgressBar(): ProgressBar = + ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + isIndeterminate = false + visibility = View.GONE + progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP) + } /** * Explains the site's current network routing and offers to switch it. Switching persists the diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index ddd4b168d4..e0f8dceaae 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -37,4 +37,8 @@ Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again. Try again + + + Failed to load (%1$d): %2$s + HTTP %1$d %2$s