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:
- The shell must expose only the narrow bridge object to page JS — never raw
window.Capacitor. - The advertised
capabilitieslist 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.Capacitordirectly. - 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.Capacitoris 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/android8.4.2 source before writing any native code:- iOS:
window.Capacitoris a WebView-levelWKUserScriptwith 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.swiftswaps theWKUserContentController's script/handler set at the local↔remote navigation boundary, replacing Capacitor's own scripts and"bridge"WKScriptMessageHandlerwith the narrow__SOVEREIGN_BRIDGE__script and asovereignBridgehandler (Bridge.swift), restoring Capacitor's own on the way back. - Android: the opposite problem.
Bridge.java'sloadWebView()already registers bothwindow.Capacitor(WebViewCompat.addDocumentStartJavaScript) and the nativeandroidBridgechannel (WebViewCompat.addWebMessageListener) scoped tobridge.getAllowedOriginRules()— which, since this app never setsserver.url, contains only the local origin. Sowindow.Capacitornever reached the remote instance to begin with; nothing to remove.NavigationPolicyWebViewClient.javaonly needed to add its ownsovereignBridgeregistration (BridgeMessageListener.java), using the same origin-scoped APIs, scoped to the runtime-chosen active instance origin instead of the static local one.
- iOS:
- Empirically verified end-to-end on both platforms (iOS Simulator, Android Emulator via this repo's bundled
.toolchain/): confirmedwindow.Capacitor === undefinedon the loaded remote instance, confirmedwindow.__SOVEREIGN_BRIDGE__.invoke('haptics.impact', ...)andinvoke('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 theokpath was exercised this way.denied(permission refused),dismissed, andunavailable(unknown capability name) are each a single, low-risk code path — the samedefault: unavailable(...)shape already shipped onsovereign-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.tsin 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/relayservice are thesovereignmonorepo'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.*/sovereignBridgecapability, 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 fromwindow.__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 asovereignmonorepo 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'sP256.KeyAgreement.PublicKey.rawRepresentationis CryptoKit's own 64-byte encoding with no0x04prefix, not the 65-byte SEC1 point the wire format needs (.x963Representationis correct); Java'sCipherAES/GCM API expectsciphertext ‖ tag(tag last), the opposite byte order from the wire format'stag ‖ ciphertext, so Android's decrypt silently produced "Tag mismatch" until the bytes were explicitly reordered. Both platforms' full projects build for real: iOS viaxcodebuild(Simulator, automatic local signing — including a genuine new Xcode target for the Notification Service Extension, created with thexcodeprojRuby gem rather than hand-edited, not just written and assumed to link); Android via./gradlew :app:assembleDebugwith nogoogle-services.jsonpresent, 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
codesignstripskeychain-access-groups/aps-environmentunder 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 (needssovereignfs's actual push credentials, provisioned server-side — see sovereign task 4.7's own leg 2); a real Firebase project'sgoogle-services.json. Revocation on sign-out is a known, documented gap — seePushRegistration.swift/.java's own doc comments — a stale token self-heals via the relay'sinvalid_tokenpruning 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.cameraPhotoin BridgeCapabilities.java and Bridge.swift), the shared SDK surface (cameraexport,packages/sdk/src/device-client.tsin the monorepo), and a browser fallback (hidden<input type="file">,captureattribute hints at the device camera). No@capacitor/cameradependency — matches haptics/notifications.native's precedent of calling native OS APIs directly (PickVisualMedia/TakePictureon Android,PHPickerViewController/UIImagePickerControlleron 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 (xcodebuildagainstApp.xcodeproj/schemeAppfor the simulator) succeeds cleanly withBridge.swift'sbiometricsConfirmandInfo.plist'sNSFaceIDUsageDescriptionadded. Android is now also build- and runtime-verified: after installing JDK 21 (thecapacitor-androidmodule'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:assembleDebugsucceeds, and the resulting APK installs and launches cleanly on thesovereign_mobile_testemulator (AVD,emulator-5554) — noFATAL/AndroidRuntimecrash in logcat,Displayed fs.sovereign.mobile/.MainActivityconfirmed, and a screenshot shows the WebView correctly rendering a real stored instance's sign-in page. A pre-existing deprecation warning (Context.VIBRATOR_SERVICEin the unrelatedhapticsImpact()method) was traced viajavapbytecode inspection and confirmed unrelated to this task'sBiometricManager/BiometricPromptcode, 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 callssdk.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'), matchingsovereign-desktop's Tauri transport and the monorepo SDK exactly — see ADR 0004, which folds haptics into v1's thin slice alongsidenotifications.nativerather 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
xcodebuildagainstApp.xcodeproj/schemeAppfor the simulator and Android:app:assembleDebug(JDK 21) both compile clean withSecureDatabase.swift/SecureDatabase.javawired intoBridge.swift/BridgeMessageListener.java'ssecureStoragedispatch, replacingSecureStorage.swift/SecureStorage.javaas the live handler (those files stay in the tree as the source of the Keychain/Keystore prompt-and-crypto primitivesSecureDatabasereuses — see their own doc comments for exactly which methods were widened fromprivateto 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 (seeMainViewController.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 — notCapApp-SPM/Package.swift, which is Capacitor-CLI-managed) on iOS, andnet.zetetic:sqlcipher-android:4.17.0+androidx.sqlite:sqliteon 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/kSecReturnDatathat hands back raw key bytes:
- iOS: the 256-bit database key is generated with
SecRandomCopyBytesand held directly in the Keychain (a distinct service string fromSecureStorage.swift's own, so a plugin can never reach it through the publicpluginId/keynamespace), gated by the same.userPresenceaccess control every other item in that file uses. Applied to SQLCipher as a raw hex-literalPRAGMA key, not viasqlite3_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 ofSecRandomCopyBytesoutput) 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()inSecureStorage.javawidened to take the alias as a parameter rather than duplicating the wholeKeyGenParameterSpecshape) and the ciphertext lives in its ownSharedPreferencesfile, unwrapped viaCipher.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 tonet.zetetic'sSQLiteDatabase.openOrCreateDatabase(File, byte[], …)— the raw-byte[]overload, confirmed by inspecting the shipped class file (javap) to exist alongside theString-passphrase overloads, giving Android the same zero-KDF raw-key path iOS gets.Both platforms also mirror iOS's own
SecureStorage.swiftre-lock design: the database connection is cached and reused, re-opened (and thus re-prompted) onceSecureStorage's existing 300-second reuse window has elapsed — on iOS this falls out for free from sharingactiveContext(); Android didn't have an equivalent to share, soSecureDatabase.javatracks elapsed wall-clock time against the sameVALIDITY_SECONDSconstant (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 —isExcludedFromBackupon iOS,getNoBackupFilesDir()(notgetFilesDir()) on Android, the latter needed because this app hasandroid: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.tsxunreachable 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 throughSecureDatabase.swift(the device's own passcode/biometric prompt, not a mock), and separately, theexample-device-onlyreference plugin (sovereignmonorepo) wrote a note through the same nativesecureStoragepath, 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
okround-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 provingBridgeMessageListener.javaand the Keystore-gated dispatch inSecureDatabase.javaare 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/dumpsyshung, andadb deviceseventually reported the deviceoffline, requiring a hard kill and restart of the emulator process each time, on multiple independent attempts (with both hardware and-gpu swiftshader_indirectsoftware rendering). This reads as a more severe manifestation of the same class of emulator instability already noted below forBiometricPrompt's device-credential fallback — not aSecureDatabase.java/BridgeMessageListener.javabug, 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, matchingdocs/pwa-real-device-testing.md's note for the web side) remains the way to settle the Androidokpath 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
sovereignmonorepo, 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 calledgetDeviceStorageKeyStatus()unconditionally, which is explicitly documented (in its own doc comment, inpackages/sdk/src/device-only-storage.ts) as the web/PWA-only path — it checksisWebAuthnAvailable()/isOpfsAvailable()/isUserVerifyingPlatformAuthenticatorAvailable(), never the bridge'ssupports('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) — seesovereign's own RFC 0093 changelog (v0.8) for the fix and its live verification. The same class of bug was also found and fixed indevice-only-kv.ts(the plugin-data storage primitive itself, distinct from this status-check UI) and in theexample-device-onlyreference 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 bothBridge.swift/BridgeMessageListener.javabut never actually advertised in either platform's handshakecapabilitiesarray — 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 reportedfalseeven though the native shell already handled it. Fixed in the same commit as addingsecureStorageto 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 perpluginId::keypair..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_CREDENTIALon API 30+, a positivesetUserAuthenticationValidityDurationSecondsbelow that — both accept the lock-screen credential, never biometric alone), encrypting values stored in ordinarySharedPreferences. 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/directLAContext-bound cipher — Android does not allow combiningCryptoObjectwith 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 (matchingpackages/sdk/src/device-only-storage.ts'sDEFAULT_RE_LOCK_POLICYin thesovereignmonorepo) 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. secureStorageadded to both platforms' handshakecapabilitiesarray, alongside thebiometrics.confirmfix noted above.SecureDatabase.swift(iOS) — the SQLCipherkv(plugin_id, key, value)table this file's own get/set/remove/keys/clear operate against; database key generation/custody and raw-keyPRAGMA keyapplication as described above; aNSLock-guarded cached connection (unlikeSecureStorage.swift's own unguardedsharedContextcache, since a concurrent double-open of a stateful native resource is a worse failure mode than a redundant re-prompt); backup-excluded viaisExcludedFromBackup.SecureDatabase.java(Android) — samekvschema and op set againstnet.zetetic:sqlcipher-android'sSQLiteDatabase; envelope-encrypted database key as described above; asynchronized-block-guarded cached connection with wall-clock re-lock tracking againstSecureStorage.java's sharedVALIDITY_SECONDS; stored undergetNoBackupFilesDir().ios/App/App.xcodeproj/project.pbxproj—SQLCipher.swiftadded as an SPM package dependency of the App target (via thexcodeprojRuby gem, not hand-edited — an SPM package reference touches enough of the object graph that manual editing was judged too risky),SQLITE_HAS_CODEC=1set on both build configurations'GCC_PREPROCESSOR_DEFINITIONS.android/app/build.gradle—net.zetetic:sqlcipher-android:4.17.0+androidx.sqlite:sqlite:2.4.0added 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 thesecureStoragecapability) is complete and build-verified. - [x] An actual interactive prompt verified end-to-end, on iOS — the monorepo-side
DeviceStorageKeySection.tsxbug 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 completedokpath 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.