diff --git a/.changeset/host-owned-media.md b/.changeset/host-owned-media.md new file mode 100644 index 0000000000..da43738649 --- /dev/null +++ b/.changeset/host-owned-media.md @@ -0,0 +1,8 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add the host-owned `Media` API for real-time sessions, authenticated participant invitations, incoming-offer decisions, local microphone/camera/screen intent, and host-composited surfaces. Sessions belong to the authenticated product, account, network, and runtime. Calling and capture consent remain separate, screen capture uses the trusted picker, and operation identifiers support cancellation and recovery without duplicate effects. Capability discovery reports unsupported hosts without requesting permission or starting capture. + +Media TypeScript examples consume observable session snapshots and retain their host listener through mutation admission and cleanup. The mock host preserves product, root-account, and Bulletin-genesis isolation for automatic-preimage consent and quota storage. Android storage fixtures exercise scoped identity and isolation, and Media validation helpers preserve commit, capture, and surface checks without duplicated throw paths. diff --git a/CHANGELOG.md b/CHANGELOG.md index 329454ff7b..b72a53711b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Fixed +- Resolve paired usernames from already-minted pending dotNS claims regardless of reservation age, while requiring current forward-registry ownership and rejecting conflicting atomic/subnode owners during lookup. Read-only Rust probes resolve the public `session.14`, `session.18`, and `session.42` accounts; legacy names without PoP provenance remain unresolved. Actual mobile-pairing qualification remains outstanding. +- Continue dotNS identity enumeration past conflicting or zero-owner candidates without accepting them, and fall back to the root account when the preferred account has no usable owned username. Preserve first-owned-name precedence by skipping filled slots before further views; transport and ABI failures remain errors, and explicit Chat forward-owner lookups still reject unusable records. - Browser receiving synchronizes and revokes each authority scope independently, so account or verified-artifact replacement cannot leave the current enrollment in a revoke/register loop. - Message catch-up receipts preserve an already queued notification activation until the product explicitly acknowledges its sequence. - Generated WASM bridges preserve owned `String` parameters instead of emitting unsized `str` arguments. diff --git a/Cargo.lock b/Cargo.lock index 28aefb7f4c..32b05fb87b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1484,6 +1509,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -2264,6 +2298,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2574,6 +2618,26 @@ dependencies = [ "hmac 0.8.1", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac 0.12.1", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "1.5.0" @@ -4050,6 +4114,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -6342,6 +6418,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "hkdf", + "hpke", "js-sys", "libc", "merlin", @@ -6350,6 +6427,7 @@ dependencies = [ "parking_lot", "quinn", "rand 0.8.7", + "rand_core 0.6.4", "rcgen", "ring", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index af4b6d773b..c4f171e30c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ futures-util = "0.3" getrandom = "0.2" hex = { version = "0.4", default-features = false, features = ["alloc"] } hkdf = "0.12" +hpke = { version = "0.12", default-features = false } image = { version = "0.25", default-features = false } indoc = "2" js-sys = "0.3" @@ -55,6 +56,7 @@ proc-macro2 = "1" qrcode = { version = "0.14.1", default-features = false } quote = "1" rand = "0.8" +rand_core = "0.6" ratatui = { version = "0.30.2", default-features = false } reqwest = { version = "0.12", default-features = false } rqrr = { version = "0.10.1", default-features = false } diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 6fc88d43bb..665ee08e17 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -241,9 +241,9 @@ The core's `Permissions` platform trait has two methods, and so does the bridge: Both return `PermissionDecision` (`ALLOW_ONCE`, `ALLOW_ALWAYS`, or `DENY`). Preserve the choice so the core can consume one-use grants without persisting them. OS refusal after app consent should throw rather than record a product denial. `TrUAPIProductExecution.permissionAuthorizationStatus` resolves the current execution's authorization (including the OS gate); settings instead use the process-wide `TrUAPIHostRuntime.permissionAuthorizations(productId)` and suspending `setPermissionAuthorizationStatus(productId, request, status)`. Settings work without opening an execution and preserve product+request, account-neutral authority. -The browser container checks product consent before opening WebSockets or requesting camera and microphone access. After -installing it, the WebChromeClient media callback should check only the Android OS permission, so it does not consume -product consent twice. +The browser container checks product consent before opening WebSockets. Raw WebView camera, microphone, +display capture, WebRTC and fullscreen remain denied even when device or generic network grants exist. +Capture and RTC run only through the host-owned Media backend, with separate core consent and Android OS permission. To disable WebRTC, await `runtime.setPermissionAuthorizationStatus(productId, request, DENIED)` with a remote `WebRtc` request **before opening** the product execution. Deny/reset invalidates pending prompts and one-use grants and closes the product's existing executions; reopen a fresh execution when the user launches it again. Other products remain open. An explicit `AUTHORIZED` settings choice updates the same authority. @@ -259,9 +259,17 @@ signing and other single-action reviews continue to use that Boolean callback. Implement `HostCoreStorage.keys()` by enumerating the existing core namespace in the real persistent backend, not a separate grant index. The default throws unsupported. Implement `HostBridge.permissionAuthorizationsChanged(productId)` on the process bridge and notify settings observers; every core write, including decisions from native prompts, reaches this callback. After invalidation, check `execution.isClosed()` and dispose that execution's WebView/media resources without automatic renderer recovery. A socket disconnect alone does not stop capture. +Storage adapters retain cancellation-safe durable compare/exchange and deferred cross-root refresh in addition to enumeration. +After a runtime settings setter returns, call `runtime.awaitCoreStorageChanges()` outside session/lifecycle/storage locks; +it reports a failed refresh even if the durable policy edit succeeded. The deferred job also emits the canonical +authorization-change callback after fencing matching Media scopes, including when the requesting coroutine was cancelled. +Keep both callbacks; canonical settings notifications do not replace Media invalidation. +Refresh uses the owning process authority, not execution administration: canonical revocation may already have closed +the execution before the deferred job runs. Storage/read failures still propagate through the drain. + The Android application's permission repository imports Room rows through `importPermissionAuthorizations` before opening an execution or reading settings. Import only fills absent canonical decisions; a stored deny or reset tombstone cannot be resurrected by an old Room grant. Supported legacy reads and both settings toggle directions use canonical records. Legacy-only balance access stays in Room. Historical multi-domain records retain their bundle identity rather than turning a bundle denial into per-domain denials. Malformed or unmappable supported records fail visibly instead of disappearing from settings. -Legacy prompts capture `permissionAuthorizationRevision` before waiting for the user and persist through `setPermissionAuthorizationStatusIfCurrent`; a false result rejects a late answer. One-use legacy grants are invalidated on process notifications. Revocation errors propagate to settings and leave the displayed persistent decision unchanged; there is no optimistic success. These are native host administration APIs, not new guest wire methods. +Legacy prompts capture `permissionAuthorizationRevision` before waiting for the user and persist through `setPermissionAuthorizationStatusIfCurrent`; a false result rejects a late answer. One-use legacy grants are invalidated on process notifications. Revocation errors propagate to settings; a persistence failure retains the old decision, while a later refresh failure reports the error without rolling back a durable denial. These are native host administration APIs, not new guest wire methods. ## Statement-store allowance renewal @@ -406,18 +414,29 @@ class MyStorage : HostStorage { override suspend fun clear(key: String) { map.remove(key) } } -// Core-owned storage: keyed by SCALE-encoded CoreStorageKey bytes. Back it with -// real persistence (e.g. EncryptedSharedPreferences); an in-memory map is shown -// for brevity. +// Core-owned storage: the bytes remain opaque. Back it with real persistence; +// writes/clears must finish durably before returning. The SDK serializes all +// callbacks and implements cancellation-safe CAS plus cross-core refreshes. +// Every adapter sharing a physical store/prefix MUST use the same identifier. +// This process-wide in-memory store is shown only for brevity. class MyCoreStorage : HostCoreStorage { - private val map = HashMap() + override val storageIdentifier = "example-process-memory" + private companion object { + val map = java.util.concurrent.ConcurrentHashMap() + } private fun k(key: ByteArray) = key.joinToString("") { "%02x".format(it) } + @OptIn(ExperimentalStdlibApi::class) + override suspend fun keys(): List = map.keys.map { it.hexToByteArray() } override suspend fun read(key: ByteArray) = map[k(key)] override suspend fun write(key: ByteArray, value: ByteArray) { map[k(key)] = value } override suspend fun clear(key: ByteArray) { map.remove(k(key)) } } -class MyBridge(private val webView: WebView) : HostBridge { +class MyBridge( + private val webView: WebView, + private val onPermissionAuthorizationsChanged: (String) -> Unit, +) : HostBridge { + override fun permissionAuthorizationsChanged(productId: String) = onPermissionAuthorizationsChanged(productId) private val main = Handler(Looper.getMainLooper()) override val storage = MyStorage() @@ -489,7 +508,8 @@ class MyBridge(private val webView: WebView) : HostBridge { } val webView: WebView = existingWebView -val bridge = MyBridge(webView) +val permissionChanges = kotlinx.coroutines.flow.MutableStateFlow(0L) +val bridge = MyBridge(webView) { permissionChanges.value += 1 } val runtimeConfig = HostRuntimeConfig( hostName = "My Host", hostIcon = "https://host.example/icon.png", diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt index 5499e53959..60290b2a1c 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt @@ -36,6 +36,7 @@ class AndroidHostCoreStorage(context: Context, namespace: String) : HostCoreStor } private val keyAlias = "io.parity.truapi.core.$namespace" private val databaseFile = File(context.noBackupFilesDir, "truapi-core-$namespace.sqlite") + override val storageIdentifier: String = databaseFile.canonicalPath private val database = object : SQLiteOpenHelper( context, databaseFile.absolutePath, 1, // OpenParams configures every pooled/reopened connection, unlike a @@ -53,6 +54,21 @@ class AndroidHostCoreStorage(context: Context, namespace: String) : HostCoreStor } private var encryptionKey: SecretKey? = null + override suspend fun keys(): List = storageCall { + database.readableDatabase.query( + "core_slots", arrayOf("slot"), null, null, null, null, null, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + val encoded = cursor.getString(0) + val key = android.util.Base64.decode(encoded, android.util.Base64.NO_WRAP) + check(slot(key) == encoded) { "Invalid private core storage key" } + add(key) + } + } + } + } + override suspend fun read(key: ByteArray): ByteArray? = storageCall { database.readableDatabase.query( "core_slots", arrayOf("ciphertext"), "slot = ?", arrayOf(slot(key)), diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/CoreStorageAccess.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/CoreStorageAccess.kt new file mode 100644 index 0000000000..d88077e481 --- /dev/null +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/CoreStorageAccess.kt @@ -0,0 +1,130 @@ +package io.parity.truapi + +import java.util.WeakHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import uniffi.truapi.HostRejection +import uniffi.truapi.NativeTrUApiHostRuntime +import uniffi.truapi.nativeDescribeCoreStorageKey + +/** One coordinator for every adapter in this process, including independent Rust roots. */ +internal object CoreStorageAccess { + // Collisions only serialize unrelated slots. The bounded table never forgets a held slot lock. + private val locks = Array(256) { Mutex() } + private data class Owner( + val storageIdentifier: String, + val group: Any, + val productId: String, + val runtime: NativeTrUApiHostRuntime, + ) + private val executions = WeakHashMap() + private val failures = WeakHashMap() + private sealed interface Notice { + class Changed( + val storage: HostCoreStorage, + val origin: Any, + val key: ByteArray, + val reportFailure: () -> Unit, + val authorizationChanged: (String) -> Unit, + ) : Notice + class Barrier(val origin: Any, val completion: CompletableDeferred) : Notice + } + // Never closed: foreign CAS jobs may outlive a cancelled requester or closed execution. + private val notices = Channel(Channel.UNLIMITED) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + scope.launch { + for (notice in notices) { + when (notice) { + is Notice.Changed -> { + val failed = try { refresh(notice) } catch (_: Throwable) { true } + if (failed) { + failures[notice.origin] = true + runCatching { notice.reportFailure() } + } + } + is Notice.Barrier -> { + if (failures.remove(notice.origin) == true) { + notice.completion.completeExceptionally(HostRejection.Rejected("Permission refresh failed")) + } else { + notice.completion.complete(Unit) + } + } + } + } + } + } + + // Non-reentrant: `operation` must use the raw [HostCoreStorage], never `serialized` again. + suspend fun serialized(storage: HostCoreStorage, key: ByteArray, operation: suspend () -> T): T { + val hash = 31 * storage.storageIdentifier.hashCode() + key.contentHashCode() + return locks[hash and (locks.size - 1)].withLock { operation() } + } + + fun register( + execution: TrUAPIProductExecution, + storageIdentifier: String, + group: Any, + productId: String, + runtime: NativeTrUApiHostRuntime, + ) { + synchronized(executions) { executions[execution] = Owner(storageIdentifier, group, productId, runtime) } + } + + fun unregister(execution: TrUAPIProductExecution) { + synchronized(executions) { executions.remove(execution) } + } + + fun changed( + storage: HostCoreStorage, + origin: Any, + key: ByteArray, + reportFailure: () -> Unit, + authorizationChanged: (String) -> Unit, + ) { + notices.trySend(Notice.Changed(storage, origin, key.copyOf(), reportFailure, authorizationChanged)).getOrThrow() + } + + suspend fun awaitChanges(origin: Any) { + val completion = CompletableDeferred() + notices.trySend(Notice.Barrier(origin, completion)).getOrThrow() + completion.await() + } + + private suspend fun refresh(notice: Notice.Changed): Boolean { + // CAS queues before completion to survive cancellation. Its physical write lock must + // nevertheless be released before decoding metadata or entering any Rust core. + serialized(notice.storage, notice.key) { } + val description = nativeDescribeCoreStorageKey(notice.key) + val request = description.permissionRequest ?: return false + val productId = description.productId ?: return false + val targets = synchronized(executions) { + executions.entries.filter { (execution, owner) -> + !execution.isClosed && owner.storageIdentifier == notice.storage.storageIdentifier && + owner.productId == productId + }.map { it.value }.distinctBy { it.group } + } + var failed = false + for (owner in targets) { + try { + // Canonical revocation may already have closed the execution. Refresh through + // its process authority, which re-reads storage and fences the exact product scope. + // Include the writer: its CAS can outlive the requesting Rust future. + owner.runtime.refreshPermissionAuthorization(productId, request) + } catch (_: Throwable) { + // Do not suppress an unreadable policy or turn it into broader execution closure. + failed = true + } + } + // Notify after refresh so the shell sees canonical closure, including a dropped CAS caller. + runCatching { notice.authorizationChanged(productId) } + return failed + } +} diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 3f7ec06fcf..14fc01669d 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -33,6 +33,9 @@ import java.time.format.FormatStyle import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow @@ -115,6 +118,7 @@ import uniffi.truapi.ReceivingAuthority import uniffi.truapi.ReceivingWatch import uniffi.truapi.ReceivingRegistration import uniffi.truapi.ReceivingEvent +import uniffi.truapi.NativeMediaCallbacks /** Package metadata. */ object TrUAPIHost { @@ -144,6 +148,13 @@ interface HostStorage { * [HostRejection] on failure. */ interface HostCoreStorage { + /** + * Stable physical-store namespace, including any account/tenant prefix. + * Distinct adapters accessing the same physical slots MUST use the same value. + * All access to those slots must go through the SDK's coordinated callbacks. + */ + val storageIdentifier: String + @Throws(HostRejection::class) suspend fun read(key: ByteArray): ByteArray? @@ -383,7 +394,10 @@ interface HostBridge : NativeChatFilesHost { /** Preserve the selected lifetime for identity and account access consent. */ @Throws(HostRejection::class) suspend fun confirmPermission(review: UserConfirmationReview): PermissionDecision = - if (confirmUserAction(review)) PermissionDecision.ALLOW_ALWAYS else PermissionDecision.DENY + if (confirmUserAction(review)) { + if (review is UserConfirmationReview.PreimageSubmit) PermissionDecision.ALLOW_ONCE + else PermissionDecision.ALLOW_ALWAYS + } else PermissionDecision.DENY /** Return the current preimage value for [key], or null for a miss. */ @Throws(HostRejection::class) @@ -613,7 +627,12 @@ interface GameHostBridge { * [HostCallbacks] interface. Keeps the public API stable even if uniffi-bindgen * renames generated symbols. */ -private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallbacks { +private class HostCallbackAdapter( + private val bridge: HostBridge, + private val storageGroup: Any, + private val authorizationChanged: (String) -> Unit = bridge::permissionAuthorizationsChanged, +) : HostCallbacks { + private val coreStorage = bridge.coreStorage // The core declares this and `authStateChanged` infallible, so uniffi has // no error type to convert a throw into and panics -- which aborts under // `panic = "abort"`. Neither may let a host exception reach the FFI. @@ -627,7 +646,7 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback } override fun permissionAuthorizationsChanged(productId: String) { - runCatching { bridge.permissionAuthorizationsChanged(productId) } + runCatching { authorizationChanged(productId) } } // Infallible across the FFI for the same reason `onCoreLog` is. @@ -696,13 +715,48 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback } override suspend fun coreStorageRead(key: ByteArray): ByteArray? = - withHostRejection { bridge.coreStorage.read(key) } + withHostRejection { CoreStorageAccess.serialized(coreStorage, key) { coreStorage.read(key) } } override suspend fun coreStorageWrite(key: ByteArray, value: ByteArray) = - withHostRejection { bridge.coreStorage.write(key, value) } + withHostRejection { CoreStorageAccess.serialized(coreStorage, key) { coreStorage.write(key, value) } } override suspend fun coreStorageClear(key: ByteArray) = - withHostRejection { bridge.coreStorage.clear(key) } + withHostRejection { CoreStorageAccess.serialized(coreStorage, key) { coreStorage.clear(key) } } + + override suspend fun compareExchangeCoreStorage( + key: ByteArray, + expected: ByteArray?, + replacement: ByteArray, + notifyOnSuccess: Boolean, + ): Boolean = withContext(NonCancellable + Dispatchers.IO) { + withHostRejection { + CoreStorageAccess.serialized(coreStorage, key) { + val current = coreStorage.read(key) + if (current == null && expected != null || current != null && (expected == null || !current.contentEquals(expected))) { + false + } else { + coreStorage.write(key, replacement) + if (notifyOnSuccess) enqueueCoreStorageChange(key) + true + } + } + } + } + + private fun enqueueCoreStorageChange(key: ByteArray) { + CoreStorageAccess.changed( + coreStorage, storageGroup, key, + reportFailure = { onCoreLog("host.permission_refresh.failed", "Permission refresh failed") }, + authorizationChanged = ::permissionAuthorizationsChanged, + ) + } + + // Infallible, nonblocking, and never reenters a core from the FFI callback. + override fun coreStorageChanged(key: ByteArray) { + runCatching { enqueueCoreStorageChange(key) }.onFailure { + onCoreLog("host.permission_refresh.failed", "Permission refresh failed") + } + } override suspend fun coreStorageKeys(): List = withHostRejection { bridge.coreStorage.keys() } @@ -993,7 +1047,8 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor ) : AutoCloseable { // Co-owns the adapter alongside the generated FfiConverter handle map, // which is what actually keeps the callback object alive for the runtime. - private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge) + private val storageGroup = Any() + private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge, storageGroup) private val nativeWalletRetainer: NativeCoinageCallbacks? = nativeWallet?.let { NativeCoinageCallbackAdapter(it) } private val inner: NativeTrUApiHostRuntime = @@ -1059,12 +1114,32 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor inner.notifyContactsChanged() } + /** Current root account; retain this snapshot in account-scoped permission requests. */ + fun currentSessionPublicKey(): ByteArray? = inner.currentSessionPublicKey() + + @Throws(HostRejection::class) + suspend fun permissionAuthorizationStatus( + productId: String, + request: PermissionAuthorizationRequest, + ): PermissionAuthorizationStatus = inner.permissionAuthorizationStatus(productId, request) + + /** + * Wait for enqueued cross-core policy refreshes after a settings edit. + * Call outside lifecycle/session/storage locks; refresh failures propagate. + */ + @Throws(HostRejection::class) + suspend fun awaitCoreStorageChanges() { + CoreStorageAccess.awaitChanges(storageGroup) + } + /** * Open one executable connection with a host-assigned immutable context. * Pass [chat] to install the host's Chat adapter; hosts without the Chat * modality omit it. Pass [pocket] to install the card collection, and omit * that where the host has no Pocket surface. Pass [game] to hold game * reminders, and omit it where the host cannot. + * Pass [media] only for a complete trusted capture/RTC/compositor backend. + * Its generated typed callbacks are host-only and retained until execution close. */ @Throws(NativeRuntimeConfigException::class) fun openProductExecution( @@ -1073,8 +1148,9 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor chat: ChatHostBridge? = null, pocket: PocketHostBridge? = null, game: GameHostBridge? = null, + media: NativeMediaCallbacks? = null, ): TrUAPIProductExecution { - val adapter = HostCallbackAdapter(bridge) + val adapter = HostCallbackAdapter(bridge, storageGroup, callbackRetainer::permissionAuthorizationsChanged) val chatAdapter = chat?.let { ChatCallbackAdapter(it) } val pocketAdapter = pocket?.let { PocketCallbackAdapter(it) } val gameAdapter = game?.let { GameCallbackAdapter(it) } @@ -1084,9 +1160,12 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor chatAdapter, pocketAdapter, gameAdapter, + media, configuration, ) - return TrUAPIProductExecution(execution, adapter, chatAdapter, pocketAdapter, gameAdapter) + return TrUAPIProductExecution(execution, adapter, chatAdapter, pocketAdapter, gameAdapter, media).also { + CoreStorageAccess.register(it, bridge.coreStorage.storageIdentifier, storageGroup, execution.productContext().productId, inner) + } } /** All durable registrations; inspect syncPending before synchronizing. */ @@ -1337,8 +1416,10 @@ class TrUAPIProductExecution internal constructor( private val chatRetainer: NativeChatCallbacks?, private val pocketRetainer: NativePocketCallbacks?, private val gameRetainer: NativeGameCallbacks?, + private val mediaRetainer: NativeMediaCallbacks?, ) : AutoCloseable { private val shutDown = AtomicBoolean(false) + internal val isClosed: Boolean get() = shutDown.get() /** * Register this execution against the host runtime's shared localhost @@ -1445,6 +1526,16 @@ class TrUAPIProductExecution internal constructor( suspend fun authorizeRemotePermission(request: RemotePermissionRequest): Boolean = inner.authorizeRemotePermission(request) + /** Current core-derived Calling scope for trusted permission settings. */ + @Throws(HostRejection::class) + suspend fun callingPermissionAuthorizationRequest(): PermissionAuthorizationRequest = + inner.callingPermissionAuthorizationRequest() + + /** Re-read shared policy and fence this core's exact affected Media scope, without prompting. */ + @Throws(HostRejection::class) + suspend fun refreshPermissionAuthorization(request: PermissionAuthorizationRequest) { + inner.refreshPermissionAuthorization(request) + } /** Push a host theme update to active TrUAPI theme subscriptions. */ fun notifyThemeChanged(theme: HostThemeSubscribeItem) { @@ -1493,6 +1584,7 @@ class TrUAPIProductExecution internal constructor( // as well as guarded: a concurrent close could otherwise free the // handle between the guard and the call. if (shutDown.compareAndSet(false, true)) { + CoreStorageAccess.unregister(this) inner.shutdown() } inner.close() diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index 16f4d727f8..8ff90d7fc7 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -17,6 +17,18 @@ owner: "@johnthecat" > **NOTE (2026-09-18): retain the legacy Google Fonts exception.** > For compatibility, `BLESSED_REMOTE_DOMAINS` lets every product access `fonts.googleapis.com` and `fonts.gstatic.com` without a prompt. This covers all paths and query strings on those hosts, not only font requests, and requests disclose the user's IP address to Google. An explicit matching denial overrides the exception. These implicit grants are not stored. +> **NOTE (2026-10-06): trusted native products skip only notification app consent.** +> The iOS and Android TrUAPI notification permission callbacks reuse the canonical +> `hasTrustedRemotePermissions` classifier for the application-consent step only. +> The core still reads product-scoped decisions before invoking the callback: +> stored denial, one-use consumption and storage errors retain their existing +> behavior. The native callback still checks/requests the host application's OS +> notification permission; OS refusal never returns an authorization or reaches +> notification scheduling. Camera, microphone and other device capabilities do +> not inherit this exception. Browser hosts must make the same separation in +> their concrete permission callback, not bypass that callback in Rust: the +> shared callback can own both application consent and browser/OS authorization. + ## Summary The host callback distinguishes `AllowOnce`, `AllowAlways`, and `Deny`. @@ -44,6 +56,34 @@ bundle-specific and are not flattened into stronger per-domain denials. The Host API currently has two underdefined permission calls — `host_device_permission` and `remote_permission` — that lack coverage for several device capabilities (NFC, Clipboard, OpenUrl, Biometrics), do not support batched remote-permission requests, and have no specified lifecycle for when prompts occur or how decisions are persisted. This RFC defines the complete set of device and remote permissions, updates the `remote_permission` signature to accept a batch, specifies lasting and one-use permission decisions, and establishes that business methods (`host_sign_raw`, `host_sign_payload`, `host_create_transaction`, `host_create_transaction_with_non_product_account`, `remote_statement_store_submit`, `remote_preimage_submit`, `remote_chain_transaction_broadcast`) implicitly trigger permission prompts if permission has not yet been granted. +## Bounded automatic preimage uploads + +`RemotePermission::PreimageSubmit` permits the capability, not an unattended upload. +The core separately requests upload consent, including the product id, active root +public key, configured Bulletin genesis hash and byte count. `AllowOnce` approves +only that attempt. `AllowAlways` explicitly enables automatic uploads for that +product/account/network, limited to **262,144 bytes per upload and four attempts +per rolling 3,600 seconds**. Larger uploads and an exhausted budget still require +per-upload review. Trusted products and unrelated automatic-signing grants do not +bypass this consent. + +The host-owned `AutomaticPreimageSubmit { rootPublicKey }` authorization request +reads or changes this separate setting. The core verifies the root against the +active session; products cannot administer it. `NotDetermined` and `Denied` +revoke automatic approval without disabling individually reviewed uploads. +Revocation or an account change invalidates a pending consent sheet, and the core +rechecks approval after allowance acquisition. Revocation cannot withdraw a +transaction already handed to the chain backend. + +The core serializes reservations across product connections in its runtime and +uses storage compare-and-exchange for every persisted reservation and consent +edit. Independent runtimes sharing a store therefore share the quota; a stale +consent answer cannot overwrite another runtime's revocation. Storage contention +fails closed. Usage is persisted before contacting the backend. Failed or +cancelled attempts remain charged; the existing internal allowance retry is the +same attempt. Restarting, revoking or regranting does not reset the rolling budget. +Legacy boolean host confirmation callbacks mean `AllowOnce`, never a durable grant. + ## Motivation The current Host API design document defines two permission functions: diff --git a/docs/rfcs/sso-request-cancellation.md b/docs/rfcs/sso-request-cancellation.md index 4ea2cb7256..52793d9319 100644 --- a/docs/rfcs/sso-request-cancellation.md +++ b/docs/rfcs/sso-request-cancellation.md @@ -52,6 +52,16 @@ and reads the next message only after the current one is answered. - **Peers without it.** The Rust responder, iOS and Android were each checked: an undecodable `Cancel` is logged, the session continues, and the target is served as before. Sent alone, it costs no other message. +## Response correlation + +The pairing host authenticates and decrypts each statement before reading its message headers. A response with a +readable `responding_to` for another request is ignored before decoding its payload, so a truncated late response cannot +fail the current call. Response discriminants come from the existing SCALE enum, including explicitly indexed +extensions; there is no second hand-maintained tag list. + +Malformed current-response payloads and unreadable headers remain errors. Statement acknowledgements still use their own +request id, and matching replies and disconnects retain wire order. + ## Trade-offs - A cancel is fire-and-forget, and pairing negotiates no capabilities. `Cancelled` tells a product it stopped waiting, diff --git a/hosts/android/README.md b/hosts/android/README.md index 61e137e720..77fab725c2 100644 --- a/hosts/android/README.md +++ b/hosts/android/README.md @@ -34,6 +34,26 @@ - **Manual backups** — Keep your account stored only in the secure enclave storage locally. - **Customization** — Fully customizable UI design system with 5 default themes. +### Bulletin upload consent + +Native TrUAPI uploads ask for **Upload once**, **bounded automatic uploads**, or +**Reject**. The review displays the product, root account, Bulletin genesis, +upload size, and host-owned limits. Automatic consent applies only to that +product/account/network: at most 262144 bytes per upload and four automatic +uploads per rolling hour. Larger uploads and exhausted budgets still prompt; +AutoSigning and trusted-product status do not grant upload consent. + +App permissions includes a separate **Automatic Bulletin uploads** setting backed +by CoreAdmin. Revoking or resetting it removes only automatic approval: individual +uploads can still ask, and usage is not reset. The setting follows the active +account and rejects actions from an earlier account's row; it is unavailable +without an authenticated account snapshot. + +These settings coexist with the core-owned camera, microphone, and scoped Calling +permissions. Automatic-upload consent does not authorize Media, and Media +revocation still refreshes live executions. Wallet changes invalidate the settings +snapshot while the shared runtime disconnects and activates the next account. + ## Getting started
@@ -172,6 +192,13 @@ starts or after it tears down its component. Tests needing app dependencies use the debug-only `IntegrationTestEntryPoint`; production service injection remains unchanged. +Network security exceptions in `app/src/main/res/xml/network_security_config.xml` +are shared by all host features. Declare each hostname only once: Android rejects +duplicate domains while initializing the application, before instrumentation can +run. Product bridges use the exact `127.0.0.1` and `localhost` exceptions; keep +the default cleartext policy disabled and do not duplicate these entries when +adding a bridge. + The app talks to Polkadot system chains (People Chain, Asset Hub, Bulletin Chain). Which chains and RPC nodes it uses is not hard-coded: the `chains` / `chains_v2` Remote Config keys of your Firebase project define the set, so a fork can point the same build at Polkadot, at the diff --git a/hosts/android/app/src/androidTest/java/io/paritytech/polkadotapp/app/MediaPermissionIntegrationTest.kt b/hosts/android/app/src/androidTest/java/io/paritytech/polkadotapp/app/MediaPermissionIntegrationTest.kt index 85a252bc9d..41ba446161 100644 --- a/hosts/android/app/src/androidTest/java/io/paritytech/polkadotapp/app/MediaPermissionIntegrationTest.kt +++ b/hosts/android/app/src/androidTest/java/io/paritytech/polkadotapp/app/MediaPermissionIntegrationTest.kt @@ -1,7 +1,9 @@ package io.paritytech.polkadotapp.app import android.Manifest +import android.net.Uri import android.webkit.JavascriptInterface +import android.webkit.PermissionRequest import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView @@ -16,11 +18,8 @@ import io.parity.truapi.LocalhostBridgeBootstrap import uniffi.truapi.ProductExecutionConfig import uniffi.truapi.ProductExecutionKind import io.parity.truapi.TrUAPIHostRuntime -import io.paritytech.polkadotapp.common.utils.permissions.PermissionAsker -import io.paritytech.polkadotapp.common.utils.permissions.PermissionResult import io.paritytech.polkadotapp.feature_products_api.model.ProductId import io.paritytech.polkadotapp.feature_products_impl.domain.hostApi.FixedProductId -import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.handlers.DeviceCapabilityPermissionHandler import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIBootstrapInstaller import io.paritytech.polkadotapp.feature_products_impl.domain.webView.ProductWebChromeClient import kotlinx.coroutines.CoroutineScope @@ -35,6 +34,7 @@ import org.junit.runner.RunWith import uniffi.truapi.HostDevicePermissionRequest import uniffi.truapi.HostFeatureSupportedRequest import uniffi.truapi.RemotePermission +import uniffi.truapi.RemotePermissionRequest import uniffi.truapi.PermissionAuthorizationRequest import uniffi.truapi.PermissionAuthorizationStatus import uniffi.truapi.PermissionDecision @@ -43,12 +43,13 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit +import java.util.UUID import kotlin.io.path.createTempDirectory @RunWith(AndroidJUnit4::class) class MediaPermissionIntegrationTest { @Test - fun rustConsentPrecedesNativeCaptureAndAllowOnceIsConsumedOnce() { + fun productRawMediaRemainsDeniedDespiteDeviceAndGenericNetworkGrants() { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext for (permission in listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) { @@ -57,14 +58,8 @@ class MediaPermissionIntegrationTest { val bridge = PermissionBridge() val reports = Reports() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - val permissionAsker = object : PermissionAsker by unused() { - override suspend fun askPermission(vararg permissions: String): PermissionResult { - bridge.events.add("native:${permissions.single()}") - return PermissionResult.GRANTED - } - } val config = HostRuntimeConfig( - hostName = "Media permission test", + hostName = "Media isolation test", peopleChainGenesisHash = ByteArray(32), bulletinChainGenesisHash = ByteArray(32), assetHubChainGenesisHash = ByteArray(32), @@ -76,9 +71,14 @@ class MediaPermissionIntegrationTest { for (capability in listOf(HostDevicePermissionRequest.CAMERA, HostDevicePermissionRequest.MICROPHONE)) { runtime.setPermissionAuthorizationStatus( "media.paseo", PermissionAuthorizationRequest.Device(capability), - PermissionAuthorizationStatus.NOT_DETERMINED, + PermissionAuthorizationStatus.AUTHORIZED, ) } + runtime.setPermissionAuthorizationStatus( + "media.paseo", + PermissionAuthorizationRequest.Remote(RemotePermissionRequest(RemotePermission.WebRtc)), + PermissionAuthorizationStatus.AUTHORIZED, + ) } runtime.openProductExecution(bridge, ProductExecutionConfig("media.paseo", ProductExecutionKind.APP)).use { execution -> val endpoint = execution.startWsBridge() @@ -86,10 +86,17 @@ class MediaPermissionIntegrationTest { instrumentation.runOnMainSync { webView = WebView(context) webView.settings.javaScriptEnabled = true - webView.webChromeClient = ProductWebChromeClient( - unused(), DeviceCapabilityPermissionHandler(unused(), unused(), permissionAsker), - unused(), unused(), "Media test", FixedProductId(ProductId.fromStoredValue("media.paseo")), scope, null, - ) + val chrome = ProductWebChromeClient(unused(), unused(), "Media test", + FixedProductId(ProductId.fromStoredValue("media.paseo")), scope, null) + webView.webChromeClient = chrome + var nativeDenied = false + chrome.onPermissionRequest(object : PermissionRequest() { + override fun getOrigin(): Uri = Uri.parse("http://localhost") + override fun getResources() = arrayOf(RESOURCE_VIDEO_CAPTURE, RESOURCE_AUDIO_CAPTURE) + override fun grant(resources: Array) { error("Raw native capture was granted") } + override fun deny() { nativeDenied = true } + }) + assertTrue("Native capture must be denied independently of script policy", nativeDenied) webView.addJavascriptInterface(reports, "mediaReport") webView.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest) = @@ -100,42 +107,13 @@ class MediaPermissionIntegrationTest { )(webView) webView.loadUrl("http://localhost/") } - fun call(script: String, expected: String) { - instrumentation.runOnMainSync { - webView.evaluateJavascript( - "Promise.resolve().then(() => $script).then(value => mediaReport.result(String(value)))" + - ".catch(error => mediaReport.result(error.name));", - null, - ) - } - assertEquals(script, expected, reports.results.poll(15, TimeUnit.SECONDS)) - } try { assertTrue("fixture did not load", reports.ready.await(15, TimeUnit.SECONDS)) - for ((capability, manifest) in listOf( - HostDevicePermissionRequest.CAMERA to Manifest.permission.CAMERA, - HostDevicePermissionRequest.MICROPHONE to Manifest.permission.RECORD_AUDIO, - )) { - bridge.events.clear() - val name = if (capability == HostDevicePermissionRequest.CAMERA) "Camera" else "Microphone" - bridge.decisions.add(PermissionDecision.ALLOW_ONCE) - call( - "window.__HOST_API_CLIENT__.client.permissions.requestDevicePermission('$name')" + - ".match(value => value.granted, error => { throw error; })", - "true", - ) - call("capture('$name')", "captured") - assertEquals( - listOf("prompt:$capability", "native:$manifest"), - bridge.events.toList(), - ) - bridge.decisions.add(PermissionDecision.DENY) - call("capture('$name')", "NotAllowedError") - assertEquals( - listOf("prompt:$capability", "native:$manifest", "prompt:$capability"), - bridge.events.toList(), - ) + instrumentation.runOnMainSync { + webView.evaluateJavascript("probe().then(value => mediaReport.result(value)).catch(error => mediaReport.result(String(error)));", null) } + assertEquals("camera:denied,microphone:denied,display:denied,rtc:denied,fullscreen:denied", + reports.results.poll(15, TimeUnit.SECONDS)) } finally { instrumentation.runOnMainSync { webView.destroy() } scope.cancel() @@ -146,26 +124,20 @@ class MediaPermissionIntegrationTest { private class PermissionBridge : HostBridge { override fun permissionAuthorizationsChanged(productId: String) = Unit - val decisions = LinkedBlockingQueue() - val events = LinkedBlockingQueue() override val storage: HostStorage = unused() override val coreStorage = object : HostCoreStorage { + override val storageIdentifier = "media-isolation-${UUID.randomUUID()}" private val values = ConcurrentHashMap, ByteArray>() + override suspend fun read(key: ByteArray): ByteArray? = values[key.toList()]?.copyOf() + override suspend fun write(key: ByteArray, value: ByteArray) { values[key.toList()] = value.copyOf() } override suspend fun keys(): List = values.keys.map { it.toByteArray() } - override suspend fun read(key: ByteArray): ByteArray? = values[key.toList()] - override suspend fun write(key: ByteArray, value: ByteArray) { values[key.toList()] = value } override suspend fun clear(key: ByteArray) { values.remove(key.toList()) } } override suspend fun navigateTo(url: String) = Unit override suspend fun featureSupported(request: HostFeatureSupportedRequest) = false override suspend fun remotePermission(product: ProductExecutionConfig, request: RemotePermission) = PermissionDecision.DENY - override suspend fun devicePermission( - product: ProductExecutionConfig, - request: HostDevicePermissionRequest, - ): PermissionDecision { - events.add("prompt:$request") - return checkNotNull(decisions.poll()) { "Unexpected extra permission prompt" } - } + override suspend fun devicePermission(product: ProductExecutionConfig, request: HostDevicePermissionRequest): PermissionDecision = + error("Raw capture must never prompt through Core Media") } private class Reports { @@ -183,12 +155,19 @@ class MediaPermissionIntegrationTest { private const val PAGE = """ diff --git a/hosts/android/app/src/main/AndroidManifest.xml b/hosts/android/app/src/main/AndroidManifest.xml index 6db21bfd79..f1a1b58755 100644 --- a/hosts/android/app/src/main/AndroidManifest.xml +++ b/hosts/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,8 @@ + + @@ -138,6 +140,16 @@ + + + + + + 127.0.0.1 + localhost + 163.172.160.22 192.168.100.3 - - - 127.0.0.1 - diff --git a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/ContainerHttpAuthorizationTest.kt b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/ContainerHttpAuthorizationTest.kt index 3642de8496..f924231bc1 100644 --- a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/ContainerHttpAuthorizationTest.kt +++ b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/ContainerHttpAuthorizationTest.kt @@ -187,6 +187,7 @@ class ContainerHttpAuthorizationTest { } private class MemoryStorage : HostStorage, HostCoreStorage { + override val storageIdentifier = "http-policy-${java.util.UUID.randomUUID()}" private val values = ConcurrentHashMap() override suspend fun keys(): List = values.keys.filterIsInstance>().map { it.toByteArray() } diff --git a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/CoreDatabaseTest.kt b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/CoreDatabaseTest.kt index 838cb82184..b52e5a1663 100644 --- a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/CoreDatabaseTest.kt +++ b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/CoreDatabaseTest.kt @@ -28,10 +28,14 @@ import uniffi.truapi.RemotePermission class CoreDatabaseTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val directory = context.noBackupFilesDir.resolve("truapi-test-${UUID.randomUUID()}") + private val productStorageName = "${directory.name}_product" + private val coreStorageName = "${directory.name}_core" @After fun cleanUp() { directory.deleteRecursively() + context.deleteSharedPreferences(productStorageName) + context.deleteSharedPreferences(coreStorageName) } @Test @@ -66,10 +70,11 @@ class CoreDatabaseTest { private inner class InertBridge : HostBridge { override fun permissionAuthorizationsChanged(productId: String) = Unit override val storage = PrefsHostStorage( - context.getSharedPreferences("truapi_core_db_test_product", android.content.Context.MODE_PRIVATE), + context.getSharedPreferences(productStorageName, android.content.Context.MODE_PRIVATE), ) override val coreStorage = PrefsHostCoreStorage( - context.getSharedPreferences("truapi_core_db_test_core", android.content.Context.MODE_PRIVATE), + context.getSharedPreferences(coreStorageName, android.content.Context.MODE_PRIVATE), + File(context.applicationInfo.dataDir, "shared_prefs/$coreStorageName.xml").canonicalPath, ) override suspend fun navigateTo(url: String) = Unit diff --git a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PermissionAdministrationTest.kt b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PermissionAdministrationTest.kt index fa18ea56e7..99a9dfb7f2 100644 --- a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PermissionAdministrationTest.kt +++ b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PermissionAdministrationTest.kt @@ -48,6 +48,8 @@ class PermissionAdministrationTest { val revision = runtime.permissionAuthorizationRevision("game.paseo") runtime.setPermissionAuthorizationStatus("game.paseo", request, PermissionAuthorizationStatus.DENIED) assertTrue(execution.isClosed()) + // The deferred Media fanout must use root authority after canonical closure. + runtime.awaitCoreStorageChanges() assertFalse(other.isClosed()) assertTrue(bridge.changed.contains("game.paseo")) assertFalse(runtime.setPermissionAuthorizationStatusIfCurrent( @@ -65,6 +67,7 @@ class PermissionAdministrationTest { assertEquals(PermissionAuthorizationStatus.NOT_DETERMINED, runtime.permissionAuthorizations("game.paseo").single().status) runtime.setPermissionAuthorizationStatus("game.paseo", request, PermissionAuthorizationStatus.DENIED) assertTrue(reopened.isClosed()) + runtime.awaitCoreStorageChanges() } bridge.store.failWrites = true assertTrue(runCatching { @@ -87,14 +90,15 @@ class PermissionAdministrationTest { } private class Storage : HostCoreStorage, HostStorage { + override val storageIdentifier = "permission-admin-${java.util.UUID.randomUUID()}" private val core = ConcurrentHashMap, ByteArray>() private val product = ConcurrentHashMap() @Volatile var failWrites = false override suspend fun keys(): List = core.keys.map { it.toByteArray() } - override suspend fun read(key: ByteArray): ByteArray? = core[key.toList()] + override suspend fun read(key: ByteArray): ByteArray? = core[key.toList()]?.copyOf() override suspend fun write(key: ByteArray, value: ByteArray) { if (failWrites) throw HostRejection.Rejected("disk full") - core[key.toList()] = value + core[key.toList()] = value.copyOf() } override suspend fun clear(key: ByteArray) { core.remove(key.toList()) } override suspend fun read(key: String): ByteArray? = product[key] diff --git a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PrefsHostStorage.kt b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PrefsHostStorage.kt index 57e3e78898..36bd00a540 100644 --- a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PrefsHostStorage.kt +++ b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/PrefsHostStorage.kt @@ -31,12 +31,17 @@ class PrefsHostStorage(private val prefs: SharedPreferences) : HostStorage { * decisions. Uses `commit()` because a write the core believes succeeded must * not be lost on process death; failures surface as the declared [HostRejection]. */ -class PrefsHostCoreStorage(private val prefs: SharedPreferences) : HostCoreStorage { +class PrefsHostCoreStorage( + private val prefs: SharedPreferences, + override val storageIdentifier: String, +) : HostCoreStorage { + override suspend fun read(key: ByteArray): ByteArray? { + val stored = prefs.getString(bytesToHex(key), null) ?: return null + return decodeOrNull(stored) ?: throw HostRejection.Rejected("corrupt core storage value") + } override suspend fun keys(): List = prefs.all.keys.map { key -> - decodeOrNull(key) ?: error("Invalid core storage key") + decodeOrNull(key) ?: throw HostRejection.Rejected("corrupt core storage key") } - override suspend fun read(key: ByteArray): ByteArray? = - decodeOrNull(prefs.getString(bytesToHex(key), null)) override suspend fun write(key: ByteArray, value: ByteArray) { if (!prefs.edit().putString(bytesToHex(key), bytesToHex(value)).commit()) { throw HostRejection.Rejected("failed to persist core storage key") diff --git a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/TrUAPIDiagnosticsTest.kt b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/TrUAPIDiagnosticsTest.kt index f849749aa8..9655854bf1 100644 --- a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/TrUAPIDiagnosticsTest.kt +++ b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/TrUAPIDiagnosticsTest.kt @@ -81,6 +81,7 @@ class TrUAPIDiagnosticsTest { ) override val coreStorage = PrefsHostCoreStorage( context.getSharedPreferences("truapi_core_storage", android.content.Context.MODE_PRIVATE), + java.io.File(context.applicationInfo.dataDir, "shared_prefs/truapi_core_storage.xml").canonicalPath, ) override fun onCoreLog(marker: String, detail: String) { synchronized(logs) { logs.add("$marker: $detail") } diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt index 925b3815f7..cdf6b207db 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt @@ -17,6 +17,28 @@ class RealPreferences @Inject constructor( @ApplicationContext context: Context ) : Preferences { private val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_FILE, Context.MODE_PRIVATE) + val storageIdentifier: String = + java.io.File(context.applicationInfo.dataDir, "shared_prefs/$SHARED_PREFERENCES_FILE.xml").canonicalPath + private companion object { + // SharedPreferences mutates memory even when commit fails. Keep every + // adapter for those same physical slots from treating that value as durable. + val physicalFailures = java.util.concurrent.ConcurrentHashMap>() + } + private val failedCommits = physicalFailures.computeIfAbsent(storageIdentifier) { + java.util.concurrent.ConcurrentHashMap.newKeySet() + } + + fun putStringCommitted(field: String, value: String) { + failedCommits.add(field) + check(sharedPreferences.edit().putString(field, value).commit()) { "Preferences commit failed" } + failedCommits.remove(field) + } + + fun removeFieldCommitted(field: String) { + failedCommits.add(field) + check(sharedPreferences.edit().remove(field).commit()) { "Preferences commit failed" } + failedCommits.remove(field) + } /* SharedPreferencesImpl stores listeners in a WeakHashMap, @@ -27,7 +49,10 @@ class RealPreferences @Inject constructor( */ private val listeners = mutableSetOf() - override fun contains(field: String) = sharedPreferences.contains(field) + override fun contains(field: String): Boolean { + check(field !in failedCommits) { "Preferences durability unavailable" } + return sharedPreferences.contains(field) + } override fun keys(): Set = sharedPreferences.all.keys @@ -42,10 +67,12 @@ class RealPreferences @Inject constructor( field: String, defaultValue: String, ): String { + check(field !in failedCommits) { "Preferences durability unavailable" } return sharedPreferences.getString(field, defaultValue) ?: defaultValue } override fun getString(field: String): String? { + check(field !in failedCommits) { "Preferences durability unavailable" } return sharedPreferences.getString(field, null) } diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt index 1f14414106..4d136fc1b9 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt @@ -3,6 +3,12 @@ package io.paritytech.polkadotapp.common.data.storage.preferences.encrypted import kotlinx.coroutines.flow.Flow interface EncryptedPreferences { + /** Physical namespace shared by adapters of these same preference slots. */ + val storageIdentifier: String + + /** Return only after durable persistence, throwing if encryption or commit fails. */ + fun putEncryptedStringCommitted(field: String, value: String) + fun removeKeyCommitted(field: String) fun keys(): Set fun putEncryptedString( diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt index 69eaddfbd5..d6fe8cc30a 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt @@ -1,6 +1,6 @@ package io.paritytech.polkadotapp.common.data.storage.preferences.encrypted -import io.paritytech.polkadotapp.common.data.storage.preferences.Preferences +import io.paritytech.polkadotapp.common.data.storage.preferences.RealPreferences import io.paritytech.polkadotapp.common.utils.flowOfAll import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -8,9 +8,18 @@ import javax.inject.Singleton @Singleton internal class RealEncryptedPreferences @Inject constructor( - private val preferences: Preferences, + private val preferences: RealPreferences, private val encryptionUtil: EncryptionUtil ) : EncryptedPreferences { + override val storageIdentifier: String get() = preferences.storageIdentifier + + override fun putEncryptedStringCommitted(field: String, value: String) { + val encrypted = encryptionUtil.encrypt(value) + check(encrypted.isNotEmpty()) { "Preference encryption failed" } + preferences.putStringCommitted(field, encrypted) + } + + override fun removeKeyCommitted(field: String) = preferences.removeFieldCommitted(field) override fun keys(): Set = preferences.keys() override fun putEncryptedString( diff --git a/hosts/android/common/src/main/res/values/strings.xml b/hosts/android/common/src/main/res/values/strings.xml index b54416f9fb..f0aa81ee7f 100644 --- a/hosts/android/common/src/main/res/values/strings.xml +++ b/hosts/android/common/src/main/res/values/strings.xml @@ -1629,6 +1629,12 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc Approve main-purse payment? Allow account access? Allow own-account access? + Allow calls for this account and network? + Allow this product to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data. + Authenticated recipient + Recipient amount (asset cents) + Maximum debit including fees (asset cents) + Asset instance Request Call Call data @@ -1645,14 +1651,22 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc Payload size (bytes) Resource Target product - Recipient - Amount (cents) - Maximum debit (cents) - Asset instance Payment operation Requested by %1$s Approve Reject + Upload once + Upload and allow bounded automatic uploads + Root account + Bulletin network (genesis) + Automatic uploads + Allow this product to upload up to %1$s bytes per upload, at most %2$s uploads per rolling %3$s seconds, for this root account and Bulletin network only. Larger uploads and exhausted budgets still ask. Revoke in app permissions; revoking or granting again does not reset usage. + Automatic Bulletin uploads + For this product and root account %1$s on Bulletin network %2$s: up to 262144 bytes per upload, at most 4 uploads per rolling hour. Larger uploads and exhausted budgets still ask. Turning off or resetting revokes only automatic approval; individual uploads can still ask. Usage is not reset. + Allow bounded automatic uploads + Revoke automatic uploads + Reset to ask each time + Automatic-upload settings require an active account. If settings cannot be loaded, reopen this page to retry. Choose a contact %1$s learns only who you choose, and never your contact list. Unnamed contact diff --git a/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContext.kt b/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContext.kt index 8da5b8c8bb..7dbe832893 100644 --- a/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContext.kt +++ b/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContext.kt @@ -8,6 +8,9 @@ interface SigningContext { /** The account this request signs with, resolved before the signing screen is shown. */ val signingAccount: SigningAccount + /** False when approval resumes a core-owned signing operation. */ + val signsOnApproval: Boolean get() = true + /** * Delivers what the waiting caller needs on approval. The native path signs * with [sign] and hands back the signature. The TrUAPI core holds the key diff --git a/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContextHolder.kt b/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContextHolder.kt index 7b0ffbef39..5e47c91efe 100644 --- a/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContextHolder.kt +++ b/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/model/signing/SigningContextHolder.kt @@ -7,10 +7,12 @@ import javax.inject.Singleton class SigningContextHolder @Inject constructor() { private var context: SigningContext? = null + @Synchronized fun set(context: SigningContext) { this.context = context } + @Synchronized fun get(): SigningContext? = context /** @@ -19,6 +21,7 @@ class SigningContextHolder @Inject constructor() { * the holder may already carry the next request's context; an * unconditional clear would wipe it and crash that screen on recreation. */ + @Synchronized fun clear(owner: SigningContext) { if (context === owner) { context = null diff --git a/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/presentation/spaHost/ProductWebViewHost.kt b/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/presentation/spaHost/ProductWebViewHost.kt index 209ad7fdb9..8364c5a7f9 100644 --- a/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/presentation/spaHost/ProductWebViewHost.kt +++ b/hosts/android/feature/products/api/src/main/kotlin/io/paritytech/polkadotapp/feature_products_api/presentation/spaHost/ProductWebViewHost.kt @@ -19,7 +19,7 @@ fun ProductWebViewHost(modifier: Modifier = Modifier, webView: WebView?) { modifier = modifier, factory = { context -> FrameLayout(context) }, update = { host -> - if (host.getChildAt(0) !== webView) { + if (webView?.parent !== host) { host.removeAllViews() webView?.let { (it.parent as? ViewGroup)?.removeView(it) diff --git a/hosts/android/feature/products/impl/build.gradle.kts b/hosts/android/feature/products/impl/build.gradle.kts index 415da595a0..77b55c498f 100644 --- a/hosts/android/feature/products/impl/build.gradle.kts +++ b/hosts/android/feature/products/impl/build.gradle.kts @@ -79,6 +79,7 @@ dependencies { implementation(project(":design")) implementation(project(":database")) implementation(project(":chains")) + implementation(project(":tools:media-connection:impl")) implementation(project(":feature:chats:api")) implementation(project(":feature:account:api")) implementation(project(":feature:settings:api")) diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt index e6a9c8b0a8..9541d8617a 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import timber.log.Timber +import uniffi.truapi.hasTrustedRemotePermissions import javax.inject.Inject import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -299,7 +300,16 @@ class HostApiInteractor @Inject constructor( capability: DeviceCapabilityType, ): Result = runCatching { val permission = ProductPermission.DeviceCapability(capability) - val decision = permissionRequester.prompt(callingProductId, permission) + // The core has already checked stored refusals. Trust removes only the + // product prompt; the host's OS authorization below is still required. + val decision = if ( + capability == DeviceCapabilityType.Notifications && + hasTrustedRemotePermissions(callingProductId.value) + ) { + PermissionDecision.AllowAlways + } else { + permissionRequester.prompt(callingProductId, permission) + } if (decision != PermissionDecision.Deny && !deviceCapabilityPermissionHandler.requestOsPermissionIfNeeded(capability) ) { diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/NativeMediaPermissions.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/NativeMediaPermissions.kt new file mode 100644 index 0000000000..7b81d50922 --- /dev/null +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/NativeMediaPermissions.kt @@ -0,0 +1,116 @@ +package io.paritytech.polkadotapp.feature_products_impl.domain.hostApi + +import android.Manifest +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import io.parity.truapi.TrUAPIProductExecution +import io.paritytech.polkadotapp.feature_account_api.data.repository.AccountRepository +import io.paritytech.polkadotapp.feature_products_api.model.ProductId +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.EncryptedHostCoreStorage +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.ProductTrUAPIHostBridge +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIHostRuntimeProvider +import io.paritytech.polkadotapp.tools_media_connection_impl.nativeMedia.NativeMediaConsent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import uniffi.truapi.HostDevicePermissionRequest +import uniffi.truapi.PermissionAuthorizationRequest +import uniffi.truapi.PermissionAuthorizationStatus +import javax.inject.Inject +import javax.inject.Singleton + +data class NativeMediaPermissionStatus( + val request: PermissionAuthorizationRequest, + val granted: Boolean, + val ownerAccountId: Long, + val productId: String, + val runtimeGeneration: Long, +) + +/** Trusted settings for the same process runtime and physical policy slots as live products. */ +@Singleton +class NativeMediaPermissions @Inject constructor( + @param:ApplicationContext private val context: Context, + private val accounts: AccountRepository, + private val runtimeProvider: TrUAPIHostRuntimeProvider, + private val bridgeFactory: ProductTrUAPIHostBridge.Factory, +) { + fun observeMediaPermissions(product: ProductId): Flow> = flow { + runtimeProvider.runtime().getOrThrow() + emitAll(combine(EncryptedHostCoreStorage.storageChanges, runtimeProvider.sessionRevision) { _, _ -> + withPermissionAdmin(product) { execution -> + runtimeProvider.withSessionMutation { + val owner = accounts.getWalletAccount().id + check(runtimeProvider.sessionOwner == owner) { "Wallet session changed" } + val requests = listOf( + PermissionAuthorizationRequest.Device(HostDevicePermissionRequest.CAMERA), + PermissionAuthorizationRequest.Device(HostDevicePermissionRequest.MICROPHONE), + execution.callingPermissionAuthorizationRequest(), + ) + requests.map { request -> + NativeMediaPermissionStatus(request, + execution.permissionAuthorizationStatus(request) == PermissionAuthorizationStatus.AUTHORIZED, + owner, product.value, runtimeProvider.sessionRevision.value) + } + } + } + }) + } + + suspend fun toggleMediaPermission(product: ProductId, permission: NativeMediaPermissionStatus) { + val runtime = runtimeProvider.runtime().getOrThrow() + withPermissionAdmin(product) { execution -> + runtimeProvider.withSessionMutation { checkScope(product, permission, execution) } + val request = permission.request + val granted = if (permission.granted) false else when (request) { + is PermissionAuthorizationRequest.Calling -> + NativeMediaConsent.calling(context, product.value, request.network, request.account) + is PermissionAuthorizationRequest.Device -> NativeMediaConsent.device(context, product.value, + when (request.v1) { + HostDevicePermissionRequest.CAMERA -> Manifest.permission.CAMERA + HostDevicePermissionRequest.MICROPHONE -> Manifest.permission.RECORD_AUDIO + else -> error("Not a Media device permission") + }) + else -> error("Not a Media permission") + } + runtimeProvider.withSessionMutation { + checkScope(product, permission, execution) + withContext(Dispatchers.IO) { + runtime.setPermissionAuthorizationStatus(product.value, request, + if (granted) PermissionAuthorizationStatus.AUTHORIZED else PermissionAuthorizationStatus.DENIED) + } + } + // Commit and enqueue happen atomically in the SDK. Never await a refresh under + // the session mutation gate: refresh may need that same runtime to make progress. + runtime.awaitCoreStorageChanges() + } + } + + private suspend fun checkScope(product: ProductId, permission: NativeMediaPermissionStatus, execution: TrUAPIProductExecution) { + check(product.value == permission.productId && + runtimeProvider.sessionOwner == permission.ownerAccountId && + accounts.getWalletAccount().id == permission.ownerAccountId && + runtimeProvider.sessionRevision.value == permission.runtimeGeneration) { "Wallet session changed" } + if (permission.request is PermissionAuthorizationRequest.Calling) { + val current = execution.callingPermissionAuthorizationRequest() as? PermissionAuthorizationRequest.Calling + check(current != null && current.network.contentEquals(permission.request.network) && + current.account.contentEquals(permission.request.account)) { "Calling identity changed" } + } + } + + private suspend fun withPermissionAdmin(product: ProductId, block: suspend (TrUAPIProductExecution) -> T): T = coroutineScope { + val runtime = runtimeProvider.runtime().getOrThrow() + val bridge = bridgeFactory.create(this) + var opened: TrUAPIProductExecution? = null + try { + withContext(Dispatchers.IO) { opened = bridge.openPermissionExecution(runtime, product) } + block(requireNotNull(opened)) + } finally { + opened?.close() + } + } +} diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/handlerGroups/SigningHostCalls.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/handlerGroups/SigningHostCalls.kt index 353f2069a0..a07521831c 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/handlerGroups/SigningHostCalls.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/handlerGroups/SigningHostCalls.kt @@ -147,7 +147,7 @@ private data class SignRawLegacyParams( fun toSignRawPayload(): RawPayloadContent = rawPayloadContentOf(data, payload) } -private fun String.parseSigner(): AccountId { +internal fun String.parseSigner(): AccountId { return runCatching { toAccountId().intoAccountId() } .recover { fromHex().intoAccountId() } .getOrThrow() diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt index e01fea41af..39113054df 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt @@ -42,7 +42,16 @@ internal fun ProductPermission.canonicalRequest(): PermissionAuthorizationReques })) } +/** Account-scoped Media and upload permissions have their own settings surfaces. */ +internal fun PermissionAuthorizationRequest.hasDedicatedSettings(): Boolean = when (this) { + is PermissionAuthorizationRequest.Calling, is PermissionAuthorizationRequest.AutomaticPreimageSubmit -> true + is PermissionAuthorizationRequest.Remote -> v1.permission == RemotePermission.Calling + else -> false +} + internal fun PermissionAuthorizationRequest.legacyPermission(): ProductPermission = when (this) { + is PermissionAuthorizationRequest.Calling, is PermissionAuthorizationRequest.AutomaticPreimageSubmit -> + error("Account-scoped permission requires dedicated settings") is PermissionAuthorizationRequest.Device -> ProductPermission.DeviceCapability(DeviceCapabilityType.entries.single { it.native() == v1 }) is PermissionAuthorizationRequest.AccountAccess -> ProductPermission.AccountAccess(targetProductId) PermissionAuthorizationRequest.IdentityDisclosure -> ProductPermission.UserIdentityAccess @@ -63,6 +72,7 @@ internal fun PermissionAuthorizationRequest.legacyPermission(): ProductPermissio else ProductPermission.RemotePermission.NetworkAccessSet(domains) } RemotePermission.WebRtc -> ProductPermission.RemotePermission.WebRtcAccess + RemotePermission.Calling -> error("Calling requires scoped Media settings") RemotePermission.ChainSubmit -> ProductPermission.RemotePermission.ChainSubmitAccess RemotePermission.StatementSubmit -> ProductPermission.RemotePermission.StatementSubmitAccess RemotePermission.PreimageSubmit -> ProductPermission.RemotePermission.PreimageSubmitAccess diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepository.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepository.kt index d5b60527b9..7e6eeee189 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepository.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepository.kt @@ -142,30 +142,37 @@ class RealProductPermissionRepository @Inject constructor( override suspend fun grant(productId: ProductId, permission: ProductPermission) = set(productId, permission, true) override suspend fun revoke(productId: ProductId, permission: ProductPermission) = set(productId, permission, false) - private suspend fun set(productId: ProductId, permission: ProductPermission, granted: Boolean) = mutationMutex.withLock { - checkRevision(productId, permission) - val product = id(productId) - val request = permission.canonicalRequest() - val revision = currentCoroutineContext()[RequestRevision] - if (!granted) { - changes.changed(product) - oneTimeGrants.keys.removeAll { it.product == product } - } - if (request != null) { - val runtime = runtime() - val status = if (granted) PermissionAuthorizationStatus.AUTHORIZED else PermissionAuthorizationStatus.DENIED - if (revision == null) { - runtime.setPermissionAuthorizationStatus(product, request, status) - } else if (!runtime.setPermissionAuthorizationStatusIfCurrent(product, request, status, requireNotNull(revision.core))) { - throw ProductPermissionDeniedException(permission) + private suspend fun set(productId: ProductId, permission: ProductPermission, granted: Boolean) { + val changedRuntime = mutationMutex.withLock { + checkRevision(productId, permission) + val product = id(productId) + val request = permission.canonicalRequest() + val revision = currentCoroutineContext()[RequestRevision] + if (!granted) { + changes.changed(product) + oneTimeGrants.keys.removeAll { it.product == product } } - } else { - dao.insert(ProductPermissionGrantLocal(productId.value, permission.typeName, permission.key, granted, System.currentTimeMillis())) - changes.changed(product) + val changedRuntime = if (request != null) { + val runtime = runtime() + val status = if (granted) PermissionAuthorizationStatus.AUTHORIZED else PermissionAuthorizationStatus.DENIED + if (revision == null) { + runtime.setPermissionAuthorizationStatus(product, request, status) + } else if (!runtime.setPermissionAuthorizationStatusIfCurrent(product, request, status, requireNotNull(revision.core))) { + throw ProductPermissionDeniedException(permission) + } + runtime + } else { + dao.insert(ProductPermissionGrantLocal(productId.value, permission.typeName, permission.key, granted, System.currentTimeMillis())) + changes.changed(product) + null + } + // Conditional authorization preserves the core revision. Never recapture it here: + // an external revoke must remain visible to the next write in a batched prompt. + if (request == null) revision?.local = changes.revision(product) + changedRuntime } - // Conditional authorization preserves the core revision. Never recapture it here: - // an external revoke must remain visible to the next write in a batched prompt. - if (request == null) revision?.local = changes.revision(product) + // Cross-root Media refresh may re-enter host authority; never drain under the mutation lock. + changedRuntime?.awaitCoreStorageChanges() } override suspend fun getAllByProduct(productId: ProductId): List { @@ -175,7 +182,7 @@ class RealProductPermissionRepository @Inject constructor( val canonical = canonicalEntries(productId, legacy) val result = linkedMapOf() legacy.filter { it.permission.canonicalRequest() == null }.forEach { result[it.permission] = it } - canonical.forEach { entry -> + canonical.filterNot { it.request.hasDedicatedSettings() }.forEach { entry -> val permission = entry.request.legacyPermission() result[permission] = ProductPermissionStatus(permission, entry.status == PermissionAuthorizationStatus.AUTHORIZED) } @@ -202,7 +209,7 @@ class RealProductPermissionRepository @Inject constructor( private fun effectiveStatuses( productId: ProductId, entries: List, - ): Map = entries.associate { entry -> + ): Map = entries.filterNot { it.request.hasDedicatedSettings() }.associate { entry -> key(productId, entry.request.legacyPermission()) to entry.status } @@ -213,6 +220,14 @@ class RealProductPermissionRepository @Inject constructor( observeAllByProduct(productId).map { it.isNotEmpty() } override suspend fun revokeAllByProduct(productId: ProductId) { + val dedicated = canonicalEntries(productId).filter { it.request.hasDedicatedSettings() } getAllByProduct(productId).forEach { revoke(productId, it.permission) } + if (dedicated.isNotEmpty()) { + val runtime = runtime() + for (entry in dedicated) { + runtime.setPermissionAuthorizationStatus(id(productId), entry.request, PermissionAuthorizationStatus.DENIED) + } + runtime.awaitCoreStorageChanges() + } } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/productPermissions/ProductPermissionsInteractor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/productPermissions/ProductPermissionsInteractor.kt index d4cbeacca7..e88d3817e7 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/productPermissions/ProductPermissionsInteractor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/productPermissions/ProductPermissionsInteractor.kt @@ -5,21 +5,107 @@ import io.paritytech.polkadotapp.feature_products_api.model.ProductId import io.paritytech.polkadotapp.feature_products_impl.data.repository.ProductRepository import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.ProductPermissionRepository import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.models.ProductPermissionStatus +import io.paritytech.polkadotapp.feature_products_impl.domain.hostApi.NativeMediaPermissions +import io.paritytech.polkadotapp.feature_products_impl.domain.hostApi.NativeMediaPermissionStatus +import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.models.ProductPermission +import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.models.DeviceCapabilityType +import kotlinx.coroutines.flow.map +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIHostRuntimeProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update +import uniffi.truapi.AuthState +import uniffi.truapi.PermissionAuthorizationRequest +import uniffi.truapi.PermissionAuthorizationStatus import kotlinx.coroutines.flow.Flow import javax.inject.Inject class ProductPermissionsInteractor @Inject constructor( private val productRepository: ProductRepository, - private val permissionRepository: ProductPermissionRepository + private val permissionRepository: ProductPermissionRepository, + private val mediaPermissions: NativeMediaPermissions, + private val runtimeProvider: TrUAPIHostRuntimeProvider, ) { + private val automaticPermissionRevision = MutableStateFlow(0L) + + @OptIn(ExperimentalCoroutinesApi::class) + fun observeAutomaticUploads(productId: ProductId): Flow = flow { + val runtime = runtimeProvider.runtime().getOrThrow() + emitAll( + combine(runtimeProvider.sessionState, automaticPermissionRevision) { session, _ -> session } + .transformLatest { session -> + emit(null) + if (session !is AuthState.Connected) return@transformLatest + val root = runtime.currentSessionPublicKey() ?: return@transformLatest + if (!root.contentEquals(session.v1.publicKey)) return@transformLatest + val genesis = runtimeProvider.bulletinGenesisHash ?: return@transformLatest + try { + val status = runtime.permissionAuthorizationStatus( + productId.value, PermissionAuthorizationRequest.AutomaticPreimageSubmit(root), + ) + if (isCurrentAccount(root) && root.contentEquals(runtime.currentSessionPublicKey())) { + emit(AutomaticPreimagePermission(root, genesis.toHexString(withPrefix = true), status)) + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + // No stale or guessed grant is displayed when the read fails. + emit(null) + } + }, + ) + } + + suspend fun setAutomaticUploads( + productId: ProductId, + rendered: AutomaticPreimagePermission, + status: PermissionAuthorizationStatus, + ) { + check(isCurrentAccount(rendered.rootPublicKey)) { "Account changed; reopen app permissions" } + val runtime = runtimeProvider.runtime().getOrThrow() + check(isCurrentAccount(rendered.rootPublicKey) && rendered.rootPublicKey.contentEquals(runtime.currentSessionPublicKey())) { + "Account changed; reopen app permissions" + } + try { + runtime.setPermissionAuthorizationStatus( + productId.value, + PermissionAuthorizationRequest.AutomaticPreimageSubmit(rendered.rootPublicKey), + status, + ) + } finally { + automaticPermissionRevision.update { it + 1 } + } + } + + private fun isCurrentAccount(root: ByteArray): Boolean = + (runtimeProvider.sessionState.value as? AuthState.Connected)?.v1?.publicKey?.contentEquals(root) == true + suspend fun getProduct(productId: ProductId): Product? { return productRepository.getProductById(productId) } fun observePermissions(productId: ProductId): Flow> { - return permissionRepository.observeAllByProduct(productId) + return permissionRepository.observeAllByProduct(productId).map { permissions -> + permissions.filterNot { + when ((it.permission as? ProductPermission.DeviceCapability)?.capability) { + DeviceCapabilityType.Camera, DeviceCapabilityType.Microphone -> true + else -> false + } + } + } } + fun observeMediaPermissions(productId: ProductId) = mediaPermissions.observeMediaPermissions(productId) + + suspend fun toggleMediaPermission(productId: ProductId, permission: NativeMediaPermissionStatus) = + mediaPermissions.toggleMediaPermission(productId, permission) + suspend fun togglePermission(productId: ProductId, permissionStatus: ProductPermissionStatus) { if (permissionStatus.granted.not()) { permissionRepository.grant(productId, permissionStatus.permission) @@ -28,3 +114,9 @@ class ProductPermissionsInteractor @Inject constructor( } } } + +data class AutomaticPreimagePermission( + val rootPublicKey: ByteArray, + val genesisHash: String, + val status: PermissionAuthorizationStatus, +) diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/CreateTransactionInteractor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/CreateTransactionInteractor.kt index ac8c9da8b4..caeaf0ebf3 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/CreateTransactionInteractor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/CreateTransactionInteractor.kt @@ -55,8 +55,8 @@ class CreateTransactionInteractor @AssistedInject constructor( override suspend fun humanReadableRepresentation(): Result = runCatching { val runtime = runtime() - val origin = resolveTransactionOrigin().getOrThrow() - val resolved = resolveExtensions(origin).getOrThrow() + // Review only needs the signed-extension interpretation, not a signing key/origin. + val resolved = resolveExtensions(isSigned = true).getOrThrow() val call = GenericCall.fromByteArray(runtime, payload.callData.value) val view = createTransactionHumanReadable(call, resolved.allRequestedExtensions) extrinsicSerializerGson.toJson(view) @@ -98,7 +98,7 @@ class CreateTransactionInteractor @AssistedInject constructor( val runtime = chainRegistry.getRuntime(chainId) return resolveTransactionOrigin().flatMap { origin -> - resolveExtensions(origin).map { resolved -> + resolveExtensions(isSigned = origin is SignedTransactionOrigin).map { resolved -> val call = GenericCall.fromByteArray(runtime, payload.callData.value) ExtrinsicBuildingContext( chain = chain, @@ -111,12 +111,12 @@ class CreateTransactionInteractor @AssistedInject constructor( } } - private suspend fun resolveExtensions(origin: TransactionOrigin): Result { + private suspend fun resolveExtensions(isSigned: Boolean): Result { return extensionsResolver.resolve( extensions = payload.extensions, txExtVersion = payload.txExtVersion, chainId = chainId(), - isSigned = origin is SignedTransactionOrigin, + isSigned = isSigned, ) } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningContext.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningContext.kt index 11fedf71c7..ae432417c0 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningContext.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningContext.kt @@ -14,9 +14,13 @@ class ProductSigningContext( override val signingAccount: SigningAccount, ) : SigningContext { private val result = CompletableDeferred>() + override val signsOnApproval = true - override suspend fun approve(sign: suspend () -> Result): Result = - sign().map { signed -> result.complete(Result.success(signed)) } + override suspend fun approve(sign: suspend () -> Result): Result { + val signed = sign() + signed.onSuccess { result.complete(Result.success(it)) } + return signed.map { Unit } + } override suspend fun deliverRejection(): Result { result.complete(Result.failure(CancellationException("User rejected"))) @@ -25,3 +29,4 @@ class ProductSigningContext( suspend fun awaitResult(): Result = result.await() } + diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningScreenLauncher.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningScreenLauncher.kt index c07fff022c..dcd4bf3d1e 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningScreenLauncher.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/signTransaction/ProductSigningScreenLauncher.kt @@ -6,22 +6,30 @@ import io.paritytech.polkadotapp.feature_products_api.model.signing.SigningConte import io.paritytech.polkadotapp.feature_products_api.model.signing.SigningRequestBody import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Parks a [ProductSigningContext], opens the signing confirmation screen and awaits the user's * decision. Shared by every host-side entry point that needs a confirmation, since neither may * depend on the other: `HostApiInteractor` already injects `AccountsProtocol`. */ +@Singleton class ProductSigningScreenLauncher @Inject constructor( private val signingContextHolder: SigningContextHolder, private val productsRouter: ProductsRouter, ) { + private val reviews = Mutex() + suspend fun awaitDecision( requesterName: String, requesterIconUrl: String, signingRequestBody: SigningRequestBody, signingAccount: SigningAccount, - ): Result { + ): Result = reviews.withLock { val context = ProductSigningContext( requesterName = requesterName, requesterIconUrl = requesterIconUrl, @@ -29,9 +37,12 @@ class ProductSigningScreenLauncher @Inject constructor( signingAccount = signingAccount, ) - signingContextHolder.set(context) - productsRouter.openSignTransaction() + withContext(Dispatchers.Main.immediate) { + signingContextHolder.set(context) + productsRouter.openSignTransaction() + } - return context.awaitResult() + context.awaitResult() } + } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt index ac89150571..a514d61923 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt @@ -78,8 +78,13 @@ fun UserConfirmationReview.toConfirmation(callingProductId: String): TrUAPIConfi is UserConfirmationReview.PreimageSubmit -> TrUAPIConfirmation.PreimageSubmit( - requesterProductId = callingProductId, - sizeBytes = v1.size.toLong(), + requesterProductId = v1.productId, + sizeBytes = v1.size, + rootPublicKey = v1.rootPublicKey.hex(), + genesisHash = v1.genesisHash.hex(), + automaticMaxBytes = v1.automaticMaxBytes, + automaticMaxUploads = v1.automaticMaxUploads, + automaticWindowSeconds = v1.automaticWindowSeconds, ) is UserConfirmationReview.MainPurseChatPayment -> @@ -104,6 +109,8 @@ fun UserConfirmationReview.toConfirmation(callingProductId: String): TrUAPIConfi is UserConfirmationReview.ProfileDisclosure -> TrUAPIConfirmation.ProfileDisclosure(requesterProductId = v1.productId) + is UserConfirmationReview.Calling -> + TrUAPIConfirmation.Calling(v1.productId, v1.network.displayHex(), v1.account.displayHex()) } @OptIn(ExperimentalStdlibApi::class) @@ -112,12 +119,18 @@ private fun RingLocation.describe(): String = chainId.toHexString() @OptIn(ExperimentalStdlibApi::class) private fun ByteArray.hex(): String = toHexString() +private fun DerivationIndex.describe(): String = when (this) { + is DerivationIndex.Index -> "Index($v1)" + is DerivationIndex.Raw -> "Raw(${v1.displayHex()})" +} + private fun AllocatableResource.describe(): String = when (this) { is AllocatableResource.StatementStoreAllowance -> "statement-store allowance" + is AllocatableResource.ProductStatementStoreAllowance -> + "product-account statement-store allowance for ${v1.describe()}" is AllocatableResource.BulletinAllowance -> "bulletin allowance" is AllocatableResource.SmartContractAllowance -> "smart-contract allowance" is AllocatableResource.AutoSigning -> "auto-signing" - is AllocatableResource.ProductStatementStoreAllowance -> "product statement-store allowance" } private fun NativeProductAccountId.toDomain(): ProductAccountId = ProductAccountId( @@ -163,11 +176,8 @@ private fun RawPayload.toContent(): RawPayloadContent = when (this) { } /** - * An unwatermarked payload carries no `` protection, so a signature over it can authorize a - * transaction; the core requires a host to say so. This sheet shows a raw payload as an ordinary - * message and has nowhere to put that warning, and carrying the flag into the domain model would - * reach the SSO SCALE mappers, which encode a wire message to the paired wallet. So the request is - * refused rather than presented as the harmless thing it is not; a sheet that can warn lifts this. + * Without `` protection a signature can authorize a transaction. The + * existing signing sheet cannot present that risk, so preserve its refusal. */ private fun SignRawReview.toSigningRequestBody(): SigningRequestBody = when (this) { is SignRawReview.Product -> { @@ -186,6 +196,8 @@ private fun requireWatermark(watermarked: Boolean) { if (!watermarked) throw UnsupportedReviewException("raw payload without transaction-payload protection") } +private fun ByteArray.displayHex(): String = "0x" + joinToString("") { "%02x".format(it) } + private fun TxPayloadExtension.toDomain() = EncodedTransactionExtensionValue( id = id, // Rust: `additional_signed` = implicit (signed, not in body); diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt index 8d6f2612d8..3218c7e46a 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt @@ -6,6 +6,9 @@ import io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.Encry import uniffi.truapi.HostRejection import uniffi.truapi.HostLocalStorageReadException import java.text.Normalizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * Product-scoped storage for the Rust core, encrypted at rest. @@ -50,16 +53,30 @@ class EncryptedHostStorage( class EncryptedHostCoreStorage( private val preferences: EncryptedPreferences, ) : HostCoreStorage { - override suspend fun read(key: ByteArray): ByteArray? = readValue(preferences, qualify(key)) + override val storageIdentifier: String = "${preferences.storageIdentifier}/$CORE_NAMESPACE" + + override suspend fun read(key: ByteArray): ByteArray? = coreCall { + val field = qualify(key) + if (!preferences.hasKey(field)) return@coreCall null + val stored = requireNotNull(preferences.getDecryptedString(field)) + check(stored.startsWith(VALUE_TAG)) { "Invalid core storage envelope" } + requireNotNull(decodeOrNull(stored.removePrefix(VALUE_TAG))) { "Invalid core storage bytes" } + } - override suspend fun write(key: ByteArray, value: ByteArray) { - writeValue(preferences, qualify(key), value) - ?.let { throw HostRejection.Rejected("core storage: $it") } + override suspend fun write(key: ByteArray, value: ByteArray) = coreCall { + preferences.putEncryptedStringCommitted(qualify(key), VALUE_TAG + value.toHex()) + changes.update { it + 1 } } - override suspend fun clear(key: ByteArray) { - runCatching { preferences.removeKey(qualify(key)) } - .getOrElse { throw HostRejection.Rejected("failed to clear core storage key: ${it.message}") } + override suspend fun clear(key: ByteArray) = coreCall { + preferences.removeKeyCommitted(qualify(key)) + changes.update { it + 1 } + } + + private inline fun coreCall(block: () -> T): T = try { + block() + } catch (error: Exception) { + throw HostRejection.Rejected("Private host storage unavailable") } override suspend fun keys(): List = preferences.keys() @@ -71,8 +88,10 @@ class EncryptedHostCoreStorage( private fun qualify(key: ByteArray) = "$CORE_NAMESPACE/${key.toHex()}" - private companion object { - const val CORE_NAMESPACE = "truapi/core" + companion object { + private const val CORE_NAMESPACE = "truapi/core" + private val changes = MutableStateFlow(0L) + val storageChanges = changes.asStateFlow() } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index e2e8998764..c32352348e 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -12,6 +12,13 @@ import java.util.Locale import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import uniffi.truapi.HostLocaleSubscribeItem +import android.Manifest +import android.content.pm.PackageManager +import android.webkit.WebView +import io.paritytech.polkadotapp.tools_media_connection_impl.nativeMedia.NativeMediaBackend +import io.paritytech.polkadotapp.tools_media_connection_impl.nativeMedia.NativeMediaBackendFactory +import io.paritytech.polkadotapp.tools_media_connection_impl.nativeMedia.NativeMediaConsent +import uniffi.truapi.DevicePermissionStatus import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -97,6 +104,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( private val pocketCardStore: PocketCardStore, @param:ApplicationContext private val context: Context, private val appLanguageProvider: AppLanguageProvider, + private val mediaFactory: NativeMediaBackendFactory, private val permissionRepository: Lazy, private val permissionChanges: PermissionAuthorizationChanges, private val productGameReminder: ProductGameReminder, @@ -135,6 +143,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( private fun currentLocale() = HostLocaleSubscribeItem(cachedLanguageTag.get(), ZoneId.systemDefault().id) + private var mediaBackend: NativeMediaBackend? = null init { // Tear the execution down with the owning scope: otherwise a closed @@ -221,10 +230,29 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( } override suspend fun confirmUserAction(review: UserConfirmationReview): Boolean = - confirmationLauncher.decide(review, requesterFallback = callingProductId.value) + if (review is UserConfirmationReview.Calling) { + review.v1.productId == callingProductId.value && + NativeMediaConsent.calling(context, review.v1.productId, review.v1.network, review.v1.account) + } else { + confirmationLauncher.decide(review, requesterFallback = callingProductId.value) != TrUAPIPermissionDecision.DENY + } override suspend fun confirmPermission(review: UserConfirmationReview): TrUAPIPermissionDecision = - confirmationLauncher.decidePermission(review, requesterFallback = callingProductId.value) + if (review is UserConfirmationReview.Calling) { + if (confirmUserAction(review)) TrUAPIPermissionDecision.ALLOW_ONCE else TrUAPIPermissionDecision.DENY + } else { + confirmationLauncher.decide(review, requesterFallback = callingProductId.value) + } + + override suspend fun devicePermissionStatus(request: HostDevicePermissionRequest): DevicePermissionStatus { + val permission = when (request) { + HostDevicePermissionRequest.CAMERA -> Manifest.permission.CAMERA + HostDevicePermissionRequest.MICROPHONE -> Manifest.permission.RECORD_AUDIO + else -> return DevicePermissionStatus.NOT_APPLICABLE + } + return if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) + DevicePermissionStatus.GRANTED else DevicePermissionStatus.NOT_DETERMINED + } override suspend fun devicePermission( product: ProductExecutionConfig, @@ -239,6 +267,9 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( product: ProductExecutionConfig, request: RemotePermission, ): TrUAPIPermissionDecision { + // Calling consent is scoped by the core's typed network/account review. + if (request == RemotePermission.Calling || request == RemotePermission.WebRtc) + return TrUAPIPermissionDecision.DENY return hostApiInteractor .requestRemotePermissionDecision(callingProductId, request.toDomain()) .getOrElse { throw it } @@ -309,10 +340,13 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( cachedChains.set(chains) cachedLanguageTag.set(appLanguageProvider.languageTag.first()) val pocket = ProductPocketHostBridge(productId, pocketCardStore, scope) + val media = mediaFactory.create(productId.value) + mediaBackend = media val opened = runtime.openProductExecution( bridge = buildBridge(productId, navigationPolicy, card), configuration = ProductExecutionConfig(productId.value, kind), pocket = pocket, + media = media, game = gameBridge(productId), ) execution = opened @@ -386,6 +420,28 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( execution?.notifyThemeChanged(theme) } + fun viewportChanged() { + mediaBackend?.viewportChanged() + } + + /** Composites native media over [webView]; register through `addWebViewSetup` so a replacement WebView is attached too. */ + fun attachMediaView(webView: WebView) { + mediaBackend?.attach(webView) + } + + /** Fails media closed after the product's renderer died, leaving the execution to the WebView replacement. */ + fun releaseMedia() { + mediaBackend?.close() + mediaBackend = null + } + + /** Settings use the same callbacks/storage and process runtime, without a second backend. */ + fun openPermissionExecution(runtime: TrUAPIHostRuntime, productId: ProductId): TrUAPIProductExecution = + runtime.openProductExecution( + buildBridge(productId, NavigationPolicy.DeeplinkNavigation { error("Navigation unavailable in permission settings") }, card = null), + ProductExecutionConfig(productId.value, ProductExecutionKind.APP), + ) + /** * Tears down the execution and its chain connections; the shared runtime * stays up for other products. Idempotent. Detaches the provider before @@ -393,6 +449,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( * being disposed. */ fun stop() { + releaseMedia() val opened = execution ?: return execution = null permissionChangesJob?.cancel() @@ -415,37 +472,11 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( } } -/** - * Confirm-only: the core owns the key and signs after approval, so this - * answers yes/no and never produces a signature. A review the app cannot - * describe fails closed, but that is a mapping bug rather than the normal - * path. [requesterFallback] names the requester for the one review that does - * not carry a product id itself. - */ +/** Only explicit answers become authority; mapping/prompt failures propagate and one-use remains one-use. */ internal suspend fun TrUAPIConfirmationLauncher.decide( review: UserConfirmationReview, requesterFallback: String, -): Boolean { - val confirmation = runCatching { review.toConfirmation(requesterFallback) } - .getOrElse { - Timber.w(it, "truapi.confirm: could not describe review, rejecting") - return false - } - - return awaitDecision(confirmation) -} - -/** Only an explicit user answer becomes authority; mapping and prompt failures propagate. */ -internal suspend fun TrUAPIConfirmationLauncher.decidePermission( - review: UserConfirmationReview, - requesterFallback: String, -): TrUAPIPermissionDecision { - return if (awaitDecision(review.toConfirmation(requesterFallback))) { - TrUAPIPermissionDecision.ALLOW_ALWAYS - } else { - TrUAPIPermissionDecision.DENY - } -} +): TrUAPIPermissionDecision = awaitDecision(review.toConfirmation(requesterFallback)) // Reports the theme name the native host's `themeSubscribe` already sends, so a // product reads the same theme on either runtime. @@ -476,6 +507,7 @@ private fun RemotePermission.toDomain(): RemotePermissionRequest = when (this) { RemotePermission.PreimageSubmit -> RemotePermissionRequest.PreimageSubmit RemotePermission.StatementSubmit -> RemotePermissionRequest.StatementSubmit is RemotePermission.JamPeers -> RemotePermissionRequest.JamPeers(genesis.toHexString(withPrefix = true)) + RemotePermission.Calling -> error("Calling requires a scoped core review") } private fun PermissionDecision.toNative(): TrUAPIPermissionDecision = when (this) { diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIBootstrapInstaller.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIBootstrapInstaller.kt index 396493938a..c74d3a634b 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIBootstrapInstaller.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIBootstrapInstaller.kt @@ -6,7 +6,6 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import dagger.hilt.android.qualifiers.ApplicationContext import io.parity.truapi.ContainerScriptBundle -import io.paritytech.polkadotapp.feature_products_impl.domain.webView.ProductWebChromeClient import javax.inject.Inject /** @@ -25,14 +24,29 @@ class TrUAPIBootstrapInstaller @Inject constructor( "WebView lacks DOCUMENT_START_SCRIPT; cannot run a TrUAPI product" } - val container = "window.__truapi_localhost = {...window.__truapi_localhost, nativeHttp: true};\n" + + val container = MEDIA_ISOLATION + "\n" + + "window.__truapi_localhost = {...window.__truapi_localhost, nativeHttp: true};\n" + ContainerScriptBundle.load(context) return { bootstrap -> { webView -> WebViewCompat.addDocumentStartJavaScript(webView, "if (window === window.top) {\n$bootstrap\n}", origins) WebViewCompat.addDocumentStartJavaScript(webView, container, setOf("*")) - (webView.webChromeClient as? ProductWebChromeClient)?.useContainerPermissions() } } } + + private companion object { + // Every frame, before the shared container. Product realms have no raw RTC, + // capture or fullscreen escape even when the shared policy marker is absent. + val MEDIA_ISOLATION = """ + (() => { + const deny = (object, name) => { if (!object) return; try { Object.defineProperty(object, name, { value: undefined, writable: false, configurable: false }); } catch (_) {} }; + ['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection','RTCDataChannel','MediaStreamTrackProcessor','MediaStreamTrackGenerator'].forEach(name => deny(globalThis, name)); + ['getUserMedia','webkitGetUserMedia','mozGetUserMedia'].forEach(name => { deny(navigator, name); deny(Object.getPrototypeOf(navigator), name); }); + if (navigator.mediaDevices) ['getUserMedia','getDisplayMedia','enumerateDevices'].forEach(name => { deny(navigator.mediaDevices, name); deny(Object.getPrototypeOf(navigator.mediaDevices), name); }); + ['requestFullscreen','webkitRequestFullscreen','webkitRequestFullScreen'].forEach(name => deny(Element.prototype, name)); + ['webkitEnterFullscreen','webkitEnterFullScreen'].forEach(name => deny(HTMLVideoElement.prototype, name)); + })(); + """.trimIndent() + } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmation.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmation.kt index cafee15ea8..fa8168f216 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmation.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmation.kt @@ -71,7 +71,12 @@ sealed interface TrUAPIConfirmation { /** Submit a preimage to the host-selected backend. */ class PreimageSubmit( override val requesterProductId: String, - val sizeBytes: Long, + val sizeBytes: ULong, + val rootPublicKey: String, + val genesisHash: String, + val automaticMaxBytes: ULong, + val automaticMaxUploads: UInt, + val automaticWindowSeconds: UInt, ) : TrUAPIConfirmation.Prompt /** Approve one exact payment from the user's main purse. */ @@ -95,4 +100,10 @@ sealed interface TrUAPIConfirmation { class ProductSubtree( override val requesterProductId: String, ) : TrUAPIConfirmation.Prompt + + class Calling( + override val requesterProductId: String, + val network: String, + val account: String, + ) : TrUAPIConfirmation.Prompt } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmationLauncher.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmationLauncher.kt index aada3bfd10..35a93cf9fc 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmationLauncher.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIConfirmationLauncher.kt @@ -7,6 +7,7 @@ import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotMa import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import uniffi.truapi.PermissionDecision import javax.inject.Inject import javax.inject.Singleton @@ -18,18 +19,24 @@ import javax.inject.Singleton * open for as long as the user takes. */ class TrUAPIConfirmationContext(val confirmation: TrUAPIConfirmation.Prompt) { - private val decision = CompletableDeferred() + private val decision = CompletableDeferred() fun approve() { - decision.complete(true) + decision.complete(PermissionDecision.ALLOW_ONCE) + } + + fun approveAutomatically() { + if (confirmation is TrUAPIConfirmation.PreimageSubmit) { + decision.complete(PermissionDecision.ALLOW_ALWAYS) + } } /** Also the answer for a dismissed sheet, so an abandoned prompt fails closed. */ fun reject() { - decision.complete(false) + decision.complete(PermissionDecision.DENY) } - suspend fun await(): Boolean = decision.await() + suspend fun await(): PermissionDecision = decision.await() } /** @@ -73,7 +80,7 @@ class TrUAPIConfirmationLauncher @Inject constructor( // first caller. Released even if the sheet is dismissed: onCleared rejects. private val oneAtATime = Mutex() - suspend fun awaitDecision(confirmation: TrUAPIConfirmation): Boolean = oneAtATime.withLock { + suspend fun awaitDecision(confirmation: TrUAPIConfirmation): PermissionDecision = oneAtATime.withLock { when (confirmation) { is TrUAPIConfirmation.Signing -> awaitSigningDecision(confirmation) is TrUAPIConfirmation.Prompt -> awaitPromptDecision(confirmation) @@ -82,22 +89,36 @@ class TrUAPIConfirmationLauncher @Inject constructor( // The app's own signing sheet, so a core-initiated signature is reviewed the // same way a native one is, decoded call and all. - private suspend fun awaitSigningDecision(confirmation: TrUAPIConfirmation.Signing): Boolean { + private suspend fun awaitSigningDecision(confirmation: TrUAPIConfirmation.Signing): PermissionDecision { val context = TrUAPISigningContext( requesterName = confirmation.requesterProductId, signingRequestBody = confirmation.request, signingAccount = confirmation.request.signingAccount(), ) - signingContextHolder.set(context) - productsRouter.openSignTransaction() - return context.await() + try { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { + signingContextHolder.set(context) + productsRouter.openSignTransaction() + } + return if (context.await()) PermissionDecision.ALLOW_ONCE else PermissionDecision.DENY + } finally { + context.onAbandoned() + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable + kotlinx.coroutines.Dispatchers.Main.immediate) { + signingContextHolder.clear(context) + } + } } - private suspend fun awaitPromptDecision(confirmation: TrUAPIConfirmation.Prompt): Boolean { + private suspend fun awaitPromptDecision(confirmation: TrUAPIConfirmation.Prompt): PermissionDecision { val context = TrUAPIConfirmationContext(confirmation) holder.set(context) - productsRouter.openTrUAPIConfirmation() - return context.await() + return try { + productsRouter.openTrUAPIConfirmation() + context.await() + } finally { + context.reject() + holder.clear(context) + } } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt index c23000d16c..c1c0f3300a 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -82,12 +81,23 @@ class TrUAPIHostRuntimeProvider @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + dispatchers.computation) private val bootMutex = Mutex() private var boot: Deferred>? = null + private val sessionMutation = Mutex() + private val revision = MutableStateFlow(0L) + val sessionRevision: StateFlow = revision.asStateFlow() + @Volatile internal var sessionOwner: Long? = null + private set + + internal suspend fun withSessionMutation(block: suspend () -> T): T = + sessionMutation.withLock { block() } private val authState = MutableStateFlow(AuthState.Disconnected) - /** Core-owned session state. Nothing consumes it yet; see [HostBridge.authStateChanged]. */ + /** Core-owned session state, also invalidated while the wallet account changes. */ val sessionState: StateFlow = authState.asStateFlow() + var bulletinGenesisHash: ByteArray? = null + private set + // Resolved once when the runtime boots: the core asks for chains on its // dispatcher thread, where a suspending registry lookup is not allowed. private val cachedChains = AtomicReference(EMPTY_CHAINS) @@ -114,8 +124,12 @@ class TrUAPIHostRuntimeProvider @Inject constructor( } private suspend fun build(): Result = runCatching { + val owner = accountRepository.getWalletAccount().id val config = buildRuntimeConfig() + bulletinGenesisHash = config.bulletinChainGenesisHash cachedChains.set(chainDirectory.resolve()) + check(accountRepository.getWalletAccount().id == owner) { "Wallet changed while booting runtime" } + sessionOwner = owner TrUAPIHostRuntime(HostRuntimeBridge(), config) } @@ -184,11 +198,21 @@ class TrUAPIHostRuntimeProvider @Inject constructor( accountRepository.walletAccountFlow() .map { it.id } .distinctUntilChanged() - .drop(1) - .collect { - localSessionSource.resolve() - .mapCatching { session -> runtime.activateLocalSession(session.secret, session.liteUsername) } - .logFailure("TrUAPI local session could not follow the wallet switch") + .collect { owner -> + sessionMutation.withLock { + if (sessionOwner == owner) return@withLock + authState.value = AuthState.Disconnected + revision.value += 1 + sessionOwner = null + runtime.disconnect() + localSessionSource.resolve() + .mapCatching { session -> + check(accountRepository.getWalletAccount().id == owner) { "Wallet changed" } + runtime.activateLocalSession(session.secret, session.liteUsername) + sessionOwner = owner + } + .logFailure("TrUAPI local session could not follow the wallet switch") + } } } } @@ -248,10 +272,10 @@ class TrUAPIHostRuntimeProvider @Inject constructor( PermissionDecision.DENY override suspend fun confirmUserAction(review: UserConfirmationReview): Boolean = - confirmationLauncher.decide(review, requesterFallback = HOST_REQUESTER) + confirmationLauncher.decide(review, requesterFallback = HOST_REQUESTER) != PermissionDecision.DENY override suspend fun confirmPermission(review: UserConfirmationReview): PermissionDecision = - confirmationLauncher.decidePermission(review, requesterFallback = HOST_REQUESTER) + confirmationLauncher.decide(review, requesterFallback = HOST_REQUESTER) override suspend fun featureSupported(request: HostFeatureSupportedRequest): Boolean = when (request) { @@ -267,14 +291,7 @@ class TrUAPIHostRuntimeProvider @Inject constructor( override fun chainClose(connectionId: UInt) = chainProvider.close(connectionId) - /** - * Observed, not acted on. Rendering [AuthState.Pairing] as a pairing - * sheet needs a core-driven session, and `PairingHostRuntime` is not - * reachable from a native host yet (truapi#334, "Move SSO to the shared - * Rust core"), so the core never reaches a state worth showing. iOS - * stubs this the same way. Surfaced as state rather than a log line so - * wiring the UI later is a subscription, not a rewrite. - */ + /** Settings observe these account transitions; no app-supplied scope is used. */ override fun authStateChanged(state: AuthState) { authState.value = state Timber.tag("truapi.auth").d("%s", state.marker()) diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt index b7f3332f18..074dc0b8ed 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt @@ -76,9 +76,14 @@ class TrUAPISessionStarter @Inject constructor( card = card, onPermissionRevoked = { provider.disposeRevokedExecution() }, ) { bootstrap -> - provider.addWebViewSetup(installBootstrap(bootstrap)) + val install = installBootstrap(bootstrap) + provider.addWebViewSetup { webView -> + install(webView) + bridge.attachMediaView(webView) + } } .mapCatching { execution -> + provider.bindTrUAPILifecycle(bridge) provider.useTrUAPIPermissions(productId, execution) provider.loadInitialContent() } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISigningContext.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISigningContext.kt index 0b004cc1d4..44eb4da52d 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISigningContext.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISigningContext.kt @@ -21,6 +21,7 @@ class TrUAPISigningContext( ) : SigningContext { // Matches the native host, which has no icon for a product either. override val requesterIconUrl: String = "" + override val signsOnApproval = false private val decision = CompletableDeferred() diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/worker/TrUAPIWorkerSupervisor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/worker/TrUAPIWorkerSupervisor.kt index 157a8fca1c..d260da8c9a 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/worker/TrUAPIWorkerSupervisor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/worker/TrUAPIWorkerSupervisor.kt @@ -110,7 +110,9 @@ class TrUAPIWorkerSupervisor @Inject constructor( }.getOrElse { return Result.failure(it) } provider.addOnWebViewDestroyedListener { rebootAfterRendererLoss(productId, worker) } - return hostBridgeFactory.create(worker.scope) + val bridge = hostBridgeFactory.create(worker.scope) + provider.bindTrUAPILifecycle(bridge) + return bridge .attach( runtime, productId, @@ -121,7 +123,13 @@ class TrUAPIWorkerSupervisor @Inject constructor( provider.disposeRevokedExecution() scope.launch { transitions.withLock { stop(productId) } } }, - ) { bootstrap -> provider.addWebViewSetup(installBootstrap(bootstrap)) } + ) { bootstrap -> + val install = installBootstrap(bootstrap) + provider.addWebViewSetup { webView -> + install(webView) + bridge.attachMediaView(webView) + } + } .flatMap { execution -> runCatching { provider.useTrUAPIPermissions(execution) diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/webView/BrowserWebViewProvider.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/webView/BrowserWebViewProvider.kt index 08e0ad3cde..0d7e4ed14a 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/webView/BrowserWebViewProvider.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/webView/BrowserWebViewProvider.kt @@ -30,6 +30,8 @@ import io.paritytech.polkadotapp.feature_products_impl.domain.hostApi.UrlDerived import io.paritytech.polkadotapp.feature_products_impl.domain.hostApi.navigation.NavigationPolicy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import android.net.Uri +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.ProductTrUAPIHostBridge import kotlinx.coroutines.flow.MutableStateFlow /** @@ -80,6 +82,7 @@ class BrowserWebViewProvider @AssistedInject constructor( // Per-session decorator over the resolver: tracks the domain currently being served and // exposes its download/unpack progress for the host UI to render. private val contentLoader = DotNsContentLoader(dotNsResolver) + private var nativeBridge: ProductTrUAPIHostBridge? = null // No dotNS load reports progress for a page served from a loopback url, so its own load does. private val servedPageProgress = MutableStateFlow(DotNsLoadProgress.Idle) @@ -113,8 +116,7 @@ class BrowserWebViewProvider @AssistedInject constructor( domStorageEnabled = true allowFileAccess = false allowContentAccess = false - // A camera preview is a MediaStream in an autoplaying