ReviewOS

also looking at this

craft-native/craft

feat(android): the secure-storage quartet — ratchet 95 -> 91

#147
Merged chris wants to merge feat/android-secure-storage into main
6 files +501 -3
packages/android/templates/CraftBridge.kt.templatemodified+14-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1544,6 +1544,8 @@ class CraftBridge(
15441544
15451545 @JavascriptInterface
15461546 fun secureSet(key: String, value: String): Boolean {
1547 if (CraftNative.secureSet(securePrefs, key, value)) return true
1548
15471549 return try {
15481550 securePrefs.edit().putString(key, value).apply()
15491551 true
@@ -1554,6 +1556,14 @@ class CraftBridge(
15541556
15551557 @JavascriptInterface
15561558 fun secureGet(key: String): String? {
1559 // Three answers, not two: Declined falls through, NotFound is a real
1560 // null, Found is a real value that may be the empty string.
1561 when (val read = CraftNative.secureGet(securePrefs, key)) {
1562 is CraftNative.SecureRead.Found -> return read.value
1563 CraftNative.SecureRead.NotFound -> return null
1564 CraftNative.SecureRead.Declined -> Unit
1565 }
1566
15571567 return try {
15581568 securePrefs.getString(key, null)
15591569 } catch (e: Exception) {
@@ -1563,6 +1573,8 @@ class CraftBridge(
15631573
15641574 @JavascriptInterface
15651575 fun secureRemove(key: String): Boolean {
1576 if (CraftNative.secureRemove(securePrefs, key)) return true
1577
15661578 return try {
15671579 securePrefs.edit().remove(key).apply()
15681580 true
@@ -1573,6 +1585,8 @@ class CraftBridge(
15731585
15741586 @JavascriptInterface
15751587 fun secureClear(): Boolean {
1588 if (CraftNative.secureClear(securePrefs)) return true
1589
15761590 return try {
15771591 securePrefs.edit().clear().apply()
15781592 true
packages/android/templates/CraftNative.kt.templatemodified+73-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,8 @@
11package com.craft.runtime
22
33import android.app.Activity
4import android.content.SharedPreferences
5import org.json.JSONObject
46
57/**
68 * The Zig runtime's native methods.
@@ -64,6 +66,10 @@ object CraftNative {
6466 private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
6567 private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
6668 private external fun nativeGetNetworkStatus(activity: Activity): String?
69 private external fun nativeSecureSet(prefs: SharedPreferences, key: String, value: String): Boolean
70 private external fun nativeSecureGet(prefs: SharedPreferences, key: String): String?
71 private external fun nativeSecureRemove(prefs: SharedPreferences, key: String): Boolean
72 private external fun nativeSecureClear(prefs: SharedPreferences): Boolean
6773
6874 /**
6975 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -176,4 +182,71 @@ object CraftNative {
176182 null
177183 }
178184 }
185
186 /**
187 * The three answers a secure read can give.
188 *
189 * `secureGet` returns `String?` to the page, where null means "no such
190 * key". The seam needs to say "Zig did not serve this" as well, and a
191 * nullable String cannot carry both — an absent key and a declined call
192 * lead to different code. There is no spare value to steal either: `""` is
193 * a legitimate stored value, and so is any sentinel a caller could have
194 * saved.
195 */
196 sealed class SecureRead {
197 /** Zig did not serve the call; run the Kotlin implementation. */
198 object Declined : SecureRead()
199
200 /** Zig served it and the key is not present. */
201 object NotFound : SecureRead()
202
203 /** Zig served it and the key holds [value], which may be empty. */
204 data class Found(val value: String) : SecureRead()
205 }
206
207 fun secureSet(prefs: SharedPreferences, key: String, value: String): Boolean {
208 if (!isAvailable) return false
209 return try {
210 nativeSecureSet(prefs, key, value)
211 } catch (e: UnsatisfiedLinkError) {
212 false
213 }
214 }
215
216 fun secureGet(prefs: SharedPreferences, key: String): SecureRead {
217 if (!isAvailable) return SecureRead.Declined
218 val envelope = try {
219 nativeSecureGet(prefs, key)
220 } catch (e: UnsatisfiedLinkError) {
221 null
222 } ?: return SecureRead.Declined
223
224 // A malformed envelope is Zig's bug, and declining is the safe answer:
225 // the Kotlin then reads the store itself and the page sees the truth.
226 return try {
227 val json = JSONObject(envelope)
228 if (json.getBoolean("found")) SecureRead.Found(json.getString("value"))
229 else SecureRead.NotFound
230 } catch (e: Exception) {
231 SecureRead.Declined
232 }
233 }
234
235 fun secureRemove(prefs: SharedPreferences, key: String): Boolean {
236 if (!isAvailable) return false
237 return try {
238 nativeSecureRemove(prefs, key)
239 } catch (e: UnsatisfiedLinkError) {
240 false
241 }
242 }
243
244 fun secureClear(prefs: SharedPreferences): Boolean {
245 if (!isAvailable) return false
246 return try {
247 nativeSecureClear(prefs)
248 } catch (e: UnsatisfiedLinkError) {
249 false
250 }
251 }
179252}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -446,6 +446,9 @@ pub fn build(b: *std.Build) void {
446446 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_network.zig", .{
447447 .root_source_file = b.path("src/bridge_android_network.zig"),
448448 });
449 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_securestore.zig", .{
450 .root_source_file = b.path("src/bridge_android_securestore.zig"),
451 });
449452 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
450453
451454 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+103-1
Changes to packages/zig/src/android_dispatch.zig
@@ -41,6 +41,7 @@ const system = ;
4141const clipboard = ;
4242const intents = ;
4343const network = ;
44const securestore = ;
4445
4546const Jni = jni.Jni;
4647
@@ -165,6 +166,30 @@ const natives = [_]jni.JNINativeMethod{
165166 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
166167 .fnPtr = @ptrCast(&nativeGetNetworkStatus),
167168 },
169 // These four take the SharedPreferences rather than the Activity: the
170 // store is EncryptedSharedPreferences over a MasterKey the Kotlin already
171 // built, and rebuilding it here would be a second implementation of a
172 // security-sensitive construction that has to agree exactly.
173 .{
174 .name = "nativeSecureSet",
175 .signature = "(Landroid/content/SharedPreferences;Ljava/lang/String;Ljava/lang/String;)Z",
176 .fnPtr = @ptrCast(&nativeSecureSet),
177 },
178 .{
179 .name = "nativeSecureGet",
180 .signature = "(Landroid/content/SharedPreferences;Ljava/lang/String;)Ljava/lang/String;",
181 .fnPtr = @ptrCast(&nativeSecureGet),
182 },
183 .{
184 .name = "nativeSecureRemove",
185 .signature = "(Landroid/content/SharedPreferences;Ljava/lang/String;)Z",
186 .fnPtr = @ptrCast(&nativeSecureRemove),
187 },
188 .{
189 .name = "nativeSecureClear",
190 .signature = "(Landroid/content/SharedPreferences;)Z",
191 .fnPtr = @ptrCast(&nativeSecureClear),
192 },
168193};
169194
170195/// How binding went. A value rather than a log line, so every path is
@@ -388,6 +413,83 @@ fn nativeGetNetworkStatus(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject
388413 return j.newStringUtf(owned.ptr) catch null;
389414}
390415
416fn nativeSecureSet(
417 env: jni.JNIEnv,
418 _: jni.jobject,
419 prefs: jni.jobject,
420 key: jni.jstring,
421 value: jni.jstring,
422) callconv(.c) jni.jboolean {
423 const j = Jni.init(env);
424 var arena = std.heap.ArenaAllocator.init(backing);
425 defer arena.deinit();
426 const allocator = arena.allocator();
427
428 const k = ownedUtf8(j, allocator, key) orelse return jni.JNI_FALSE;
429 const v = ownedUtf8(j, allocator, value) orelse return jni.JNI_FALSE;
430
431 securestore.set(j, prefs, k.ptr, v.ptr) catch |err| {
432 std.log.warn("craft: secureSet fell through to the shim ({s})", .{@errorName(err)});
433 return jni.JNI_FALSE;
434 };
435 return jni.JNI_TRUE;
436}
437
438/// Returns the `{"found":…}` envelope, or null when Zig did not serve the call.
439///
440/// Null cannot mean "no such key" here — the Kotlin already uses null for
441/// that, and the seam needs a third answer. See the module comment on
442/// `bridge_android_securestore.zig`.
443fn nativeSecureGet(
444 env: jni.JNIEnv,
445 _: jni.jobject,
446 prefs: jni.jobject,
447 key: jni.jstring,
448) callconv(.c) jni.jstring {
449 const j = Jni.init(env);
450 var arena = std.heap.ArenaAllocator.init(backing);
451 defer arena.deinit();
452 const allocator = arena.allocator();
453
454 const k = ownedUtf8(j, allocator, key) orelse return null;
455 const value = securestore.get(allocator, j, prefs, k.ptr) catch |err| {
456 std.log.warn("craft: secureGet fell through to the shim ({s})", .{@errorName(err)});
457 return null;
458 };
459
460 const json = securestore.renderRead(allocator, value) catch return null;
461 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
462 @memcpy(owned, json);
463 return j.newStringUtf(owned.ptr) catch null;
464}
465
466fn nativeSecureRemove(
467 env: jni.JNIEnv,
468 _: jni.jobject,
469 prefs: jni.jobject,
470 key: jni.jstring,
471) callconv(.c) jni.jboolean {
472 const j = Jni.init(env);
473 var arena = std.heap.ArenaAllocator.init(backing);
474 defer arena.deinit();
475
476 const k = ownedUtf8(j, arena.allocator(), key) orelse return jni.JNI_FALSE;
477 securestore.remove(j, prefs, k.ptr) catch |err| {
478 std.log.warn("craft: secureRemove fell through to the shim ({s})", .{@errorName(err)});
479 return jni.JNI_FALSE;
480 };
481 return jni.JNI_TRUE;
482}
483
484fn nativeSecureClear(env: jni.JNIEnv, _: jni.jobject, prefs: jni.jobject) callconv(.c) jni.jboolean {
485 const j = Jni.init(env);
486 securestore.clear(j, prefs) catch |err| {
487 std.log.warn("craft: secureClear fell through to the shim ({s})", .{@errorName(err)});
488 return jni.JNI_FALSE;
489 };
490 return jni.JNI_TRUE;
491}
492
391493// =============================================================================
392494// Tests
393495// =============================================================================
@@ -499,7 +601,7 @@ test "the registered natives name methods the Kotlin actually declares" {
499601 // A descriptor is checked by the JVM at registration, so a wrong one fails
500602 // at load rather than at call — but only if the *name* matches something.
501603 // These two strings are the contract with CraftBridge.kt.
502 try testing.expectEqual(@as(usize, 8), natives.len);
604 try testing.expectEqual(@as(usize, 12), natives.len);
503605 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
504606
505607 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_securestore.zigadded+303-0
Changes to packages/zig/src/bridge_android_securestore.zig
@@ -0,0 +1,303 @@
1//! The Android secure-storage quartet: `secureSet`, `secureGet`,
2//! `secureRemove`, `secureClear`.
3//!
4//! ## Kotlin passes the store; Zig does not build one
5//!
6//! Every other action here reaches its platform object through the `Activity`.
7//! These four do not, and the difference is deliberate. `securePrefs` is
8//!
9//! EncryptedSharedPreferences.create(activity, "craft_secure_prefs",
10//! masterKey, AES256_SIV, AES256_GCM)
11//!
12//! over a lazily-built `MasterKey`. Zig could construct that — it is two
13//! builder calls and two enum constants — and constructing it would be a
14//! mistake. It is a second implementation of a security-sensitive
15//! construction, in a second language, that has to agree with the first
16//! exactly: a different key scheme, a different file name, a different SIV
17//! mode, and the two halves write data neither can read. Nothing would fail
18//! at build time and the symptom would be a user's saved token disappearing
19//! after an app update.
20//!
21//! So the native methods take the `SharedPreferences` as a parameter. The
22//! object is Kotlin's, the lifetime is Kotlin's, and Zig only ever calls the
23//! plain `SharedPreferences` interface on it — which is the whole of what the
24//! Kotlin does too, since `EncryptedSharedPreferences` *is* a
25//! `SharedPreferences` and the encryption is behind the interface.
26//!
27//! This generalises: where the shim already holds a configured platform
28//! object, handing it across the seam beats rebuilding it.
29//!
30//! ## `secureGet` has three answers where the others have two
31//!
32//! The Kotlin returns `String?`, and null there means "no such key". The seam
33//! also needs to say "Zig did not serve this" — and a nullable String cannot
34//! carry both, because an absent key and a declined call are different things
35//! and the caller must do different things about them.
36//!
37//! There is no spare value to steal: `""` is a legitimate stored value, and so
38//! is any sentinel a caller could also have saved. So the reply is an envelope,
39//! `{"found":…}`, and null is reserved for "declined". Three states, three
40//! representations, none of them overloaded.
41
42const std = ;
43const jni = ;
44const bridge_error = ;
45
46const Jni = jni.Jni;
47const jobject = jni.jobject;
48
49pub const A = struct {
50 pub const secure_set = "secureSet";
51 pub const secure_get = "secureGet";
52 pub const secure_remove = "secureRemove";
53 pub const secure_clear = "secureClear";
54};
55
56/// `prefs.edit()` — the editor every write goes through.
57///
58/// `SharedPreferences.Editor` is an inner interface, so its binary name
59/// carries the `$`: `android/content/SharedPreferences$Editor`.
60fn editor(j: Jni, prefs: jobject) !jobject {
61 const prefs_cls = try j.objectClass(prefs);
62 return j.callObjectMethod(
63 prefs,
64 try j.methodId(prefs_cls, "edit", "()Landroid/content/SharedPreferences$Editor;"),
65 );
66}
67
68/// `edit.apply()`.
69///
70/// `apply` rather than `commit`, matching the Kotlin. It writes to memory
71/// synchronously and to disk on a background thread, and returns void — so a
72/// disk failure is invisible to both implementations, and "true" here means
73/// the edit was accepted rather than persisted. Switching to `commit` would
74/// make Zig report a truth the shim does not, which is a divergence even when
75/// it is an improvement.
76fn apply(j: Jni, edit: jobject) !void {
77 const edit_cls = try j.objectClass(edit);
78 try j.callVoidMethodA(edit, try j.methodId(edit_cls, "apply", "()V"), &.{});
79}
80
81pub fn set(j: Jni, prefs: jobject, key: [*:0]const u8, value: [*:0]const u8) !void {
82 try j.pushLocalFrame(16);
83 defer _ = j.popLocalFrame(null);
84
85 const edit = try editor(j, prefs);
86 const edit_cls = try j.objectClass(edit);
87
88 _ = try j.callObjectMethodA(
89 edit,
90 try j.methodId(
91 edit_cls,
92 "putString",
93 "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;",
94 ),
95 &.{ .{ .l = try j.newStringUtf(key) }, .{ .l = try j.newStringUtf(value) } },
96 );
97 try apply(j, edit);
98}
99
100pub fn remove(j: Jni, prefs: jobject, key: [*:0]const u8) !void {
101 try j.pushLocalFrame(16);
102 defer _ = j.popLocalFrame(null);
103
104 const edit = try editor(j, prefs);
105 const edit_cls = try j.objectClass(edit);
106
107 _ = try j.callObjectMethodA(
108 edit,
109 try j.methodId(edit_cls, "remove", "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;"),
110 &.{.{ .l = try j.newStringUtf(key) }},
111 );
112 try apply(j, edit);
113}
114
115pub fn clear(j: Jni, prefs: jobject) !void {
116 try j.pushLocalFrame(16);
117 defer _ = j.popLocalFrame(null);
118
119 const edit = try editor(j, prefs);
120 const edit_cls = try j.objectClass(edit);
121
122 _ = try j.callObjectMethodA(
123 edit,
124 try j.methodId(edit_cls, "clear", "()Landroid/content/SharedPreferences$Editor;"),
125 &.{},
126 );
127 try apply(j, edit);
128}
129
130/// The reply envelope for `secureGet`. See the module comment for why a bare
131/// nullable string cannot carry these three states.
132pub fn renderRead(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 {
133 var out: std.ArrayListUnmanaged(u8) = .empty;
134 errdefer out.deinit(allocator);
135
136 if (value) |text| {
137 try out.appendSlice(allocator, "{\"found\":true,\"value\":\"");
138 // The quotes are this caller's job — `appendJsonEscaped` escapes the
139 // contents and writes no delimiters. A stored value containing a quote
140 // would otherwise tear the envelope and read back as absent.
141 try bridge_error.appendJsonEscaped(allocator, &out, text);
142 try out.appendSlice(allocator, "\"}");
143 } else {
144 try out.appendSlice(allocator, "{\"found\":false}");
145 }
146 return out.toOwnedSlice(allocator);
147}
148
149/// `prefs.getString(key, null)`, as an owned optional. Caller frees.
150pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: [*:0]const u8) !?[]u8 {
151 try j.pushLocalFrame(16);
152 defer _ = j.popLocalFrame(null);
153
154 const prefs_cls = try j.objectClass(prefs);
155 const value = try j.callObjectMethodA(
156 prefs,
157 try j.methodId(
158 prefs_cls,
159 "getString",
160 "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
161 ),
162 // The default is null, which is what makes "absent" distinguishable
163 // from a stored empty string.
164 &.{ .{ .l = try j.newStringUtf(key) }, .{ .l = null } },
165 );
166 if (value == null) return null;
167 return try j.stringToUtf8(allocator, value);
168}
169
170// =============================================================================
171// Tests
172// =============================================================================
173
174const testing = std.testing;
175
176test "the read envelope distinguishes absent from empty" {
177 // The whole reason the envelope exists. A stored `""` and a missing key
178 // are different, and a bare nullable string collapses them the moment the
179 // seam also needs to say "declined".
180 const absent = try renderRead(testing.allocator, null);
181 defer testing.allocator.free(absent);
182 const empty = try renderRead(testing.allocator, "");
183 defer testing.allocator.free(empty);
184
185 var a = try std.json.parseFromSlice(std.json.Value, testing.allocator, absent, .{});
186 defer a.deinit();
187 var e = try std.json.parseFromSlice(std.json.Value, testing.allocator, empty, .{});
188 defer e.deinit();
189
190 try testing.expectEqual(false, a.value.object.get("found").?.bool);
191 try testing.expect(a.value.object.get("value") == null);
192
193 try testing.expectEqual(true, e.value.object.get("found").?.bool);
194 try testing.expectEqualStrings("", e.value.object.get("value").?.string);
195}
196
197test "a stored value containing a quote does not tear the envelope" {
198 // Secure storage holds tokens, and a JWT is base64 — but nothing stops a
199 // page storing arbitrary text, and `appendJsonEscaped` writes no
200 // delimiters of its own. The same trap that broke the iOS
201 // location-recording state file.
202 const json = try renderRead(testing.allocator, "a\"b\\c\nd");
203 defer testing.allocator.free(json);
204
205 var p = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
206 defer p.deinit();
207 try testing.expectEqual(true, p.value.object.get("found").?.bool);
208 try testing.expectEqualStrings("a\"b\\c\nd", p.value.object.get("value").?.string);
209}
210
211test "the action names match the Kotlin methods exactly" {
212 try testing.expectEqualStrings("secureSet", A.secure_set);
213 try testing.expectEqualStrings("secureGet", A.secure_get);
214 try testing.expectEqualStrings("secureRemove", A.secure_remove);
215 try testing.expectEqualStrings("secureClear", A.secure_clear);
216}
217
218// --- A fake SharedPreferences ---------------------------------------------
219//
220// `renderRead` above tests the envelope, and the envelope is only half the
221// decision: whether a key is absent is settled in `get`, against
222// `getString(key, null)`. A mutation returning `""` for an absent key passed
223// every test in this file until these were written — the same shape of gap the
224// envelope exists to close, one layer down.
225
226var fake_storage: [8]u8 = undefined;
227var fake_value_present = true;
228
229fn sobj(tag: usize) jobject {
230 return @ptrCast(&fake_storage[tag]);
231}
232fn sObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass {
233 return sobj(0);
234}
235fn sMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
236 return @ptrCast(@constCast(name));
237}
238fn sNewStringUTF(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jstring {
239 return sobj(1);
240}
241fn sCallObjectMethodA(_: jni.JNIEnv, _: jobject, _: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) jobject {
242 // `getString(key, null)` — the second argument is the default, and the
243 // fake honours it rather than inventing one, because "the default comes
244 // back" is precisely the absent case.
245 if (!fake_value_present) return args[1].l;
246 return sobj(2);
247}
248fn sGetStringUTFChars(_: jni.JNIEnv, _: jni.jstring, _: ?*jni.jboolean) callconv(.c) ?[*:0]const u8 {
249 return "stored";
250}
251fn sReleaseStringUTFChars(_: jni.JNIEnv, _: jni.jstring, _: [*:0]const u8) callconv(.c) void {}
252fn sExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
253 return null;
254}
255fn sPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
256 return 0;
257}
258fn sPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject {
259 return keep;
260}
261
262fn getWith(present: bool) !?[]u8 {
263 fake_value_present = present;
264 var table = std.mem.zeroes(jni.JNINativeInterface);
265 table.GetObjectClass = @ptrCast(&sObjectClass);
266 table.GetMethodID = @ptrCast(&sMethodId);
267 table.NewStringUTF = @ptrCast(&sNewStringUTF);
268 table.CallObjectMethodA = @ptrCast(&sCallObjectMethodA);
269 table.GetStringUTFChars = @ptrCast(&sGetStringUTFChars);
270 table.ReleaseStringUTFChars = @ptrCast(&sReleaseStringUTFChars);
271 table.ExceptionOccurred = @ptrCast(&sExceptionOccurred);
272 table.PushLocalFrame = @ptrCast(&sPush);
273 table.PopLocalFrame = @ptrCast(&sPop);
274
275 const ptr: *const jni.JNINativeInterface = &table;
276 return get(testing.allocator, Jni.init(&ptr), sobj(3), "token");
277}
278
279test "an absent key reads as null, never as an empty string" {
280 // The mutation this was written for: returning `""` here instead of null
281 // makes every missing key look like a stored empty string, and the
282 // envelope then reports `found: true` for something that was never saved.
283 const absent = try getWith(false);
284 try testing.expect(absent == null);
285
286 const present = try getWith(true);
287 defer if (present) |p| testing.allocator.free(p);
288 try testing.expect(present != null);
289 try testing.expectEqualStrings("stored", present.?);
290}
291
292test "the absent case survives the round trip through the envelope" {
293 // End to end, because the two halves are only correct together: a `get`
294 // that loses the distinction and a `renderRead` that keeps it still
295 // produce `{"found":true}` for a key nobody stored.
296 const absent = try getWith(false);
297 const json = try renderRead(testing.allocator, absent);
298 defer testing.allocator.free(json);
299
300 var p = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
301 defer p.deinit();
302 try testing.expectEqual(false, p.value.object.get("found").?.bool);
303}
packages/zig/test/android_conformance_test.zigmodified+5-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -44,6 +44,7 @@ const zig_sources = [_][]const u8{
4444 @embedFile("src/bridge_android_clipboard.zig"),
4545 @embedFile("src/bridge_android_intents.zig"),
4646 @embedFile("src/bridge_android_network.zig"),
47 @embedFile("src/bridge_android_securestore.zig"),
4748};
4849
4950/// How many spec actions Zig does not serve yet.
@@ -55,8 +56,10 @@ const zig_sources = [_][]const u8{
5556/// History: 103 at the seam; 102 with getDeviceInfo; 100 with getMemoryUsage
5657/// and log — the first two that need no Activity at all; 98 with the
5758/// clipboard pair, the first that do; 96 with openURL and share; 95 with
58/// getNetworkStatus, which iOS declares unavailable and Android can answer.
59const max_not_yet_migrated: usize = 95;
59/// getNetworkStatus, which iOS declares unavailable and Android can answer;
60/// 91 with the secure-storage quartet, the first to take a Kotlin-held object
61/// rather than the Activity.
62const max_not_yet_migrated: usize = 91;
6063
6164/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
6265///