fix(embed): full-screen round-trip no longer corrupts embedded WebViews

Opening an embedded page full-screen and returning left every embedded WebView
in the :napplet process broken: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads
returning empty (a field that visibly shows text reports value==""), dead
text-selection highlight, and broken IME (caret stuck at 0, can't delete).

Two process-global defects in the full-screen hosts were corrupting the shared
WebView state the embedded surfaces rely on:

- pauseTimers()/resumeTimers() are PROCESS-GLOBAL — they pause/resume JS,
  layout and parsing timers for every WebView in the process. The full-screen
  activities (and the napplet embed pause/resume path) called them on their own
  lifecycle, so returning from full-screen froze the embedded surfaces, which
  have no resume of their own. Replaced with per-WebView onPause()/onResume()
  (which pause only that surface's JS/DOM — still satisfies the napplet
  background-security goal). No process-global timer calls remain.
- WebView.destroy() was called while the WebView was still attached to the
  window, which corrupts the shared multiprocess renderer. Detach (removeView)
  and stopLoading() before destroy() in both full-screen activities.

This also resolves the long-standing "selection highlight dead after a
full-screen excursion" blocker — same root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-25 21:25:55 -04:00
co-authored by Claude Opus 4.8
parent aa520c361f
commit 6995082334
3 changed files with 29 additions and 15 deletions
@@ -35,6 +35,7 @@ import android.os.Messenger
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
@@ -231,7 +232,6 @@ class NappletBrowserActivity : ComponentActivity() {
super.onResume()
if (this::webView.isInitialized) {
webView.onResume()
webView.resumeTimers()
}
resumed = true
heartbeatHandler.removeCallbacks(heartbeat)
@@ -240,8 +240,11 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onPause() {
if (this::webView.isInitialized) {
// Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is
// process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including
// the embedded ones in NappletBrowserService, which have no resume of their own. That left the
// embed frozen (dead page/connection) after returning from a full-screen excursion.
webView.onPause()
webView.pauseTimers()
}
resumed = false
heartbeatHandler.removeCallbacks(heartbeat)
@@ -251,7 +254,17 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onDestroy() {
runCatching { unbindService(brokerConnection) }
if (this::webView.isInitialized) webView.destroy()
if (this::webView.isInitialized) {
// Detach from the view tree BEFORE destroy(). Destroying a WebView while it is still attached to
// the window corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER
// (embedded) WebViews living in this `:napplet` process: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads
// returning empty (`value == ""` on a field that visibly shows text), dead selection-highlight paint,
// and broken IME — all after a full-screen excursion returns to an embed. (`destroy()` requires the
// view to be removed from the hierarchy first; see WebView.destroy() docs.)
webView.stopLoading()
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
super.onDestroy()
}
@@ -321,7 +321,6 @@ class NappletHostActivity : ComponentActivity() {
super.onResume()
if (this::webView.isInitialized) {
webView.onResume()
webView.resumeTimers()
}
// Launching this :napplet-process surface backgrounded the main process; tell the broker to
// hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and
@@ -335,8 +334,10 @@ class NappletHostActivity : ComponentActivity() {
// sign/decrypt/pay request whose consent prompt would surface over (and be confused with)
// Amethyst's own UI. Requests only happen while the user is looking at this napplet.
if (this::webView.isInitialized) {
// webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't
// fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes
// EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume.
webView.onPause()
webView.pauseTimers()
}
// No longer foreground: stop renewing and let the main process resume normal background scaling.
resumed = false
@@ -384,6 +385,11 @@ class NappletHostActivity : ComponentActivity() {
runCatching { unbindService(brokerConnection) }
keyActions.clear()
if (this::webView.isInitialized) {
// Detach before destroy(): destroying an attached WebView corrupts the shared multiprocess
// renderer/network state and breaks the other (embedded) WebViews in this `:napplet` process
// (dead DNS, empty DOM reads, dead selection paint, broken IME). See NappletBrowserActivity.
webView.stopLoading()
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
super.onDestroy()
@@ -172,16 +172,11 @@ class NappletHostService : Service() {
}
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletEmbedContract.MSG_PAUSE ->
tabFor(msg)?.webView?.let {
it.onPause()
it.pauseTimers()
}
NappletEmbedContract.MSG_RESUME ->
tabFor(msg)?.webView?.let {
it.onResume()
it.resumeTimers()
}
// onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call
// pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in
// `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one.
NappletEmbedContract.MSG_PAUSE -> tabFor(msg)?.webView?.onPause()
NappletEmbedContract.MSG_RESUME -> tabFor(msg)?.webView?.onResume()
NappletEmbedContract.MSG_IME_OP -> {
val tab = tabFor(msg) ?: return true
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true