Skip to content

Commit 56ed2ab

Browse files
committed
feat: native Capacitor plugin with parallel PQ crypto
Compile secure-storage as native .so (Android) / .a (iOS) instead of running PQ crypto in WASM. UniFFI generates Kotlin + Swift bindings. Architecture: - TypeScript platform routing → Capacitor plugin → UniFFI → Rust native - native_vfs.rs: SQLite VFS with buffered writes, merged flush, pending overlay - redb single-file ACID storage (replaces flat files + custom WAL) - Parallel PQ: execute_pq_jobs dispatches across 2 workers + main thread - Cross-block batching: N×5 PQ ops in one parallel round Performance (Android ARM): - Message INSERT: 3.3s (WASM) → 220ms (native parallel) - UserProfile INSERT: 666ms → 100ms - Small UPDATE: 110ms → 65ms Includes: Android .so (3 targets), iOS XCFramework, Kotlin/Swift plugins, build script, UniFFI bindgen, benchmark tests.
1 parent 6def6df commit 56ed2ab

File tree

33 files changed

+7680
-112
lines changed

33 files changed

+7680
-112
lines changed

android/app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
apply plugin: 'com.android.application'
2+
apply plugin: 'org.jetbrains.kotlin.android'
23

34
android {
45
namespace = "net.massa.gossip"
@@ -79,6 +80,9 @@ dependencies {
7980
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
8081
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
8182
implementation project(':capacitor-cordova-android-plugins')
83+
// UniFFI-generated Kotlin bindings require JNA + Kotlin stdlib
84+
implementation 'net.java.dev.jna:jna:5.17.0@aar'
85+
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.1.0'
8286
}
8387

8488
apply from: 'capacitor.build.gradle'

android/app/src/main/java/net/massa/gossip/MainActivity.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public void onCreate(Bundle savedInstanceState) {
2929
registerPlugin(BatteryOptimizationPlugin.class);
3030
registerPlugin(NetworkObserverPlugin.class);
3131
registerPlugin(BackgroundRunnerStoragePlugin.class);
32-
32+
registerPlugin(SecureStoragePlugin.class);
33+
3334
super.onCreate(savedInstanceState);
3435

3536
// Samsung (OneUI) ignores adjustNothing and always resizes the WebView.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package net.massa.gossip
2+
3+
import com.getcapacitor.JSArray
4+
import com.getcapacitor.JSObject
5+
import com.getcapacitor.Plugin
6+
import com.getcapacitor.PluginCall
7+
import com.getcapacitor.PluginMethod
8+
import com.getcapacitor.annotation.CapacitorPlugin
9+
import java.util.concurrent.Executors
10+
import java.util.concurrent.ThreadFactory
11+
import org.json.JSONArray
12+
import org.json.JSONObject
13+
14+
import uniffi.secureStorage.initSecureStorage as rustInitSecureStorage
15+
import uniffi.secureStorage.provisionStorageNative
16+
import uniffi.secureStorage.allocateSessionNative
17+
import uniffi.secureStorage.unlockSessionNative
18+
import uniffi.secureStorage.lockSessionNative
19+
import uniffi.secureStorage.isUnlockedNative
20+
import uniffi.secureStorage.coverTrafficTickNative
21+
import uniffi.secureStorage.execSqlNative
22+
import uniffi.secureStorage.flushNative
23+
import uniffi.secureStorage.closeNative
24+
import uniffi.secureStorage.SqlParam
25+
import uniffi.secureStorage.SqlValue
26+
import uniffi.secureStorage.QueryResult
27+
import uniffi.secureStorage.SecureStorageException
28+
29+
@CapacitorPlugin(name = "SecureStorageNative")
30+
class SecureStoragePlugin : Plugin() {
31+
32+
// PQ (ML-KEM) crypto needs ~4MB stack; default thread pool has ~512KB.
33+
private val executor = Executors.newSingleThreadExecutor(ThreadFactory { r ->
34+
Thread(null, r, "secure-storage", 8 * 1024 * 1024)
35+
})
36+
37+
@PluginMethod
38+
fun initSecureStorage(call: PluginCall) {
39+
val path = call.getString("path") ?: return call.reject("missing path")
40+
val domain = call.getString("domain") ?: "gossip"
41+
executor.execute {
42+
try {
43+
val appDir = context.filesDir.resolve(path).absolutePath
44+
rustInitSecureStorage(appDir, domain)
45+
call.resolve()
46+
} catch (e: SecureStorageException) {
47+
call.reject(e.message, e)
48+
}
49+
}
50+
}
51+
52+
@PluginMethod
53+
fun provisionStorage(call: PluginCall) {
54+
executor.execute {
55+
try {
56+
val fresh = provisionStorageNative()
57+
val result = JSObject()
58+
result.put("fresh", fresh)
59+
call.resolve(result)
60+
} catch (e: SecureStorageException) { call.reject(e.message, e) }
61+
}
62+
}
63+
64+
@PluginMethod
65+
fun allocateSession(call: PluginCall) {
66+
val slot = call.getInt("slot") ?: return call.reject("missing slot")
67+
val pw = call.getArray("password") ?: return call.reject("missing password")
68+
val password = jsArrayToByteArray(pw)
69+
executor.execute {
70+
try { allocateSessionNative(slot.toUByte(), password); call.resolve() }
71+
catch (e: SecureStorageException) { call.reject(e.message, e) }
72+
}
73+
}
74+
75+
@PluginMethod
76+
fun unlockSession(call: PluginCall) {
77+
val pw = call.getArray("password") ?: return call.reject("missing password")
78+
val password = jsArrayToByteArray(pw)
79+
executor.execute {
80+
try {
81+
val unlocked = unlockSessionNative(password)
82+
call.resolve(JSObject().put("unlocked", unlocked))
83+
} catch (e: SecureStorageException) { call.reject(e.message, e) }
84+
}
85+
}
86+
87+
@PluginMethod
88+
fun lockSession(call: PluginCall) {
89+
executor.execute {
90+
try { lockSessionNative(); call.resolve() }
91+
catch (e: SecureStorageException) { call.reject(e.message, e) }
92+
}
93+
}
94+
95+
@PluginMethod
96+
fun isUnlocked(call: PluginCall) {
97+
executor.execute {
98+
try {
99+
val unlocked = isUnlockedNative()
100+
call.resolve(JSObject().put("unlocked", unlocked))
101+
} catch (e: SecureStorageException) { call.reject(e.message, e) }
102+
}
103+
}
104+
105+
@PluginMethod
106+
fun coverTrafficTick(call: PluginCall) {
107+
executor.execute {
108+
try { coverTrafficTickNative(); call.resolve() }
109+
catch (e: SecureStorageException) { call.reject(e.message, e) }
110+
}
111+
}
112+
113+
@PluginMethod
114+
fun execSql(call: PluginCall) {
115+
val sql = call.getString("sql") ?: return call.reject("missing sql")
116+
val paramsJson = call.getArray("params") ?: JSArray()
117+
val params = convertSqlParams(paramsJson)
118+
executor.execute {
119+
try {
120+
val qr = execSqlNative(sql, params)
121+
val result = JSObject()
122+
val cols = JSArray()
123+
for (c in qr.columns) cols.put(c)
124+
result.put("columns", cols)
125+
val rows = JSArray()
126+
for (row in qr.rows) {
127+
val r = JSArray()
128+
for (v in row) r.put(sqlValueToJson(v))
129+
rows.put(r)
130+
}
131+
result.put("rows", rows)
132+
result.put("lastInsertRowId", qr.lastInsertRowid)
133+
result.put("changes", qr.changes)
134+
call.resolve(result)
135+
} catch (e: SecureStorageException) { call.reject(e.message, e) }
136+
}
137+
}
138+
139+
@PluginMethod
140+
fun flush(call: PluginCall) {
141+
executor.execute {
142+
try { flushNative(); call.resolve() }
143+
catch (e: SecureStorageException) { call.reject(e.message, e) }
144+
}
145+
}
146+
147+
@PluginMethod
148+
fun close(call: PluginCall) {
149+
executor.execute {
150+
try { closeNative(); call.resolve() }
151+
catch (e: SecureStorageException) { call.reject(e.message, e) }
152+
}
153+
}
154+
155+
// ── Helpers ─────────────────────────────────────────────────────
156+
157+
private fun jsArrayToByteArray(arr: JSArray): ByteArray {
158+
val bytes = ByteArray(arr.length())
159+
for (i in 0 until arr.length()) bytes[i] = arr.getInt(i).toByte()
160+
return bytes
161+
}
162+
163+
private fun convertSqlParams(arr: JSArray): List<SqlParam> {
164+
val params = mutableListOf<SqlParam>()
165+
for (i in 0 until arr.length()) {
166+
val v = arr.opt(i)
167+
params.add(when {
168+
v == null || v == JSONObject.NULL -> SqlParam.Null
169+
v is Boolean -> SqlParam.Integer(if (v) 1L else 0L)
170+
v is Int -> SqlParam.Integer(v.toLong())
171+
v is Long -> SqlParam.Integer(v)
172+
v is Double -> SqlParam.Real(v)
173+
v is Float -> SqlParam.Real(v.toDouble())
174+
v is String -> SqlParam.Text(v)
175+
v is JSONArray -> {
176+
val b = ByteArray(v.length())
177+
for (j in 0 until v.length()) b[j] = v.getInt(j).toByte()
178+
SqlParam.Blob(b)
179+
}
180+
else -> SqlParam.Text(v.toString())
181+
})
182+
}
183+
return params
184+
}
185+
186+
private fun sqlValueToJson(v: SqlValue): Any? = when (v) {
187+
is SqlValue.Null -> JSONObject.NULL
188+
is SqlValue.Integer -> v.`value`
189+
is SqlValue.Real -> v.`value`
190+
is SqlValue.Text -> v.`value`
191+
is SqlValue.Blob -> {
192+
val arr = JSONArray()
193+
for (b in v.`value`) arr.put(b.toInt() and 0xFF)
194+
arr
195+
}
196+
}
197+
}

0 commit comments

Comments
 (0)