feat(napplet): Android sandbox host — isolated process, broker IPC, consent

Implements the Android side of the napplet/nsite trust boundary on top of the
commons core. The applet runs in a separate OS process holding no keys; every
dangerous operation is brokered to the main process and gated by user consent.

:napplet process (no secrets):
- NappletHostActivity: hardened WebView (no file/content access, no DOM storage,
  mixed-content blocked, SafeBrowsing on), applet served into an opaque-origin
  sandboxed iframe, manifest blobs served already-verified via
  shouldInterceptRequest with a default-deny CSP (connect-src 'none' = no direct
  network), and a window.napplet.* shim bridged over an origin-restricted
  WebMessageListener.

Main process (holds the signer):
- NappletBrokerService: bound Messenger service running the commons NappletBroker
  against the live account; exported=false + UID check. Builds the broker per
  request so account switches are honored; relay publish via the account's
  computed broadcast relays.
- NappletConsentActivity + NappletConsentCoordinator: capability-consent dialog
  with a suspend bridge; fails closed on dismissal.
- DataStoreNappletPermissionStore: persistent grant store.

Shared edge: NappletProtocolJson (JSON codec), NappletIpc (Messenger contract),
NappletLauncher (packs a verified manifest into the host Intent), shell.html.

Adds androidx.webkit (Apache-2.0) for the origin-restricted message bridge — a
plain @JavascriptInterface leaks into every frame and would break the boundary.
Manifest declares the :napplet activity, the consent activity, and the broker
service. :amethyst:compileFdroidDebugKotlin passes; on-device verification and a
UI entry point are the remaining steps (see the plan doc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-19 23:50:58 +00:00
parent 119442293a
commit daae12b53d
13 changed files with 1131 additions and 7 deletions
+3
View File
@@ -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)
@@ -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.
+23
View File
@@ -402,6 +402,29 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name=".napplet.NappletHostActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:launchMode="singleTask"
android:theme="@style/Theme.Amethyst" />
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
<activity
android:name=".napplet.NappletConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
<service
android:name=".napplet.NappletBrokerService"
android:exported="false" />
</application>
@@ -0,0 +1,42 @@
<!doctype html>
<!--
Trusted napplet shell page (served from app assets at https://napplet.local/__shell__).
It hosts the untrusted applet in an opaque-origin sandboxed iframe and relays capability
messages between the applet (window.postMessage) and the native bridge (__nappletBridge),
which is origin-restricted to this page only. The applet iframe can never reach the bridge.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Napplet</title>
<style>
html, body, iframe { margin: 0; padding: 0; border: 0; width: 100%; height: 100%; background: #fff; }
</style>
</head>
<body>
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
<script>
(function () {
var iframe = document.getElementById('app');
var bridge = window.__nappletBridge;
// applet -> native: only forward messages that came from our applet iframe.
window.addEventListener('message', function (e) {
if (e.source !== iframe.contentWindow) return;
if (typeof e.data !== 'string') return;
if (bridge) bridge.postMessage(e.data);
});
// native -> applet: hand the broker's reply down into the iframe.
if (bridge) {
bridge.onmessage = function (e) {
iframe.contentWindow.postMessage(e.data, '*');
};
}
iframe.src = 'https://napplet.local/app/';
})();
</script>
</body>
</html>
@@ -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)
}
}
}
@@ -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") }
}
},
)
}
@@ -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<GrantState>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConsent(
context: Context,
info: NappletConsentInfo,
): GrantState {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<GrantState>()
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"
}
@@ -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<PathTag>()
private val servers = mutableListOf<String>()
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<Message>()
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 = "<script>$SHIM_JS</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 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<String, String> {
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); }
});
})();
"""
}
}
@@ -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"
}
@@ -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)
}
}
@@ -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<Array<String>> {
if (array == null) return emptyArray()
return Array(array.length()) { i ->
val inner = array.getJSONArray(i)
Array(inner.length()) { j -> inner.getString(j) }
}
}
}
+2
View File
@@ -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" }