mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -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
|
||||
}
|
||||
}
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-63
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+127
-6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user