diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt index 79118e9c6b..0248409a46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -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, + servers: List, +) { + val context = LocalContext.current + LaunchedEffect(paths, servers) { + runCatching { + val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + NappletBlobPrefetcher.prefetch(paths, servers, context.cacheDir, torPort) + } + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt new file mode 100644 index 0000000000..9c2afc23c2 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -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") + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobHttp.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobHttp.kt new file mode 100644 index 0000000000..7c3ff52557 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobHttp.kt @@ -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 + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobPrefetcher.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobPrefetcher.kt new file mode 100644 index 0000000000..69794dfe8f --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobPrefetcher.kt @@ -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() + + /** + * 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, + servers: List, + 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) + } + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index ebe48889dd..45f2336bd1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -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 (`/`) 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 { @@ -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 - } } 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 c2680c13fa..e915f0ad04 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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) } diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 56a48a19a0..efe528789d 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -13,4 +13,9 @@ “%1$s” published a note as you “%1$s” uploaded a file “%1$s” made a payment + + + Couldn\'t load “%1$s” + The publisher\'s servers may be offline, or you\'re not connected. You can try again. + Try again