diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index c11ed91602..b0104042ba 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -337,6 +337,9 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) + // Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge). + implementation(libs.androidx.webkit) + implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) diff --git a/amethyst/plans/2026-06-19-napplet-sandbox-host.md b/amethyst/plans/2026-06-19-napplet-sandbox-host.md index c39fcf98f1..836d9e850e 100644 --- a/amethyst/plans/2026-06-19-napplet-sandbox-host.md +++ b/amethyst/plans/2026-06-19-napplet-sandbox-host.md @@ -1,7 +1,7 @@ # Napplet / nsite sandbox host — design **Date:** 2026-06-19 -**Status:** Draft — core (commons) implemented + tested; Android host specified, not yet built +**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification **Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`). ## Goal @@ -190,9 +190,36 @@ Deferred to v2; v1 nails the single-applet boundary first. ## Phasing (within the "full napplet" milestone) -1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ verifiable now. -2. **Android host** — `:napplet` process + WebView + asset loader rendering a - static, no-capability applet (proves sandbox + verified blob serving). -3. **Broker IPC + `identity`** — getPublicKey/signEvent + consent UI. -4. **`relay` + `value` + `storage`** capabilities. -5. **Inter-applet** (v2). +1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done. +2. **Android host** — `:napplet` process + WebView + verified-blob serving via + `shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`). +3. **Broker IPC + `identity` + `relay`** — Messenger broker + (`NappletBrokerService`), `window.napplet.*` shim, consent UI + (`NappletConsentActivity`), DataStore ledger. ✅ implemented. +4. **`value` + `storage` + `net`** capabilities — protocol-defined; broker + currently answers `Unsupported`. ⏳ next. +5. **Inter-applet** (v2). ⏳ + +### Implemented Android components (amethyst `…/napplet/`) + +| File | Process | Role | +|---|---|---| +| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client | +| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check | +| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker | +| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) | +| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract | +| `NappletLauncher` | caller | Packs a verified manifest into the host Intent | +| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages | + +### Remaining before user-facing ship + +- **On-device verification (needs emulator/device):** opaque-origin iframe really + excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real + napplet renders and round-trips a `getPublicKey` / `signEvent` through consent. +- **UI entry point:** wire `NappletLauncher.launch(...)` into navigation (a napplet + list/detail screen). Today the host is reachable only programmatically. +- **Privacy:** the host's `OkHttpClient` for blob fetches ignores the user's + Tor/proxy settings — route it through the app's configured client. +- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title + and a per-capability rationale; batch-grant on first run. diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index b243df378b..d346d9868c 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -402,6 +402,29 @@ android:name=".service.call.CallNotificationReceiver" android:exported="false" /> + + + + + + + + + diff --git a/amethyst/src/main/assets/napplet/shell.html b/amethyst/src/main/assets/napplet/shell.html new file mode 100644 index 0000000000..cabb9a1a1b --- /dev/null +++ b/amethyst/src/main/assets/napplet/shell.html @@ -0,0 +1,42 @@ + + + + + + + Napplet + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt new file mode 100644 index 0000000000..dc462c1202 Binary files /dev/null and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt new file mode 100644 index 0000000000..dd584edcd8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -0,0 +1,179 @@ +/* + * 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.napplet + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.Process +import android.os.RemoteException +import android.util.Log +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletBroker +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this + * service and sends [NappletRequest]s as JSON over a [Messenger]; this is the only side that + * holds the signer, the relays, and the permission ledger. It runs each request through the + * shared [NappletBroker], which gates everything on consent and never returns key material. + * + * `exported=false` in the manifest restricts binding to this app's own UID, so no other + * installed app can reach the broker. A renderer escape that reaches the `:napplet` process + * could still bind here — but it gains only what the user explicitly consents to, per + * capability, and never the private key (the broker's contract). + */ +class NappletBrokerService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. + private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) } + + private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) } + + override fun onBind(intent: Intent?): IBinder? { + // Defense in depth on top of exported=false: only our own UID may bind. + if (Binder.getCallingUid() != Process.myUid()) return null + return incoming.binder + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun handleMessage(msg: Message): Boolean { + if (msg.what != NappletIpc.MSG_REQUEST) return false + + val data = msg.data ?: return true + val replyTo = msg.replyTo ?: return true + val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + + val identity = + NappletIdentity( + authorPubKey = data.getString(NappletIpc.KEY_AUTHOR).orEmpty(), + identifier = data.getString(NappletIpc.KEY_IDENTIFIER).orEmpty(), + aggregateHash = data.getString(NappletIpc.KEY_AGGREGATE_HASH), + ) + + scope.launch { + val response = process(identity, payload) + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(response)) + } + return true + } + + private suspend fun process( + identity: NappletIdentity, + payload: String, + ): NappletResponse { + val request = + runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() + ?: return NappletResponse.Failed("Malformed or unsupported request.") + + val broker = buildBroker() ?: return NappletResponse.Failed("No account is signed in.") + return broker.handle(identity, request) + } + + /** Builds a broker bound to the *currently* signed-in account, so account switches are honored. */ + private fun buildBroker(): NappletBroker? { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null + + val relay = + NappletRelayGateway { event: Event -> + val relays = account.computeRelayListToBroadcast(event) + account.client.publish(event, relays) + relays.map { it.url } + } + + val consent = + NappletConsentPrompt { id, capability, request -> + NappletConsentCoordinator.requestConsent( + context = applicationContext, + info = consentInfo(id, capability, request), + ) + } + + return NappletBroker(account.signer, ledger, consent, relay) + } + + private fun consentInfo( + identity: NappletIdentity, + capability: NappletCapability, + request: NappletRequest, + ): NappletConsentInfo { + val title = identity.identifier.ifBlank { "Napplet ${identity.authorPubKey.take(8)}…" } + return NappletConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + capabilityLabel = capability.name.lowercase(), + operationSummary = summaryFor(request), + ) + } + + private fun summaryFor(request: NappletRequest): String = + when (request) { + is NappletRequest.GetPublicKey -> "This napplet wants to read your public key." + is NappletRequest.SignEvent -> "This napplet wants to sign an event (kind ${request.kind}) as you." + is NappletRequest.Nip04Encrypt, is NappletRequest.Nip44Encrypt -> "This napplet wants to encrypt a message as you." + is NappletRequest.Nip04Decrypt, is NappletRequest.Nip44Decrypt -> "This napplet wants to decrypt a message addressed to you." + is NappletRequest.Publish -> "This napplet wants to publish an event to your relays." + } + + private fun reply( + replyTo: Messenger, + requestId: String, + payload: String, + ) { + val response = + Message.obtain(null, NappletIpc.MSG_RESPONSE).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_REQUEST_ID, requestId) + putString(NappletIpc.KEY_PAYLOAD, payload) + } + } + try { + replyTo.send(response) + } catch (e: RemoteException) { + Log.w("NappletBrokerService", "Applet host went away before reply could be delivered", e) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt new file mode 100644 index 0000000000..0179e9f1e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -0,0 +1,137 @@ +/* + * 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.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +/** + * The capability-consent dialog, shown in the **main** process (the only place trusted to + * make a grant). It renders the [NappletConsentInfo] for a pending request and reports the + * user's [GrantState] back through [NappletConsentCoordinator]. The untrusted applet never + * sees or drives this UI. + */ +class NappletConsentActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val token = intent.getStringExtra(NappletConsentCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletConsentCoordinator.infoFor(it) } + + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletConsentDialog( + info = info, + onDecision = { grant -> + decided = true + NappletConsentCoordinator.complete(token, grant) + finish() + }, + onDismiss = { + decided = true + NappletConsentCoordinator.cancel(token) + finish() + }, + ) + } + } + } + + override fun finish() { + // If the system tears us down before the user chose, fail closed. + if (!decided) token?.let { NappletConsentCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletConsentDialog( + info: NappletConsentInfo, + onDecision: (GrantState) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(info.appletTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(info.operationSummary) + Text( + "Capability: ${info.capabilityLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + info.coordinate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Column(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { onDecision(GrantState.ALLOW_ALWAYS) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { Text("Always allow") } + TextButton( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { Text("Allow once") } + } + }, + dismissButton = { + Column(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { onDecision(GrantState.DENY) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { Text("Never allow") } + TextButton( + onClick = { onDecision(GrantState.ASK) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { Text("Not now") } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt new file mode 100644 index 0000000000..524c3976e9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt @@ -0,0 +1,92 @@ +/* + * 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.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the consent dialog needs to describe what an applet is asking permission to do. */ +data class NappletConsentInfo( + val appletTitle: String, + val coordinate: String, + val capabilityLabel: String, + val operationSummary: String, +) + +/** + * Bridges the main-process broker to the consent UI. The broker suspends in + * [requestConsent]; this launches [NappletConsentActivity], holds the pending decision keyed + * by a one-time token, and resolves it when the activity reports the user's choice. + * + * A dismissed dialog resolves to [GrantState.ASK] — i.e. "not authorized, ask again next + * time" — so closing the prompt never silently grants nor permanently blocks. + */ +object NappletConsentCoordinator { + private class Pending( + val info: NappletConsentInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConsent( + context: Context, + info: NappletConsentInfo, + ): GrantState { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + val intent = + Intent(context, NappletConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token) + context.startActivity(intent) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + /** Called by [NappletConsentActivity] to render the prompt. */ + fun infoFor(token: String): NappletConsentInfo? = pending[token]?.info + + /** Called by [NappletConsentActivity] with the user's decision. */ + fun complete( + token: String, + grant: GrantState, + ) { + pending[token]?.deferred?.complete(grant) + } + + /** Called when the dialog is dismissed without a choice — fails closed (no grant). */ + fun cancel(token: String) { + pending[token]?.deferred?.complete(GrantState.ASK) + } + + const val EXTRA_TOKEN = "napplet_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt new file mode 100644 index 0000000000..d0dc428835 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -0,0 +1,410 @@ +/* + * 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.napplet + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.util.Log +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.BlobFetcher +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.ByteArrayInputStream + +/** + * Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no** + * account state, signer, or keys. It: + * + * 1. serves the trusted shell page and the manifest's **already-verified** blobs through + * [WebViewClient.shouldInterceptRequest] (no `file://`/`content://`, default-deny CSP with + * `connect-src 'none'` so the applet has no direct network), and + * 2. relays the applet's `window.napplet.*` capability calls — applet → shell (postMessage) → + * native (origin-restricted [WebViewCompat.addWebMessageListener]) → main-process broker + * (Messenger) → back — without ever interpreting them itself. + * + * Even a full WebView/renderer escape into this process yields no secret: the keys live only in + * the main process, and every brokered operation is still gated by user consent there. + */ +class NappletHostActivity : ComponentActivity() { + private lateinit var webView: WebView + + private val paths = mutableListOf() + private val servers = mutableListOf() + private var author: String = "" + private var identifier: String = "" + private var aggregateHash: String? = null + + private val http = OkHttpClient() + 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 + } + } + + // Messenger to the main-process broker, bound lazily; requests queue until connected. + private var brokerMessenger: Messenger? = null + private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply)) + private val pendingRequests = mutableListOf() + private var bridgeReplyProxy: JavaScriptReplyProxy? = null + + private val brokerConnection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + service: IBinder?, + ) { + brokerMessenger = Messenger(service) + pendingRequests.forEach { sendToBroker(it) } + pendingRequests.clear() + } + + override fun onServiceDisconnected(name: ComponentName?) { + brokerMessenger = null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (!readManifestExtras()) { + Toast.makeText(this, "Invalid napplet.", Toast.LENGTH_SHORT).show() + finish() + return + } + + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + Toast.makeText(this, "This device's WebView is too old to run napplets safely.", Toast.LENGTH_LONG).show() + finish() + return + } + + webView = WebView(this) + setContentView(webView) + hardenWebView(webView) + + // Origin-restricted bridge: only the trusted shell page (main frame) can reach native. + WebViewCompat.addWebMessageListener( + webView, + BRIDGE_NAME, + setOf(ORIGIN), + ::onShellMessage, + ) + + bindService(Intent(this, NappletBrokerService::class.java), brokerConnection, BIND_AUTO_CREATE) + + webView.loadUrl(SHELL_URL) + } + + override fun onDestroy() { + runCatching { unbindService(brokerConnection) } + if (this::webView.isInitialized) { + webView.destroy() + } + super.onDestroy() + } + + private fun readManifestExtras(): Boolean { + val pathList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_PATHS) ?: return false + val hashList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_HASHES) ?: return false + if (pathList.size != hashList.size || pathList.isEmpty()) return false + + for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i])) + servers.addAll(intent.getStringArrayListExtra(NappletLauncher.EXTRA_SERVERS) ?: emptyList()) + author = intent.getStringExtra(NappletLauncher.EXTRA_AUTHOR).orEmpty() + identifier = intent.getStringExtra(NappletLauncher.EXTRA_IDENTIFIER).orEmpty() + aggregateHash = intent.getStringExtra(NappletLauncher.EXTRA_AGGREGATE_HASH) + title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty() + return author.isNotEmpty() + } + + private var title: String = "" + + @Suppress("SetJavaScriptEnabled") + private fun hardenWebView(webView: WebView) { + webView.settings.apply { + javaScriptEnabled = true // the applet needs JS; isolation comes from process + CSP + sandbox + domStorageEnabled = false + databaseEnabled = false + allowFileAccess = false + allowContentAccess = false + @Suppress("DEPRECATION") + allowFileAccessFromFileURLs = false + @Suppress("DEPRECATION") + allowUniversalAccessFromFileURLs = false + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + setGeolocationEnabled(false) + mediaPlaybackRequiresUserGesture = true + cacheMode = WebSettings.LOAD_NO_CACHE + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { + safeBrowsingEnabled = true + } + } + WebView.setWebContentsDebuggingEnabled(false) + webView.webViewClient = NappletWebViewClient() + } + + /** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */ + private inner class NappletWebViewClient : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + val url = request.url.toString() + if (!request.method.equals("GET", ignoreCase = true)) return null + if (!url.startsWith(ORIGIN)) return notFound() + + if (url == SHELL_URL) return serveShell() + if (url == APP_BASE || url.startsWith(APP_BASE)) return serveAppResource(url) + return notFound() + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + // Block any navigation away from our internal origin (e.g. applet link clicks). + return request.url.host != HOST + } + } + + private fun serveShell(): WebResourceResponse { + val bytes = assets.open("napplet/shell.html").use { it.readBytes() } + return WebResourceResponse( + "text/html", + "utf-8", + 200, + "OK", + mapOf("Content-Security-Policy" to SHELL_CSP), + ByteArrayInputStream(bytes), + ) + } + + private fun serveAppResource(url: String): WebResourceResponse { + val requestPath = + url + .removePrefix(APP_BASE) + .substringBefore('?') + .substringBefore('#') + .let { if (it.isEmpty()) "/" else "/$it" } + + val resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) } + if (resolution !is StaticSiteResolution.Resolved) return notFound() + + val (mime, charset) = splitContentType(resolution.contentType) + val isHtml = mime.equals("text/html", ignoreCase = true) + val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes + + return WebResourceResponse( + mime, + charset, + 200, + "OK", + mapOf("Content-Security-Policy" to APP_CSP), + ByteArrayInputStream(bytes), + ) + } + + /** Inserts the `window.napplet` client shim into the applet's HTML document. */ + private fun injectShim(html: ByteArray): ByteArray { + val text = html.decodeToString() + val script = "" + val headIdx = text.indexOf("= 0 -> { + val close = text.indexOf('>', headIdx) + if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text + } + else -> script + text + } + return injected.encodeToByteArray() + } + + // ---- bridge: shell <-> native ---- + + private fun onShellMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + if (!isMainFrame) return // only the trusted shell, never a sub-frame + bridgeReplyProxy = replyProxy + + val raw = message.data ?: return + val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val id = envelope.optString("id").ifEmpty { return } + val payload = envelope.optString("payload").ifEmpty { return } + + val msg = + Message.obtain(null, NappletIpc.MSG_REQUEST).apply { + replyTo = replyMessenger + data = + Bundle().apply { + putString(NappletIpc.KEY_REQUEST_ID, id) + putString(NappletIpc.KEY_PAYLOAD, payload) + putString(NappletIpc.KEY_AUTHOR, author) + putString(NappletIpc.KEY_IDENTIFIER, identifier) + putString(NappletIpc.KEY_AGGREGATE_HASH, aggregateHash) + } + } + + val messenger = brokerMessenger + if (messenger == null) { + pendingRequests.add(msg) + } else { + sendToBroker(msg) + } + } + + private fun sendToBroker(msg: Message) { + try { + brokerMessenger?.send(msg) + } catch (e: Exception) { + Log.w(TAG, "Failed to deliver request to broker", e) + } + } + + private fun onBrokerReply(msg: Message): Boolean { + if (msg.what != NappletIpc.MSG_RESPONSE) return false + val data = msg.data ?: return true + val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + + val envelope = + JSONObject().apply { + put("id", id) + put("response", payload) + } + bridgeReplyProxy?.postMessage(envelope.toString()) + return true + } + + private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) + + private fun splitContentType(contentType: String): Pair { + val mime = contentType.substringBefore(';').trim().ifEmpty { "application/octet-stream" } + val charset = + contentType.substringAfter("charset=", "").trim().ifEmpty { null } + ?: if (mime.startsWith("text/") || mime.endsWith("javascript") || mime.endsWith("json")) "utf-8" else "" + return mime to charset + } + + companion object { + private const val TAG = "NappletHostActivity" + private const val HOST = "napplet.local" + private const val ORIGIN = "https://napplet.local" + private const val SHELL_URL = "$ORIGIN/__shell__" + private const val APP_BASE = "$ORIGIN/app/" + private const val BRIDGE_NAME = "__nappletBridge" + + private const val SHELL_CSP = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "frame-src https://napplet.local; base-uri 'none'; form-action 'none'" + + // 'self' does not match an opaque (sandboxed) origin, so the host is listed explicitly. + // connect-src 'none' is the key lever: the applet gets no direct network. + private const val APP_CSP = + "default-src 'self' https://napplet.local; " + + "script-src 'self' https://napplet.local 'unsafe-inline'; " + + "style-src 'self' https://napplet.local 'unsafe-inline'; " + + "img-src 'self' https://napplet.local data: blob:; " + + "font-src 'self' https://napplet.local data:; " + + "media-src 'self' https://napplet.local blob: data:; " + + "connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" + + private const val SHIM_JS = """ +(function(){ + if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true; + var seq = 0, pending = {}; + function call(payload){ + return new Promise(function(resolve){ + var id = 'r' + (seq++); + pending[id] = resolve; + parent.postMessage(JSON.stringify({ id: id, payload: JSON.stringify(payload) }), '*'); + }); + } + window.addEventListener('message', function(e){ + if (e.source !== parent) return; + if (typeof e.data !== 'string') return; + var msg; try { msg = JSON.parse(e.data); } catch (_) { return; } + if (!msg || !msg.id) return; + var cb = pending[msg.id]; if (!cb) return; delete pending[msg.id]; + var resp; try { resp = JSON.parse(msg.response); } catch (_) { resp = { type: 'failed', reason: 'bad response' }; } + cb(resp); + }); + function fail(r){ var e = new Error((r && r.reason) || (r && r.type) || 'napplet error'); e.napplet = r; throw e; } + function pubkey(r){ if (r.type === 'publicKey') return r.pubkey; fail(r); } + function evt(r){ if (r.type === 'signedEvent') return r.event; fail(r); } + function text(r){ if (r.type === 'text') return r.value; fail(r); } + function published(r){ if (r.type === 'published') return r.relays; fail(r); } + window.napplet = Object.freeze({ + getPublicKey: function(){ return call({ op: 'getPublicKey' }).then(pubkey); }, + signEvent: function(t){ return call({ op: 'signEvent', kind: t.kind, tags: t.tags || [], content: t.content || '' }).then(evt); }, + nip04Encrypt: function(peer, plaintext){ return call({ op: 'nip04Encrypt', peer: peer, plaintext: plaintext }).then(text); }, + nip04Decrypt: function(peer, ciphertext){ return call({ op: 'nip04Decrypt', peer: peer, ciphertext: ciphertext }).then(text); }, + nip44Encrypt: function(peer, plaintext){ return call({ op: 'nip44Encrypt', peer: peer, plaintext: plaintext }).then(text); }, + nip44Decrypt: function(peer, ciphertext){ return call({ op: 'nip44Decrypt', peer: peer, ciphertext: ciphertext }).then(text); }, + publish: function(ev){ return call({ op: 'publish', event: ev }).then(published); } + }); +})(); +""" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt new file mode 100644 index 0000000000..2906b97531 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt @@ -0,0 +1,44 @@ +/* + * 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.napplet + +/** + * The Messenger wire contract between the untrusted `:napplet` process (the WebView host) and + * the main-process [NappletBrokerService]. Kept tiny and string-only on purpose: nothing the + * applet controls is ever interpreted as a Binder object, and the only payloads are JSON + * strings ([NappletProtocolJson]) plus the applet's identity coordinate. + */ +object NappletIpc { + /** Host → broker: a capability request. Carries [KEY_REQUEST_ID], the identity keys, and [KEY_PAYLOAD]. */ + const val MSG_REQUEST = 1 + + /** Broker → host: the matching reply. Carries [KEY_REQUEST_ID] and [KEY_PAYLOAD]. */ + const val MSG_RESPONSE = 2 + + const val KEY_REQUEST_ID = "requestId" + const val KEY_PAYLOAD = "payload" + + // Applet identity (the ledger coordinate) travels with every request — the host cannot be + // trusted to have applied any policy, so the broker re-derives everything from these. + const val KEY_AUTHOR = "author" + const val KEY_IDENTIFIER = "identifier" + const val KEY_AGGREGATE_HASH = "aggregateHash" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt new file mode 100644 index 0000000000..1251b75b1f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -0,0 +1,63 @@ +/* + * 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.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest + +/** + * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only + * the verified manifest data the host needs to render and broker for the applet is passed — + * the declared `path → hash` map, the Blossom servers, the applet's identity coordinate, and a + * display title. No account state crosses into the sandbox process. + */ +object NappletLauncher { + const val EXTRA_PATHS = "napplet_paths" + const val EXTRA_HASHES = "napplet_hashes" + const val EXTRA_SERVERS = "napplet_servers" + const val EXTRA_AUTHOR = "napplet_author" + const val EXTRA_IDENTIFIER = "napplet_identifier" + const val EXTRA_AGGREGATE_HASH = "napplet_aggregate_hash" + const val EXTRA_TITLE = "napplet_title" + + fun launch( + context: Context, + manifest: NappletManifest, + authorPubKey: HexKey, + identifier: String, + ) { + val pathTags = manifest.paths() + val intent = + Intent(context, NappletHostActivity::class.java).apply { + putExtra(EXTRA_PATHS, ArrayList(pathTags.map { it.path })) + putExtra(EXTRA_HASHES, ArrayList(pathTags.map { it.hash })) + putExtra(EXTRA_SERVERS, ArrayList(manifest.servers())) + putExtra(EXTRA_AUTHOR, authorPubKey) + putExtra(EXTRA_IDENTIFIER, identifier) + putExtra(EXTRA_AGGREGATE_HASH, manifest.declaredAggregateHash() ?: manifest.computeAggregateHash()) + putExtra(EXTRA_TITLE, manifest.title() ?: identifier.ifBlank { "Napplet" }) + if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt new file mode 100644 index 0000000000..d3dff5f120 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt @@ -0,0 +1,102 @@ +/* + * 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.napplet + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.quartz.nip01Core.core.Event +import org.json.JSONArray +import org.json.JSONObject + +/** + * Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the JSON the + * applet exchanges over `window.napplet.*`. This is the only place the boundary parses applet + * input, so it is deliberately strict: unknown ops decode to `null` (the broker rejects them) + * and a malformed body throws rather than guessing. + * + * The host process only ever *shuttles* these strings; decoding/encoding happens in the + * main-process broker so a compromised host cannot fabricate a typed request that skips a field. + */ +object NappletProtocolJson { + /** Parses an applet request. Returns `null` for an unrecognized `op` so the broker can deny it. */ + fun decodeRequest(json: String): NappletRequest? { + val o = JSONObject(json) + return when (o.getString("op")) { + "getPublicKey" -> NappletRequest.GetPublicKey + "signEvent" -> + NappletRequest.SignEvent( + kind = o.getInt("kind"), + tags = decodeTags(o.optJSONArray("tags")), + content = o.optString("content", ""), + ) + "nip04Encrypt" -> NappletRequest.Nip04Encrypt(o.getString("peer"), o.getString("plaintext")) + "nip04Decrypt" -> NappletRequest.Nip04Decrypt(o.getString("peer"), o.getString("ciphertext")) + "nip44Encrypt" -> NappletRequest.Nip44Encrypt(o.getString("peer"), o.getString("plaintext")) + "nip44Decrypt" -> NappletRequest.Nip44Decrypt(o.getString("peer"), o.getString("ciphertext")) + "publish" -> NappletRequest.Publish(Event.fromJson(o.getJSONObject("event").toString())) + else -> null + } + } + + fun encodeResponse(response: NappletResponse): String { + val o = JSONObject() + when (response) { + is NappletResponse.PublicKey -> { + o.put("type", "publicKey") + o.put("pubkey", response.pubkey) + } + is NappletResponse.SignedEvent -> { + o.put("type", "signedEvent") + o.put("event", JSONObject(response.event.toJson())) + } + is NappletResponse.Text -> { + o.put("type", "text") + o.put("value", response.value) + } + is NappletResponse.Published -> { + o.put("type", "published") + o.put("relays", JSONArray(response.relays)) + } + is NappletResponse.Denied -> { + o.put("type", "denied") + o.put("capability", response.capability.name) + o.put("reason", response.reason) + } + is NappletResponse.Unsupported -> { + o.put("type", "unsupported") + o.put("operation", response.operation) + } + is NappletResponse.Failed -> { + o.put("type", "failed") + o.put("reason", response.reason) + } + } + return o.toString() + } + + private fun decodeTags(array: JSONArray?): Array> { + if (array == null) return emptyArray() + return Array(array.length()) { i -> + val inner = array.getJSONArray(i) + Array(inner.length()) { j -> inner.getString(j) } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34b5e7b6f8..a1c932f65e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -76,6 +76,7 @@ commonsImaging = "1.0.0-alpha6" thumbnailator = "0.4.21" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" +webkit = "1.12.1" windowCoreAndroid = "1.5.1" workRuntime = "2.11.2" androidxCamera = "1.6.1" @@ -207,6 +208,7 @@ stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android" unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }