Skip to content

Epic: Bridge ​

This shell's implementation of the shared device capability bridge — the mechanism that lets a plugin running inside the WebView call sdk.device.* and reach real device hardware, without ever importing Capacitor or branching on shell internals.

Status ​

⏳ In Progress — 20.3 done (adapter + haptics.impact + notifications.native, per ADR 0004's "v1 ships the thin slice" framing, not split across separate tasks as this doc originally implied); 20.5 done (native push, environment-gated — real credentials/hardware verification still pending, see that task's own note); 20.6 done (camera.photo); 20.7 done (biometric auth); 20.13 partial (secure storage — key custody and the SQLCipher database now built and build-verified on both platforms; interactive end-to-end verification now done on iOS (real device-credential round-trip through a live app, plus a reference plugin proving actual data persists across a full app kill and relaunch); still blocked on Android, for reasons unrelated to this repo's own code — see that task's own note); 20.9 still 📋 Planned

Overview ​

The bridge protocol — capability registry, negotiation, typed results — is designed once, centrally, in the sovereign monorepo per sovereign RFC 0083 (@sovereignfs/bridge + @sovereignfs/sdk/device-client), and shared with sovereign-desktop. This repo's job is narrower: implement the Capacitor transport of that contract, and — capability by capability — the native side of each device call. See ADR 0004 for why the protocol is not designed here.

Two rules carry through every task in this epic, restated from RFC 0083 and ADR 0004 because violating either breaks the contract for sovereign-desktop too, not just this repo:

  1. The shell must expose only the narrow bridge object to page JS — never raw window.Capacitor.
  2. The advertised capabilities list at handshake must reflect exactly what this build actually implements — never aspirational.

Tasks ​

✅ 20.3 — Mobile SDK native environment and bridge adapter ​

Rescoped by sovereign RFC 0083. The environment-detection half is covered centrally by sovereign task 3.32 (RFC 0080). What remains here is the Capacitor transport of @sovereignfs/bridge — tracked as leg 4 of sovereign workstream 0003.

Goal: Implement this shell's side of the shared device bridge transport so sdk.device.* calls made by plugins running in this WebView reach real Capacitor plugin calls.

Deliverables:

  • Capacitor transport implementation consuming @sovereignfs/bridge.
  • Handshake wiring: this shell announces its actual supported capability set and versions on load.
  • Only the narrow bridge object injected into page JS — never window.Capacitor directly.
  • Tests covering handshake, capability negotiation, and the available/denied/dismissed/unavailable result shape.

Dependencies: Task 20.1; sovereign RFC 0083 (protocol, owned centrally).

Review checklist:

  • Plugin code calls sdk.device.* without importing Capacitor.
  • This shell's handshake capability list matches what the build actually implements.
  • window.Capacitor is not reachable from page JS.
  • Unsupported capabilities return the documented typed result, not a thrown exception.

