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 868182907d..d6b3b878c5 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) ({ setNetworkMode(!useTor) }) 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)
+ }
/** Persists the new routing choice in the main process, then relaunches this screen to apply it. */
private fun setNetworkMode(newUseTor: Boolean) {
diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml
index 74b715186f..c5f8ed7576 100644
--- a/nappletHost/src/main/res/values/strings.xml
+++ b/nappletHost/src/main/res/values/strings.xml
@@ -31,4 +31,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