feat(napplets): prefetch blobs to disk on render + loading/unavailable screen

Faster opens: while an nApplet/nSite card is on screen, eagerly download +
sha256-verify all of its blobs (Tor-routed) into a shared content-addressed
cache, so tapping Open serves from disk. Prefetch is de-duplicated (in-flight +
on-disk) and cancellation-aware (stops when the card scrolls away).

- New :nappletHost pieces: NappletBlobCache (content-addressed, multi-process-safe
  atomic store, replacing the single-process OkHttp DiskLruCache), NappletBlobHttp
  (shared Tor OkHttp + size-capped download), NappletBlobPrefetcher.
- NappletContentServer now reads the shared cache first, falling back to a Tor
  download that refills it; still re-verifies every blob on serve.
- Cards trigger prefetch via a LaunchedEffect in the StaticWebsite render fns.

Nicer launch: NappletHostActivity probes the index (resolving + caching it)
behind a loading screen (monogram + title + spinner), then shows the app, or a
clear "couldn't load — try again" screen if the publisher's servers are offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 21:32:48 +00:00
parent 4b838fec72
commit 31bd34e838
7 changed files with 414 additions and 69 deletions
@@ -21,14 +21,18 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
@@ -41,6 +45,8 @@ fun RenderRootNappletEvent(
val event = baseNote.event as? RootNappletEvent ?: return
val context = LocalContext.current
PrefetchManifestBlobs(event.paths(), event.servers())
StaticWebsiteCard(
title = event.title(),
description = event.description(),
@@ -69,6 +75,8 @@ fun RenderNamedNappletEvent(
val event = baseNote.event as? NamedNappletEvent ?: return
val context = LocalContext.current
PrefetchManifestBlobs(event.paths(), event.servers())
StaticWebsiteCard(
title = event.title(),
description = event.description(),
@@ -96,6 +104,8 @@ fun RenderRootSiteEvent(
val event = baseNote.event as? RootSiteEvent ?: return
val context = LocalContext.current
PrefetchManifestBlobs(event.paths(), event.servers())
StaticWebsiteCard(
title = event.title(),
description = event.description(),
@@ -133,6 +143,8 @@ fun RenderNamedSiteEvent(
val event = baseNote.event as? NamedSiteEvent ?: return
val context = LocalContext.current
PrefetchManifestBlobs(event.paths(), event.servers())
StaticWebsiteCard(
title = event.title(),
description = event.description(),
@@ -160,3 +172,22 @@ fun RenderNamedSiteEvent(
},
)
}
/**
* While a static-site / napplet card is on screen, eagerly download + verify all of its blobs into the
* shared content-addressed cache (Tor-routed), so tapping Open launches instantly. De-duplicated and
* cancellation-aware — it stops when the card scrolls out of composition.
*/
@Composable
private fun PrefetchManifestBlobs(
paths: List<PathTag>,
servers: List<String>,
) {
val context = LocalContext.current
LaunchedEffect(paths, servers) {
runCatching {
val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
NappletBlobPrefetcher.prefetch(paths, servers, context.cacheDir, torPort)
}
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
/**
* A content-addressed, on-disk cache for verified Blossom blobs, shared across the app's processes.
*
* Blobs are keyed by their sha256 and written atomically (temp file + rename), so the store needs no
* journal and is safe for the **main** process (the prefetcher) and the **`:napplet`** process (the
* content server) to read/write concurrently — unlike OkHttp's `DiskLruCache`, which is single-process
* only. Both processes derive the same directory from the app's shared `cacheDir`.
*
* Every [put] re-hashes the bytes and only stores them if they match the key, so the store can only
* ever hold correct content; a caller may still re-verify on read (the resolver does).
*/
class NappletBlobCache(
private val dir: File,
) {
fun has(sha256: String): Boolean = fileFor(sha256).isFile
fun get(sha256: String): ByteArray? = fileFor(sha256).takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() }
/** Stores [bytes] under [sha256] iff they actually hash to it. No-op if already present or mismatched. */
fun put(
sha256: String,
bytes: ByteArray,
) {
val target = fileFor(sha256)
if (target.isFile) return
if (sha256(bytes).toHexKey() != sha256.lowercase()) return // verify-on-write: never cache wrong content
runCatching {
dir.mkdirs()
val tmp = File(dir, "$sha256.tmp.${System.nanoTime()}")
tmp.writeBytes(bytes)
if (!tmp.renameTo(target)) tmp.delete()
}
}
/** Best-effort eviction: if the store exceeds [maxBytes], delete oldest blobs until under it. */
fun trimToSize(maxBytes: Long) {
runCatching {
val files = dir.listFiles()?.filter { it.isFile && !it.name.contains(".tmp.") } ?: return
var total = files.sumOf { it.length() }
if (total <= maxBytes) return
files.sortedBy { it.lastModified() }.forEach { f ->
if (total <= maxBytes) return
total -= f.length()
f.delete()
}
}
}
private fun fileFor(sha256: String) = File(dir, sha256.lowercase())
companion object {
const val DEFAULT_MAX_BYTES = 256L * 1024 * 1024
/** The shared cache directory, identical across processes since they share the app's cacheDir. */
fun dirFor(cacheDir: File) = File(cacheDir, "napplet-blobs")
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.InetSocketAddress
import java.net.Proxy
/**
* Shared Blossom-blob HTTP fetcher, used by both the sandbox content server and the main-process
* prefetcher so blobs always travel the same Tor-routed path. No OkHttp disk cache here — durability
* is the content-addressed [NappletBlobCache], which (unlike OkHttp's journaled cache) is multi-process
* safe.
*/
object NappletBlobHttp {
const val MAX_BLOB_BYTES = 20L * 1024 * 1024
/** An OkHttp client routed through the Tor SOCKS proxy when [proxyPort] > 0, else direct. */
fun client(proxyPort: Int): OkHttpClient {
val builder = OkHttpClient.Builder()
if (proxyPort > 0) builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", proxyPort)))
return builder.build()
}
/** Downloads [url], or null on error / when the body exceeds [MAX_BLOB_BYTES] (declared length). */
fun download(
client: OkHttpClient,
url: String,
): ByteArray? =
try {
client
.newCall(
Request
.Builder()
.url(url)
.get()
.build(),
).execute()
.use { r ->
val declared = r.body.contentLength()
if (!r.isSuccessful || declared > MAX_BLOB_BYTES) {
null
} else {
r.body.bytes().takeIf { it.size <= MAX_BLOB_BYTES }
}
}
} catch (e: Exception) {
Log.w("NappletBlobHttp", "Blob fetch failed for $url", e)
null
}
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.coroutineContext
/**
* Warms the shared [NappletBlobCache] with a manifest's blobs ahead of launch, so opening a napplet /
* nSite is instant (every file already verified on disk). Called from the browse/feed cards when they
* render. Each blob is downloaded once (in-flight + on-disk de-duplicated), verified against the
* manifest hash, and stored content-addressed — exactly what the on-device host serves.
*/
object NappletBlobPrefetcher {
// De-dupes blobs already being fetched by another visible card in this process.
private val inFlight = ConcurrentHashMap.newKeySet<String>()
/**
* Downloads every not-yet-cached blob in [paths] from [servers] (Tor-routed via [proxyPort]) into
* the shared cache under [cacheDir]. Suspends on [Dispatchers.IO]; cancellation-aware so it stops
* cleanly when the card scrolls out of composition.
*/
suspend fun prefetch(
paths: List<PathTag>,
servers: List<String>,
cacheDir: File,
proxyPort: Int,
) {
if (paths.isEmpty() || servers.isEmpty()) return
withContext(Dispatchers.IO) {
val cache = NappletBlobCache(NappletBlobCache.dirFor(cacheDir))
val client = NappletBlobHttp.client(proxyPort)
for (path in paths) {
coroutineContext.ensureActive()
val hash = path.hash.lowercase()
if (cache.has(hash) || !inFlight.add(hash)) continue
try {
for (url in StaticSiteResolver.candidateUrls(servers, hash)) {
coroutineContext.ensureActive()
val bytes = NappletBlobHttp.download(client, url) ?: continue
if (StaticSiteResolver.verify(bytes, hash)) {
cache.put(hash, bytes)
break
}
}
} catch (e: CancellationException) {
throw e
} finally {
inFlight.remove(hash)
}
}
cache.trimToSize(NappletBlobCache.DEFAULT_MAX_BYTES)
}
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.napplethost
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
@@ -29,20 +28,16 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolutio
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import kotlinx.coroutines.runBlocking
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
/**
* Serves the napplet sandbox's content over the internal `https://napplet.local` origin: the trusted
* shell page, and the manifest's blobs — each **sha256-verified** by [StaticSiteResolver] before it
* leaves this class. Everything else 404s. Blobs are fetched through the user's Tor proxy (the applet
* has no direct network) and disk-cached; because they are content-addressed and re-verified on every
* serve, a stale or poisoned cache entry can never be served.
* leaves this class. Everything else 404s. Blobs come from the shared content-addressed
* [NappletBlobCache] (warmed by the prefetcher when the card was on screen, so opening is instant);
* a cache miss falls back to a Tor-routed download that re-fills the cache. Because blobs are
* content-addressed and re-verified on every serve, a stale or poisoned cache entry can never be served.
*
* This is the host's resource edge, kept separate from the Activity lifecycle and the broker bridge:
* given a [WebResourceRequest] it returns the [WebResourceResponse] (with the right CSP headers) or
@@ -56,27 +51,21 @@ class NappletContentServer(
private val shellHtmlBytes: ByteArray,
private val shimJs: String,
) {
private val http = buildHttpClient(proxyPort, cacheDir)
private val cache = NappletBlobCache(NappletBlobCache.dirFor(cacheDir))
private val http = NappletBlobHttp.client(proxyPort)
private val fetch: BlobFetcher = { url ->
try {
http
.newCall(
Request
.Builder()
.url(url)
.get()
.build(),
).execute()
.use { r ->
if (r.isSuccessful) r.body.bytes() else null
}
} catch (e: Exception) {
Log.w(TAG, "Blob fetch failed for $url", e)
null
}
val hash = url.substringAfterLast('/').lowercase()
cache.get(hash) ?: NappletBlobHttp.download(http, url)?.also { cache.put(hash, it) }
}
/**
* Resolves [requestPath] to a verified blob (or PathNotInManifest / Unresolvable). Used by the host
* to probe availability for the loading screen before showing the WebView. Warms the cache as a
* side effect, so the subsequent WebView request serves from disk.
*/
fun resolve(requestPath: String): StaticSiteResolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) }
/**
* Serves the trusted shell or a verified app blob for a GET to our origin; 404s anything else on
* the origin, and returns null (defer to the WebView) for non-GET or off-origin requests.
@@ -158,38 +147,6 @@ class NappletContentServer(
return injected.encodeToByteArray()
}
/**
* Routes blob fetches through the user's Tor SOCKS proxy when one is active (port > 0), and
* caches them on disk. Blobs are content-addressed (`<server>/<sha256>`) and therefore
* immutable, so a long-lived forced cache is safe — and the resolver re-verifies every blob's
* sha256 on the way out regardless, so a stale/poisoned cache entry can never be served.
*/
private fun buildHttpClient(
port: Int,
cacheDir: File,
): OkHttpClient {
val builder = OkHttpClient.Builder()
if (port > 0) {
builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
}
runCatching {
builder.cache(Cache(File(cacheDir, "napplet-blobs"), BLOB_CACHE_BYTES))
builder.addNetworkInterceptor { chain ->
val response = chain.proceed(chain.request())
if (response.isSuccessful) {
response
.newBuilder()
.header("Cache-Control", "public, max-age=31536000, immutable")
.removeHeader("Pragma")
.build()
} else {
response
}
}
}
return builder.build()
}
private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0)))
private fun splitContentType(contentType: String): Pair<String, String> {
@@ -199,9 +156,4 @@ class NappletContentServer(
?: if (mime.startsWith("text/") || mime.endsWith("javascript") || mime.endsWith("json")) "utf-8" else ""
return mime to charset
}
companion object {
private const val TAG = "NappletContentServer"
private const val BLOB_CACHE_BYTES = 50L * 1024 * 1024
}
}
@@ -41,7 +41,10 @@ import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
@@ -56,7 +59,14 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.napplethost.R
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
@@ -107,6 +117,10 @@ class NappletHostActivity : ComponentActivity() {
// Keyboard/command actions the applet bound via keys.registerAction; matched in dispatchKeyEvent.
private val keyActions = NappletKeyActions()
// Swaps between the loading screen, the applet WebView, and the "unavailable" screen.
private val contentFrame by lazy { FrameLayout(this) }
private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val brokerConnection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -148,17 +162,14 @@ class NappletHostActivity : ComponentActivity() {
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim)
webView = WebView(this)
hardenWebView(webView)
// Persistent trusted chrome: a sandbox bar the applet can't draw over (anti-phishing) showing
// the napplet's name and a tap-to-see "what it can access". Below it, the applet's WebView.
// Persistent trusted chrome (anti-phishing bar the applet can't draw over) over a content
// frame that shows a loading screen → the applet's WebView, or an "unavailable" screen.
val root =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(buildSandboxBar())
addView(buildDivider())
addView(webView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
addView(contentFrame, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
setContentView(root)
// Activities are edge-to-edge by default on recent Android; pad by the system bar and
@@ -169,6 +180,32 @@ class NappletHostActivity : ComponentActivity() {
insets
}
probeAndMount()
}
/**
* Probe whether the app/site's index resolves (downloading + verifying it, which also warms the
* cache) before showing the WebView — so the user sees a loading screen, then either the running
* app or a clear "unavailable" screen with Retry, instead of a blank/white WebView.
*/
private fun probeAndMount() {
contentFrame.removeAllViews()
contentFrame.addView(buildLoadingView())
uiScope.launch {
val available = withContext(Dispatchers.IO) { contentServer.resolve("/") is StaticSiteResolution.Resolved }
if (available) {
mountWebView()
} else {
contentFrame.removeAllViews()
contentFrame.addView(buildErrorView { probeAndMount() })
}
}
}
private fun mountWebView() {
webView = WebView(this)
hardenWebView(webView)
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
webView,
@@ -182,6 +219,8 @@ class NappletHostActivity : ComponentActivity() {
val brokerIntent = Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS)
bindService(brokerIntent, brokerConnection, BIND_AUTO_CREATE)
contentFrame.removeAllViews()
contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
webView.loadUrl(NappletWebContract.SHELL_URL)
}
@@ -205,6 +244,8 @@ class NappletHostActivity : ComponentActivity() {
}
override fun onDestroy() {
uiScope.cancel()
// unbind is in runCatching: if the index never resolved we never bound the broker.
runCatching { unbindService(brokerConnection) }
keyActions.clear()
if (this::webView.isInitialized) {
@@ -394,6 +435,86 @@ class NappletHostActivity : ComponentActivity() {
return true
}
// ---- loading / unavailable screens ----
/** A monogram tile (first letter of the title on a colored rounded square), matching the card. */
private fun monogram(sizeDp: Int): TextView =
TextView(this).apply {
text = barTitle().trim().take(1).uppercase()
setTextColor(resolveThemeColor(android.R.attr.textColorPrimaryInverse))
textSize = (sizeDp / 2.4f)
gravity = Gravity.CENTER
val bg =
android.graphics.drawable.GradientDrawable().apply {
cornerRadius = dp(18).toFloat()
setColor(resolveThemeColor(android.R.attr.colorPrimary))
}
background = bg
layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp))
}
private fun centeredColumn(): LinearLayout =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(dp(32), dp(32), dp(32), dp(32))
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
}
private fun buildLoadingView(): View =
centeredColumn().apply {
addView(monogram(72))
addView(spacer(dp(20)))
addView(
TextView(this@NappletHostActivity).apply {
text = barTitle()
setTextColor(resolveThemeColor(android.R.attr.textColorPrimary))
textSize = 20f
gravity = Gravity.CENTER
},
)
addView(spacer(dp(20)))
addView(ProgressBar(this@NappletHostActivity))
}
private fun buildErrorView(onRetry: () -> Unit): View =
centeredColumn().apply {
addView(
TextView(this@NappletHostActivity).apply {
text = ""
textSize = 44f
gravity = Gravity.CENTER
},
)
addView(spacer(dp(12)))
addView(
TextView(this@NappletHostActivity).apply {
text = getString(R.string.napplet_unavailable_title, barTitle())
setTextColor(resolveThemeColor(android.R.attr.textColorPrimary))
textSize = 18f
gravity = Gravity.CENTER
},
)
addView(spacer(dp(8)))
addView(
TextView(this@NappletHostActivity).apply {
text = getString(R.string.napplet_unavailable_subtitle)
setTextColor(resolveThemeColor(android.R.attr.textColorSecondary))
textSize = 14f
gravity = Gravity.CENTER
},
)
addView(spacer(dp(20)))
addView(
Button(this@NappletHostActivity).apply {
text = getString(R.string.napplet_unavailable_retry)
setOnClickListener { onRetry() }
},
)
}
private fun spacer(heightPx: Int): View = View(this).apply { layoutParams = LinearLayout.LayoutParams(1, heightPx) }
// ---- trusted sandbox chrome ----
private fun barTitle(): String = title.ifBlank { getString(R.string.napplet_untitled) }
@@ -13,4 +13,9 @@
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>
<!-- Loading / unavailable screens -->
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>
<string name="napplet_unavailable_retry">Try again</string>
</resources>