Outcome (2026-08, both platforms verified empirically, not just compiled):

  • iOS and Android needed genuinely different designs because Capacitor's own bridge isolation posture differs by platform — this wasn't assumed going in, it came from reading each platform's actual @capacitor/ios / @capacitor/android 8.4.2 source before writing any native code:
    • iOS: window.Capacitor is a WebView-level WKUserScript with no origin scoping (forMainFrameOnly: true, no origin filter) — it runs on every navigation, including the loaded remote instance, by default. Genuine isolation required actively fighting the framework: MainViewController.swift swaps the WKUserContentController's script/handler set at the local↔remote navigation boundary, replacing Capacitor's own scripts and "bridge" WKScriptMessageHandler with the narrow __SOVEREIGN_BRIDGE__ script and a sovereignBridge handler (Bridge.swift), restoring Capacitor's own on the way back.
    • Android: the opposite problem. Bridge.java's loadWebView() already registers both window.Capacitor (WebViewCompat.addDocumentStartJavaScript) and the native androidBridge channel (WebViewCompat.addWebMessageListener) scoped to bridge.getAllowedOriginRules() — which, since this app never sets server.url, contains only the local origin. So window.Capacitor never reached the remote instance to begin with; nothing to remove. NavigationPolicyWebViewClient.java only needed to add its own sovereignBridge registration (BridgeMessageListener.java), using the same origin-scoped APIs, scoped to the runtime-chosen active instance origin instead of the static local one.
  • Empirically verified end-to-end on both platforms (iOS Simulator, Android Emulator via this repo's bundled .toolchain/): confirmed window.Capacitor === undefined on the loaded remote instance, confirmed window.__SOVEREIGN_BRIDGE__.invoke('haptics.impact', ...) and invoke('notifications.native', ...) both round-trip to {status:'ok'} — including the real OS permission prompt firing (accepted, "Allow") — and confirmed navigating back to local content restores normal Capacitor function (@capacitor/preferences-backed instance list still renders). Only the ok path was exercised this way. denied (permission refused), dismissed, and unavailable (unknown capability name) are each a single, low-risk code path — the same default: unavailable(...) shape already shipped on sovereign-desktop's Tauri transport — but weren't separately clicked through on either platform; a reviewer who wants that specific coverage before merge should ask for it explicitly rather than assume it's implied by "empirically verified."
  • No automated native tests were added — this repo has no existing XCTest/Espresso harness to extend, and the capability logic lives entirely in native code unreachable from the Vitest/jsdom-based TS test suite. Verification is the empirical simulator/emulator round-trip above, the same precedent sovereign-desktop's Tauri transport (leg 3) set. A real gap against this task's "Tests covering..." deliverable as originally written, flagged rather than silently claimed as satisfied.
  • getPermission()/requestPermission() (packages/sdk/src/device-client.ts in the monorepo) already report 'granted' unconditionally on any native-bridge transport, per the fix that shipped with leg 3 — no further monorepo change was needed for this leg.

✅ 20.5 — Native push notifications (APNs/FCM) ​

Rescoped by sovereign RFC 0087 (this task's goal/deliverables below are the pre-RFC-0087 sketch — kept for history, not accurate to what shipped). The registration API, encrypted-relay fan-out, and apps/relay service are the sovereign monorepo's own task 4.7, not this task's work. What actually shipped here is the client-side half: Capacitor push registration for the device token, on-device keypair generation, the iOS Notification Service Extension, and the Android background handler.

Deliberately entirely native — no sdk.device.*/sovereignBridge capability, unlike every other bridge capability in this file. Two hard reasons, not an oversight: (1) this WebView has no isolation between shell chrome and plugin JS — anything reachable from window.__SOVEREIGN_BRIDGE__ is reachable by any plugin, and the push encryption keypair is exactly the kind of secret RFC 0083 §7 already rules must never be plugin-facing; (2) AGENTS.md's device bridge rules require a sovereign monorepo RFC 0083 registry entry before any new page-JS- reachable capability, and this design needs none. The opt-in/out UX is simply the OS's own notification permission prompt.

What's verified, and how: the actual crypto (ECDH P-256 + HKDF-SHA256

  • AES-256-GCM, matching runtime/src/push-encryption.ts's wire format exactly) was verified with real cross-language round-trips on both platforms — Node.js encrypting with the real server code, decrypting with the real Swift (PushCrypto.swift) and Java (PushCrypto.java) code, using only the recipient's own stored private key, the same thing a real device does. This caught two real bugs before they shipped: iOS's P256.KeyAgreement.PublicKey.rawRepresentation is CryptoKit's own 64-byte encoding with no 0x04 prefix, not the 65-byte SEC1 point the wire format needs (.x963Representation is correct); Java's Cipher AES/GCM API expects ciphertext ‖ tag (tag last), the opposite byte order from the wire format's tag ‖ ciphertext, so Android's decrypt silently produced "Tag mismatch" until the bytes were explicitly reordered. Both platforms' full projects build for real: iOS via xcodebuild (Simulator, automatic local signing — including a genuine new Xcode target for the Notification Service Extension, created with the xcodeproj Ruby gem rather than hand-edited, not just written and assumed to link); Android via ./gradlew :app:assembleDebug with no google-services.json present, confirming the conditional Firebase plugin application (android/app/build.gradle, Capacitor's own scaffolded pattern) correctly gates real project config without breaking the build.

What's not verified, and can't be without real credentials/hardware: Keychain-group entitlement sharing between the iOS app and its extension at runtime (confirmed the entitlements file content is correct, but codesign strips keychain-access-groups/aps-environment under Simulator-only ad-hoc signing with no Apple Developer Team configured — a tooling limit, not a code defect); real APNs/FCM delivery end-to-end (needs sovereignfs's actual push credentials, provisioned server-side — see sovereign task 4.7's own leg 2); a real Firebase project's google-services.json. Revocation on sign-out is a known, documented gap — see PushRegistration.swift/.java's own doc comments — a stale token self-heals via the relay's invalid_token pruning instead (sovereign task 4.7 leg 3, already shipped).

Goal (pre-RFC-0087 sketch — superseded, see above): Add native push notification support so users receive alerts when the app is not open.

Deliverables (as originally sketched — superseded):

  • Capacitor push notifications integration for APNs and FCM.
  • Native push registration/revocation routed through sdk.device.* / notification SDK.
  • Permission strings, privacy declarations, operator configuration docs.
  • Tests for token registration, revocation, and permission/error states.

Dependencies: Task 20.3; sovereign RFC 0015 (Notification Center), RFC 0016 (Web Push) — both owned centrally.

Review checklist:

  • User can opt in to push on iOS and Android.
  • Revoking permission or signing out removes or invalidates the device token.
  • Push payloads do not expose sensitive content beyond documented behavior.
  • Missing APNs/FCM configuration degrades to a documented no-op.

✅ 20.6 — Native photo picker and camera capture ​

Closed. camera.photo(source, pluginId?) — same additive-registry shape RFC 0083 §7 called out for this capability, not a new mechanism. Implemented on all three tiers: native (BridgeCapabilities.cameraPhoto in BridgeCapabilities.java and Bridge.swift), the shared SDK surface (camera export, packages/sdk/src/device-client.ts in the monorepo), and a browser fallback (hidden <input type="file">, capture attribute hints at the device camera). No @capacitor/camera dependency — matches haptics/notifications.native's precedent of calling native OS APIs directly (PickVisualMedia/TakePicture on Android, PHPickerViewController/UIImagePickerController on iOS) rather than pulling in a Capacitor plugin. Unit tests cover the SDK-layer bridge/ fallback logic (packages/sdk/src/__tests__/device-client.test.ts); no native XCTest/Espresso harness exists in this repo (same gap 20.3 noted), so the native halves were verified empirically on Android Emulator/iOS Simulator, not via an example plugin.

Goal: Expose native photo selection and camera capture through sdk.device.* without plugins importing Capacitor directly.

Deliverables:

  • Capacitor camera/photo picker integration.
  • SDK device method for capture/select flow.
  • Browser fallback using existing Web APIs where available.
  • Permission strings and privacy declarations for iOS and Android.
  • Example/test plugin flow proving portability across browser and this shell.

Dependencies: Task 20.3.

Review checklist:

  • Plugin can request a photo through sdk.device.* in this shell.
  • iOS and Android permission prompts use accurate copy.
  • Browser fallback works or returns a documented unsupported state.
  • Returned file/blob metadata is normalized across environments.
  • Denied permissions are handled without crashing the plugin.

✅ 20.7 — Biometric auth capability ​

Verification is partial — recorded honestly, not glossed over. Verified: the monorepo SDK side (sdk.device.biometrics.confirm()) has 5 unit tests covering success/unavailable/denied/dismissed/consent-grant paths, and this repo's iOS build (xcodebuild against App.xcodeproj/scheme App for the simulator) succeeds cleanly with Bridge.swift's biometricsConfirm and Info.plist's NSFaceIDUsageDescription added. Android is now also build- and runtime-verified: after installing JDK 21 (the capacitor-android module's minimum, unavailable at first pass — Homebrew is broken on this machine per ADR 0007's Android section, so the JDK was installed directly from Eclipse Temurin), :app:assembleDebug succeeds, and the resulting APK installs and launches cleanly on the sovereign_mobile_test emulator (AVD, emulator-5554) — no FATAL/AndroidRuntime crash in logcat, Displayed fs.sovereign.mobile/.MainActivity confirmed, and a screenshot shows the WebView correctly rendering a real stored instance's sign-in page. A pre-existing deprecation warning (Context.VIBRATOR_SERVICE in the unrelated hapticsImpact() method) was traced via javap bytecode inspection and confirmed unrelated to this task's BiometricManager/ BiometricPrompt code, which introduces no new warnings. Not verified, on either platform: an actual interactive Face ID / fingerprint prompt triggered end-to-end — there is no real caller yet (this is new SDK surface; no shipped plugin calls sdk.device.biometrics.confirm() yet), so there is nothing in the app today that would trigger the prompt to click through.

Goal: Add Face ID / fingerprint capability through sdk.device.* for high-trust local confirmation flows — without replacing Sovereign server-side auth. See ADR 0003: this capability never grants a session by itself.

Deliverables:

  • Capacitor biometric auth integration.
  • SDK device method for local biometric confirmation.
  • Clear distinction, in both code and docs, between local device confirmation and platform authentication/session freshness.
  • Permission/privacy documentation for iOS and Android.
  • Tests or simulator verification for success, failure, unavailable, and denied states.

Dependencies: Task 20.3; coordinate with auth/session freshness rules (owned centrally) before using this for sensitive flows.

Review checklist:

  • Biometric prompt can confirm a local action in this shell.
  • Capability never grants server-side auth by itself.
  • Devices without biometrics return a documented unsupported state.
  • Failed or cancelled biometric prompts are handled predictably.

✅ 20.8 — Haptics capability ​

Closed by 20.3. This task predates RFC 0083 and sketched a richer four-style feedback surface (success/warning/error/light). The actual shipped contract is haptics.impact('light' | 'medium' | 'heavy'), matching sovereign-desktop's Tauri transport and the monorepo SDK exactly — see ADR 0004, which folds haptics into v1's thin slice alongside notifications.native rather than a separate later task. Nothing separate remains here.

Goal: Expose lightweight native haptics through sdk.device.* for mobile interaction feedback.

Deliverables:

  • Capacitor haptics integration.
  • SDK device method or environment-routed implementation.
  • No-op browser fallback.
  • Usage guidance keeping haptics optional and non-essential.

Dependencies: Task 20.3.

Review checklist:

  • This shell can trigger success/warning/error/light feedback.
  • Browser and PWA environments no-op without throwing.
  • Plugins remain fully usable when haptics are unavailable.
  • Reduced-motion/accessibility preferences are respected where exposed.

📋 20.9 — Background capability planning ​

Goal: Define whether and how native background location or background work belongs in Sovereign, before any high-risk background permission is added to this shell.

Deliverables:

  • Follow-up RFC or design note for background location and background work (owned centrally, coordinated from here).
  • Store-review and privacy analysis for iOS and Android.
  • Capability gating model for plugins that request background behavior.
  • Operator/user consent and revocation model.
  • Decision on whether background work is handled by this shell's APIs, platform jobs, plugin jobs, or a combination.

Dependencies: Task 20.3; sovereign RFC 0046 (Plugin background jobs and schedules).

Review checklist:

  • No background permission is added before the design is accepted.
  • The design identifies data collection, retention, and revocation behavior.
  • Store-review risk is documented before implementation.

🚧 20.13 — Encrypted secure-storage capability (sovereign RFC 0093 leg 4) ​

Key custody and the SQLCipher database are both now built and build-verified on both platforms. iOS xcodebuild against App.xcodeproj/scheme App for the simulator and Android :app:assembleDebug (JDK 21) both compile clean with SecureDatabase.swift/ SecureDatabase.java wired into Bridge.swift/BridgeMessageListener.java's secureStorage dispatch, replacing SecureStorage.swift/SecureStorage.java as the live handler (those files stay in the tree as the source of the Keychain/Keystore prompt-and-crypto primitives SecureDatabase reuses — see their own doc comments for exactly which methods were widened from private to share).

@capacitor-community/sqlite, RFC 0093 §1's literally-named pick, turned out to be unusable as designed here — a genuine finding from this task, not an assumption. Its entire API is JS-facing (window.Capacitor/registered-plugin calls), and this shell strips Capacitor's own bridge from the WebView whenever it shows remote content (see MainViewController.swift's "Bridge isolation" doc comment) — the plugin has no native-to-native API to fall back on. The fix links the same underlying native SQLCipher libraries that plugin itself depends on, directly, bypassing the plugin wrapper entirely: SQLCipher.swift (the official Zetetic Swift Package, added to the App target's own Package Dependencies — not CapApp-SPM/Package.swift, which is Capacitor-CLI-managed) on iOS, and net.zetetic:sqlcipher-android:4.17.0 + androidx.sqlite:sqlite on Android.

The two platforms' key-custody designs genuinely diverge, not by choice but because Android Keystore keys are non-extractable by design — there is no Keystore equivalent of Keychain's SecItemCopyMatching/ kSecReturnData that hands back raw key bytes:

  • iOS: the 256-bit database key is generated with SecRandomCopyBytes and held directly in the Keychain (a distinct service string from SecureStorage.swift's own, so a plugin can never reach it through the public pluginId/key namespace), gated by the same .userPresence access control every other item in that file uses. Applied to SQLCipher as a raw hex-literal PRAGMA key, not via sqlite3_key()'s passphrase path — the latter runs the value through PBKDF2 as if it were a low-entropy human passphrase, which is both pointless (nothing to strengthen in 256 bits of SecRandomCopyBytes output) and slow (SQLCipher's default is 256,000 iterations) on every open.
  • Android: envelope encryption. A SecureRandom-generated database key is wrapped by a dedicated authentication-gated Keystore AES key (its own alias, getOrCreateKey() in SecureStorage.java widened to take the alias as a parameter rather than duplicating the whole KeyGenParameterSpec shape) and the ciphertext lives in its own SharedPreferences file, unwrapped via Cipher.init() on every use — which is still authentication-gated, so the actual security property RFC 0093 §2 asks for (release of the key is gated) holds identically; only the storage mechanics for the key material differ. Applied to net.zetetic's SQLiteDatabase.openOrCreateDatabase(File, byte[], …) — the raw-byte[] overload, confirmed by inspecting the shipped class file (javap) to exist alongside the String-passphrase overloads, giving Android the same zero-KDF raw-key path iOS gets.

Both platforms also mirror iOS's own SecureStorage.swift re-lock design: the database connection is cached and reused, re-opened (and thus re-prompted) once SecureStorage's existing 300-second reuse window has elapsed — on iOS this falls out for free from sharing activeContext(); Android didn't have an equivalent to share, so SecureDatabase.java tracks elapsed wall-clock time against the same VALIDITY_SECONDS constant (widened to package-private) instead of re-deriving a second, independently -timed copy of the same policy. The database file is explicitly kept out of device backups on both platforms — isExcludedFromBackup on iOS, getNoBackupFilesDir() (not getFilesDir()) on Android, the latter needed because this app has android:allowBackup="true" with no exclusion rules otherwise.

Interactive end-to-end verification is now done on iOS, still blocked on Android — and the Android block is for a reason internal to the emulator, not this repo's code. The monorepo-side blocker below (DeviceStorageKeySection.tsx unreachable on native) has since been fixed there, unblocking a real UI-driven test on both platforms. On iOS: verified live on the Simulator against this repo's own freshly-built app — tapped "Verify it works" in Account → Security, got a real {status:'ok'} round-trip through SecureDatabase.swift (the device's own passcode/biometric prompt, not a mock), and separately, the example-device-only reference plugin (sovereign monorepo) wrote a note through the same native secureStorage path, survived a full force-kill and relaunch of the app, and read back correctly — proving the write reaches the actual on-disk SQLCipher database, not an in-memory cache a fresh process would lose.

On Android, the same UI-driven test got further than the earlier bridge-only probe below but still didn't reach a completed ok round-trip, and for a different reason than originally documented: with no device credential configured, the flow correctly and immediately reports the RFC 0093 §5 hard-block ('no-device-auth') — itself a real, correct result, not a failure. Setting a PIN on the AVD moved the flow to a genuine credential- confirmation dialog ('dismissed'/'cancelled', not 'unavailable' — a materially different, correct code path proving BridgeMessageListener.java and the Keystore-gated dispatch in SecureDatabase.java are being exercised for real). But interacting with PIN-related system screens on this specific AVD image (sovereign-edge, Android emulator 37.1.11) reproducibly wedged the whole emulator — SystemUI became unresponsive, screencap/dumpsys hung, and adb devices eventually reported the device offline, requiring a hard kill and restart of the emulator process each time, on multiple independent attempts (with both hardware and -gpu swiftshader_indirect software rendering). This reads as a more severe manifestation of the same class of emulator instability already noted below for BiometricPrompt's device-credential fallback — not a SecureDatabase.java/BridgeMessageListener.java bug, since the dispatch, gating, and OS hand-off all happen exactly as coded up to that point. Genuine on-device testing (tracked as still-needed, matching docs/pwa-real-device-testing.md's note for the web side) remains the way to settle the Android ok path conclusively; it wasn't available in this environment, and repeated emulator crashes made further attempts an unproductive use of time rather than a code question.

A second finding, this one in the sovereign monorepo, not here — since fixed there: the intended UI entry point for this whole capability — Account → Security → "Device Storage Key" (DeviceStorageKeySection.tsx) — was unreachable on any native shell, on either platform, by a pre-existing frontend bug unrelated to this task. Its status check called getDeviceStorageKeyStatus() unconditionally, which is explicitly documented (in its own doc comment, in packages/sdk/src/device-only-storage.ts) as the web/PWA-only path — it checks isWebAuthnAvailable()/ isOpfsAvailable()/isUserVerifyingPlatformAuthenticatorAvailable(), never the bridge's supports('secureStorage')/getTransport(). On Android's Chrome WebView (no WebAuthn PRF/OPFS support) this rendered as 'unsupported' — "needs a modern browser... use the native app instead," ironic advice from inside the native app. On iOS's WKWebView (which does implement WebAuthn + OPFS) it got one step further and rendered 'no-device-auth' instead, for the same underlying reason: it was still asking the web platform-authenticator question, never the bridge. This was confirmed live on both platforms against this repo's own freshly-built apps, not inferred from reading the code alone. Fixed on the monorepo side with a transport-aware dispatcher (supports('secureStorage') picked between a native branch — no enrollment step, since native needs none — and the original web/PWA flow) — see sovereign's own RFC 0093 changelog (v0.8) for the fix and its live verification. The same class of bug was also found and fixed in device-only-kv.ts (the plugin-data storage primitive itself, distinct from this status-check UI) and in the example-device-only reference plugin, which had independently made the identical mistake.

A pre-existing bug fixed along the way, unrelated to this task's own scope: biometrics.confirm (task 20.7) was dispatched in both Bridge.swift/BridgeMessageListener.java but never actually advertised in either platform's handshake capabilities array — a direct violation of this epic's own stated rule ("the advertised list must reflect exactly what this build implements"). supports('biometrics.confirm') would have silently reported false even though the native shell already handled it. Fixed in the same commit as adding secureStorage to that same array, since both touch the identical two lines.

Goal: durable, encrypted, device-auth-gated key-value storage through the bridge — the capability sovereign RFC 0093 §2 needs to hold the device-only tier's Device Storage Key, gated by a live biometric-or- device-passcode prompt rather than a JS-side flag an attacker with the storage file could bypass.

Deliverables:

  • SecureStorage.swift (iOS) — one .userPresence-access-controlled Keychain item per pluginId::key pair. .userPresence, not .biometryCurrentSet/.biometryAny — sovereign RFC 0093 §5 explicitly rejects biometric-only gating (locks out a user with a device passcode but no enrolled Face ID/Touch ID). Reads prompt; writes do not (Keychain item creation itself needs no authentication — only later access to its value does).
  • SecureStorage.java (Android) — one Keystore-resident AES-256-GCM key (setUserAuthenticationRequired(true) + AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL on API 30+, a positive setUserAuthenticationValidityDurationSeconds below that — both accept the lock-screen credential, never biometric alone), encrypting values stored in ordinary SharedPreferences. Unlike iOS, both reads and writes prompt here — a genuine platform difference stated in that file's own doc comment, not glossed over: the Keystore enforces its authentication window on both cipher directions, where Keychain access control gates reads only.
  • Both platforms: no BiometricPrompt.CryptoObject/direct LAContext-bound cipher — Android does not allow combining CryptoObject with a device-credential-allowed prompt at all (an undocumented-until-you-hit-it platform restriction), so both implementations correlate the prompt and the key's usability window by time instead, fixed for now at 5 minutes (matching packages/sdk/src/device-only-storage.ts's DEFAULT_RE_LOCK_POLICY in the sovereign monorepo) rather than the user's actual, adjustable re-lock preference — wiring that through is a stated follow-up on the monorepo side (epic task 1.22's re-lock-enforcement scope), not something this task's wire contract carries today.
  • secureStorage added to both platforms' handshake capabilities array, alongside the biometrics.confirm fix noted above.
  • SecureDatabase.swift (iOS) — the SQLCipher kv(plugin_id, key, value) table this file's own get/set/remove/keys/clear operate against; database key generation/custody and raw-key PRAGMA key application as described above; a NSLock-guarded cached connection (unlike SecureStorage.swift's own unguarded sharedContext cache, since a concurrent double-open of a stateful native resource is a worse failure mode than a redundant re-prompt); backup-excluded via isExcludedFromBackup.
  • SecureDatabase.java (Android) — same kv schema and op set against net.zetetic:sqlcipher-android's SQLiteDatabase; envelope-encrypted database key as described above; a synchronized-block-guarded cached connection with wall-clock re-lock tracking against SecureStorage.java's shared VALIDITY_SECONDS; stored under getNoBackupFilesDir().
  • ios/App/App.xcodeproj/project.pbxproj — SQLCipher.swift added as an SPM package dependency of the App target (via the xcodeproj Ruby gem, not hand-edited — an SPM package reference touches enough of the object graph that manual editing was judged too risky), SQLITE_HAS_CODEC=1 set on both build configurations' GCC_PREPROCESSOR_DEFINITIONS.
  • android/app/build.gradle — net.zetetic:sqlcipher-android:4.17.0 + androidx.sqlite:sqlite:2.4.0 added as dependencies.

Dependencies: Task 20.3 (bridge adapter); the monorepo-side permission and protocol semantics (device:secureStorage, packages/sdk/src/device-client.ts's secureStorage surface) — already shipped there, per this epic's own rule that a new capability is a sovereign monorepo change first.

Review checklist:

  • [x] Both platforms build clean with the new capability wired into dispatch and handshake.
  • [x] Never biometric-only — both key-generation paths accept the device lock-screen credential as well as biometrics, on every supported API level.
  • [x] @capacitor-community/sqlite + SQLCipher database — built on both platforms; the plugin itself replaced with direct SQLCipher linking for the reasons above, but the actual deliverable (an encrypted, device-auth-gated relational store behind the secureStorage capability) is complete and build-verified.
  • [x] An actual interactive prompt verified end-to-end, on iOS — the monorepo-side DeviceStorageKeySection.tsx bug above is fixed, and a real UI-driven round-trip (plus a reference plugin proving persistence across a full app kill/relaunch) both succeed on the iOS Simulator.
  • [ ] The same, on Android — still blocked, but by a narrower reason than before: the UI caller now exists and reaches a real credential- confirmation dialog once a device credential is configured (a materially different, correct result from the earlier hard 'unavailable' block), but this specific AVD image's SystemUI reproducibly wedges the whole emulator when interacting with PIN/credential screens, blocking confirmation of the completed ok path in this environment. Not a regression in this repo's own code — see the task note above for the evidence trail.

Beyond the current task list ​

Research 0002 tracks capability candidates (geolocation, calendar, NFC among them) that are not yet scoped into a task above. A candidate graduates into a numbered task here only once the process in that doc is followed — a real plugin need identified, the capability added to RFC 0083's registry centrally, and permission/privacy/consent defined before implementation starts.

Open source under AGPL-3.0. Each Sovereign instance is independently operated.