ReviewOS

also looking at this

craft-native/craft

feat(android): the shared-item trio in Zig — ratchet 82 → 79

#161
Merged glennmichael123 wants to merge feat/android-shared-items into main
6 files +463 -3
packages/android/templates/CraftBridge.kt.templatemodified+12-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -3333,6 +3333,10 @@ class CraftBridge(
33333333
33343334 @JavascriptInterface
33353335 fun setSharedItem(key: String, value: String, group: String) {
3336 // Zig answers through the reply channel, so returning here is what
3337 // stops the promise being settled twice.
3338 if (CraftNative.setSharedItem(activity, key, value, group)) return
3339
33363340 try {
33373341 val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
33383342 val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
@@ -3356,6 +3360,10 @@ class CraftBridge(
33563360
33573361 @JavascriptInterface
33583362 fun getSharedItem(key: String, group: String) {
3363 // Zig answers through the reply channel, so returning here is what
3364 // stops the promise being settled twice.
3365 if (CraftNative.getSharedItem(activity, key, group)) return
3366
33593367 try {
33603368 val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
33613369 val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
@@ -3386,6 +3394,10 @@ class CraftBridge(
33863394
33873395 @JavascriptInterface
33883396 fun removeSharedItem(key: String, group: String) {
3397 // Zig answers through the reply channel, so returning here is what
3398 // stops the promise being settled twice.
3399 if (CraftNative.removeSharedItem(activity, key, group)) return
3400
33893401 try {
33903402 val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
33913403 val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
packages/android/templates/CraftNative.kt.templatemodified+35-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -113,6 +113,9 @@ object CraftNative {
113113 private external fun nativeCreateCalendarEvent(activity: Activity, eventJson: String): Boolean
114114 private external fun nativeDbExecute(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean
115115 private external fun nativeDbQuery(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean
116 private external fun nativeSetSharedItem(activity: Activity, key: String, value: String, group: String): Boolean
117 private external fun nativeGetSharedItem(activity: Activity, key: String, group: String): Boolean
118 private external fun nativeRemoveSharedItem(activity: Activity, key: String, group: String): Boolean
116119
117120 /**
118121 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -415,4 +418,36 @@ object CraftNative {
415418 false
416419 }
417420 }
421
422 /**
423 * The shared-preferences trio behind the page's `_craftSharedKeychain*`
424 * globals. Each returns whether Zig took the action; the outcome reaches
425 * the page through the reply channel.
426 */
427 fun setSharedItem(activity: Activity, key: String, value: String, group: String): Boolean {
428 if (!isAvailable) return false
429 return try {
430 nativeSetSharedItem(activity, key, value, group)
431 } catch (e: UnsatisfiedLinkError) {
432 false
433 }
434 }
435
436 fun getSharedItem(activity: Activity, key: String, group: String): Boolean {
437 if (!isAvailable) return false
438 return try {
439 nativeGetSharedItem(activity, key, group)
440 } catch (e: UnsatisfiedLinkError) {
441 false
442 }
443 }
444
445 fun removeSharedItem(activity: Activity, key: String, group: String): Boolean {
446 if (!isAvailable) return false
447 return try {
448 nativeRemoveSharedItem(activity, key, group)
449 } catch (e: UnsatisfiedLinkError) {
450 false
451 }
452 }
418453}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -461,6 +461,9 @@ pub fn build(b: *std.Build) void {
461461 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_db.zig", .{
462462 .root_source_file = b.path("src/bridge_android_db.zig"),
463463 });
464 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_shareditem.zig", .{
465 .root_source_file = b.path("src/bridge_android_shareditem.zig"),
466 });
464467 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
465468
466469 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+101-1
Changes to packages/zig/src/android_dispatch.zig
@@ -48,6 +48,7 @@ const notifcancel = ;
4848const events = ;
4949const calendar = ;
5050const db = ;
51const shareditem = ;
5152
5253const Jni = jni.Jni;
5354
@@ -262,6 +263,21 @@ const natives = [_]jni.JNINativeMethod{
262263 .signature = "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;Ljava/lang/String;)Z",
263264 .fnPtr = @ptrCast(&nativeDbQuery),
264265 },
266 .{
267 .name = "nativeSetSharedItem",
268 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z",
269 .fnPtr = @ptrCast(&nativeSetSharedItem),
270 },
271 .{
272 .name = "nativeGetSharedItem",
273 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
274 .fnPtr = @ptrCast(&nativeGetSharedItem),
275 },
276 .{
277 .name = "nativeRemoveSharedItem",
278 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
279 .fnPtr = @ptrCast(&nativeRemoveSharedItem),
280 },
265281};
266282
267283/// How binding went. A value rather than a log line, so every path is
@@ -882,6 +898,90 @@ fn nativeDbQuery(
882898 return jni.JNI_TRUE;
883899}
884900
901/// `nativeSetSharedItem(activity, key, value, group)`.
902fn nativeSetSharedItem(
903 env: jni.JNIEnv,
904 _: jni.jobject,
905 activity: jni.jobject,
906 key: jni.jstring,
907 value: jni.jstring,
908 group: jni.jstring,
909) callconv(.c) jni.jboolean {
910 const j = Jni.init(env);
911 var arena = std.heap.ArenaAllocator.init(backing);
912 defer arena.deinit();
913 const allocator = arena.allocator();
914
915 const key_text = j.stringToUtf8(allocator, key) catch return jni.JNI_FALSE;
916 const value_text = j.stringToUtf8(allocator, value) catch return jni.JNI_FALSE;
917 const group_text = j.stringToUtf8(allocator, group) catch return jni.JNI_FALSE;
918
919 shareditem.set(j, allocator, activity, group_text, key_text, value_text) catch |err| {
920 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch {};
921 return jni.JNI_TRUE;
922 };
923
924 const payload = shareditem.successPayload(allocator, key_text) catch return jni.JNI_FALSE;
925 events.settle(allocator, shareditem.resolve_global, payload) catch return jni.JNI_FALSE;
926 return jni.JNI_TRUE;
927}
928
929/// `nativeGetSharedItem(activity, key, group)`.
930///
931/// An absent key resolves with `{value: null}` rather than rejecting, which is
932/// the shim's answer: a page reading a key it never wrote has done nothing
933/// wrong.
934fn nativeGetSharedItem(
935 env: jni.JNIEnv,
936 _: jni.jobject,
937 activity: jni.jobject,
938 key: jni.jstring,
939 group: jni.jstring,
940) callconv(.c) jni.jboolean {
941 const j = Jni.init(env);
942 var arena = std.heap.ArenaAllocator.init(backing);
943 defer arena.deinit();
944 const allocator = arena.allocator();
945
946 const key_text = j.stringToUtf8(allocator, key) catch return jni.JNI_FALSE;
947 const group_text = j.stringToUtf8(allocator, group) catch return jni.JNI_FALSE;
948
949 const value = shareditem.get(j, allocator, activity, group_text, key_text) catch |err| {
950 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch {};
951 return jni.JNI_TRUE;
952 };
953
954 const payload = shareditem.valuePayload(allocator, key_text, value) catch return jni.JNI_FALSE;
955 events.settle(allocator, shareditem.resolve_global, payload) catch return jni.JNI_FALSE;
956 return jni.JNI_TRUE;
957}
958
959/// `nativeRemoveSharedItem(activity, key, group)`.
960fn nativeRemoveSharedItem(
961 env: jni.JNIEnv,
962 _: jni.jobject,
963 activity: jni.jobject,
964 key: jni.jstring,
965 group: jni.jstring,
966) callconv(.c) jni.jboolean {
967 const j = Jni.init(env);
968 var arena = std.heap.ArenaAllocator.init(backing);
969 defer arena.deinit();
970 const allocator = arena.allocator();
971
972 const key_text = j.stringToUtf8(allocator, key) catch return jni.JNI_FALSE;
973 const group_text = j.stringToUtf8(allocator, group) catch return jni.JNI_FALSE;
974
975 shareditem.remove(j, allocator, activity, group_text, key_text) catch |err| {
976 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch {};
977 return jni.JNI_TRUE;
978 };
979
980 const payload = shareditem.successPayload(allocator, key_text) catch return jni.JNI_FALSE;
981 events.settle(allocator, shareditem.resolve_global, payload) catch return jni.JNI_FALSE;
982 return jni.JNI_TRUE;
983}
984
885985// =============================================================================
886986// Tests
887987// =============================================================================
@@ -993,7 +1093,7 @@ test "the registered natives name methods the Kotlin actually declares" {
9931093 // A descriptor is checked by the JVM at registration, so a wrong one fails
9941094 // at load rather than at call — but only if the *name* matches something.
9951095 // These two strings are the contract with CraftBridge.kt.
996 try testing.expectEqual(@as(usize, 21), natives.len);
1096 try testing.expectEqual(@as(usize, 24), natives.len);
9971097 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
9981098
9991099 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_shareditem.zigadded+308-0
Changes to packages/zig/src/bridge_android_shareditem.zig
@@ -0,0 +1,308 @@
1//! `setSharedItem`, `getSharedItem` and `removeSharedItem` on Android.
2//!
3//! The iOS side of this is a keychain access group, which is why the page's
4//! globals are called `_craftSharedKeychain*`. Android has no such thing, so
5//! the shim uses a `SharedPreferences` file per group — plain, not encrypted,
6//! which is worth knowing given the name the page sees. That is the shim's
7//! choice and is ported rather than changed here.
8//!
9//! ## The group is a filename
10//!
11//! `"craft_shared_" + group` becomes the preferences file's name, and `group`
12//! is whatever the page passed. A group containing a path separator does not
13//! land where a reader would guess, and `getSharedPreferences` is what decides
14//! what happens then. Reproduced exactly: the naming is the only part of this
15//! that can silently disagree between the two implementations, and it is
16//! decided before any JNI call.
17//!
18//! ## Every reply carries the key back
19//!
20//! Both the resolve shapes name the key, so a page can tell which of several
21//! outstanding calls settled — which matters here more than elsewhere, since
22//! all three actions share one pair of globals and a second call overwrites
23//! the first's resolve function.
24
25const std = ;
26const jni = ;
27const bridge_error = ;
28
29const Jni = jni.Jni;
30const jobject = jni.jobject;
31
32pub const A = struct {
33 pub const set_shared_item = "setSharedItem";
34 pub const get_shared_item = "getSharedItem";
35 pub const remove_shared_item = "removeSharedItem";
36};
37
38pub const resolve_global = "_craftSharedKeychainResolve";
39pub const reject_global = "_craftSharedKeychainReject";
40
41/// `Context.MODE_PRIVATE`, a compile-time constant the shim's DEX holds as 0.
42const mode_private: i32 = 0;
43
44const prefix = "craft_shared";
45
46/// `if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"`.
47pub fn prefsName(allocator: std.mem.Allocator, group: []const u8) ![:0]u8 {
48 if (group.len == 0) {
49 const name = try allocator.allocSentinel(u8, prefix.len, 0);
50 @memcpy(name, prefix);
51 return name;
52 }
53
54 const name = try allocator.allocSentinel(u8, prefix.len + 1 + group.len, 0);
55 @memcpy(name[0..prefix.len], prefix);
56 name[prefix.len] = '_';
57 @memcpy(name[prefix.len + 1 ..], group);
58 return name;
59}
60
61/// `{success: true, key: "<key>"}` — what `setSharedItem` and
62/// `removeSharedItem` resolve with.
63pub fn successPayload(allocator: std.mem.Allocator, key: []const u8) ![]u8 {
64 var out: std.ArrayListUnmanaged(u8) = .empty;
65 errdefer out.deinit(allocator);
66
67 try out.appendSlice(allocator, "{\"success\":true,\"key\":\"");
68 try bridge_error.appendJsonEscaped(allocator, &out, key);
69 try out.appendSlice(allocator, "\"}");
70 return out.toOwnedSlice(allocator);
71}
72
73/// `{value: <value>, key: "<key>"}`, where a missing value is a JSON null.
74///
75/// The shim writes the two branches as two separate `evaluateJavascript`
76/// calls; the only difference between them is this argument, so they are one
77/// function here and the null is a value rather than a branch.
78pub fn valuePayload(allocator: std.mem.Allocator, key: []const u8, value: ?[]const u8) ![]u8 {
79 var out: std.ArrayListUnmanaged(u8) = .empty;
80 errdefer out.deinit(allocator);
81
82 try out.appendSlice(allocator, "{\"value\":");
83 if (value) |text| {
84 try out.append(allocator, '"');
85 try bridge_error.appendJsonEscaped(allocator, &out, text);
86 try out.append(allocator, '"');
87 } else {
88 try out.appendSlice(allocator, "null");
89 }
90 try out.appendSlice(allocator, ",\"key\":\"");
91 try bridge_error.appendJsonEscaped(allocator, &out, key);
92 try out.appendSlice(allocator, "\"}");
93 return out.toOwnedSlice(allocator);
94}
95
96/// `activity.getSharedPreferences(prefsName(group), MODE_PRIVATE)`.
97fn openPrefs(j: Jni, allocator: std.mem.Allocator, activity: jobject, group: []const u8) !jobject {
98 const name = try prefsName(allocator, group);
99 defer allocator.free(name);
100
101 const activity_cls = try j.objectClass(activity);
102 return j.callObjectMethodA(
103 activity,
104 try j.methodId(
105 activity_cls,
106 "getSharedPreferences",
107 "(Ljava/lang/String;I)Landroid/content/SharedPreferences;",
108 ),
109 &.{ .{ .l = try j.newStringUtf(name.ptr) }, .{ .i = mode_private } },
110 );
111}
112
113/// `prefs.edit().putString(key, value).apply()`.
114pub fn set(
115 j: Jni,
116 allocator: std.mem.Allocator,
117 activity: jobject,
118 group: []const u8,
119 key: []const u8,
120 value: []const u8,
121) !void {
122 try j.pushLocalFrame(16);
123 defer _ = j.popLocalFrame(null);
124
125 const prefs = try openPrefs(j, allocator, activity, group);
126 const editor = try editorFor(j, prefs);
127 const editor_cls = try j.objectClass(editor);
128
129 _ = try j.callObjectMethodA(
130 editor,
131 try j.methodId(
132 editor_cls,
133 "putString",
134 "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;",
135 ),
136 &.{
137 .{ .l = try javaString(j, allocator, key) },
138 .{ .l = try javaString(j, allocator, value) },
139 },
140 );
141
142 // `apply` rather than `commit`: it writes on a background thread and
143 // returns void, so a failure is not observable here — which is exactly
144 // what the shim's resolve already promises regardless.
145 try j.callVoidMethodA(editor, try j.methodId(editor_cls, "apply", "()V"), &.{});
146}
147
148/// `prefs.edit().remove(key).apply()`.
149pub fn remove(
150 j: Jni,
151 allocator: std.mem.Allocator,
152 activity: jobject,
153 group: []const u8,
154 key: []const u8,
155) !void {
156 try j.pushLocalFrame(16);
157 defer _ = j.popLocalFrame(null);
158
159 const prefs = try openPrefs(j, allocator, activity, group);
160 const editor = try editorFor(j, prefs);
161 const editor_cls = try j.objectClass(editor);
162
163 _ = try j.callObjectMethodA(
164 editor,
165 try j.methodId(
166 editor_cls,
167 "remove",
168 "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;",
169 ),
170 &.{.{ .l = try javaString(j, allocator, key) }},
171 );
172 try j.callVoidMethodA(editor, try j.methodId(editor_cls, "apply", "()V"), &.{});
173}
174
175/// `prefs.getString(key, null)`, null included.
176///
177/// The caller owns the returned bytes; null means the key is absent, which is
178/// the answer the page gets as `{value: null}` rather than as a rejection.
179pub fn get(
180 j: Jni,
181 allocator: std.mem.Allocator,
182 activity: jobject,
183 group: []const u8,
184 key: []const u8,
185) !?[]u8 {
186 try j.pushLocalFrame(16);
187 defer _ = j.popLocalFrame(null);
188
189 const prefs = try openPrefs(j, allocator, activity, group);
190 const value = try j.callObjectMethodA(
191 prefs,
192 try j.methodId(
193 try j.objectClass(prefs),
194 "getString",
195 "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
196 ),
197 &.{ .{ .l = try javaString(j, allocator, key) }, .{ .l = null } },
198 );
199
200 if (value == null) return null;
201 return try j.stringToUtf8(allocator, value);
202}
203
204fn editorFor(j: Jni, prefs: jobject) !jobject {
205 return j.callObjectMethod(
206 prefs,
207 try j.methodId(
208 try j.objectClass(prefs),
209 "edit",
210 "()Landroid/content/SharedPreferences$Editor;",
211 ),
212 );
213}
214
215fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
216 const terminated = try allocator.allocSentinel(u8, text.len, 0);
217 defer allocator.free(terminated);
218 @memcpy(terminated, text);
219 return j.newStringUtf(terminated.ptr);
220}
221
222// =============================================================================
223// Tests
224// =============================================================================
225
226const testing = std.testing;
227
228test "the preferences file is named the way the shim names it" {
229 const plain = try prefsName(testing.allocator, "");
230 defer testing.allocator.free(plain);
231 try testing.expectEqualStrings("craft_shared", plain);
232
233 const grouped = try prefsName(testing.allocator, "team");
234 defer testing.allocator.free(grouped);
235 try testing.expectEqualStrings("craft_shared_team", grouped);
236
237 // `isNotEmpty()` is a length check, not a blank check — a group of one
238 // space is a group, and its file is named with the space in it.
239 const spaced = try prefsName(testing.allocator, " ");
240 defer testing.allocator.free(spaced);
241 try testing.expectEqualStrings("craft_shared_ ", spaced);
242}
243
244test "the name is NUL-terminated, because FindClass's neighbour needs it" {
245 // `NewStringUTF` takes a C string. A slice that merely looks right here
246 // would read past the end at the call site instead.
247 const name = try prefsName(testing.allocator, "team");
248 defer testing.allocator.free(name);
249 try testing.expectEqual(@as(u8, 0), name.ptr[name.len]);
250}
251
252test "a set or remove resolves with the key it was given" {
253 const payload = try successPayload(testing.allocator, "token");
254 defer testing.allocator.free(payload);
255 try testing.expectEqualStrings("{\"success\":true,\"key\":\"token\"}", payload);
256}
257
258test "a get resolves with the value, or with a JSON null" {
259 const found = try valuePayload(testing.allocator, "token", "abc");
260 defer testing.allocator.free(found);
261 try testing.expectEqualStrings("{\"value\":\"abc\",\"key\":\"token\"}", found);
262
263 // The absent case is a resolve, not a rejection: a page reading a key it
264 // never wrote has done nothing wrong.
265 const missing = try valuePayload(testing.allocator, "token", null);
266 defer testing.allocator.free(missing);
267 try testing.expectEqualStrings("{\"value\":null,\"key\":\"token\"}", missing);
268
269 // And an empty string is not the absent case.
270 const empty = try valuePayload(testing.allocator, "token", "");
271 defer testing.allocator.free(empty);
272 try testing.expectEqualStrings("{\"value\":\"\",\"key\":\"token\"}", empty);
273}
274
275test "a key or value carrying a quote survives as JSON" {
276 // Both are page text, and both go through the reply channel — the same
277 // shape that hung the promise before #154 was fixed.
278 const payload = try valuePayload(testing.allocator, "it's", "a \"quoted\" value\nwith a newline");
279 defer testing.allocator.free(payload);
280
281 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
282 defer parsed.deinit();
283 try testing.expectEqualStrings("it's", parsed.value.object.get("key").?.string);
284 try testing.expectEqualStrings(
285 "a \"quoted\" value\nwith a newline",
286 parsed.value.object.get("value").?.string,
287 );
288
289 const success = try successPayload(testing.allocator, "it's");
290 defer testing.allocator.free(success);
291 var parsed_success = try std.json.parseFromSlice(std.json.Value, testing.allocator, success, .{});
292 defer parsed_success.deinit();
293 try testing.expectEqualStrings("it's", parsed_success.value.object.get("key").?.string);
294 try testing.expect(parsed_success.value.object.get("success").?.bool);
295}
296
297test "MODE_PRIVATE is zero" {
298 // A compile-time constant in Java, so the shim's DEX holds the literal.
299 try testing.expectEqual(@as(i32, 0), mode_private);
300}
301
302test "the actions and globals match the shim exactly" {
303 try testing.expectEqualStrings("setSharedItem", A.set_shared_item);
304 try testing.expectEqualStrings("getSharedItem", A.get_shared_item);
305 try testing.expectEqualStrings("removeSharedItem", A.remove_shared_item);
306 try testing.expectEqualStrings("_craftSharedKeychainResolve", resolve_global);
307 try testing.expectEqualStrings("_craftSharedKeychainReject", reject_global);
308}
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -49,6 +49,7 @@ const zig_sources = [_][]const u8{
4949 @embedFile("src/bridge_android_notifcancel.zig"),
5050 @embedFile("src/bridge_android_calendar.zig"),
5151 @embedFile("src/bridge_android_db.zig"),
52 @embedFile("src/bridge_android_shareditem.zig"),
5253};
5354
5455/// How many spec actions Zig does not serve yet.
@@ -67,8 +68,9 @@ const zig_sources = [_][]const u8{
6768/// through the reply channel rather than by returning; 85 with
6869/// getCalendarEvents, the first to send the page a payload it built rather
6970/// than a constant; 84 with createCalendarEvent, the first to read one; 82
70/// with the database pair, the first to borrow a connection the Kotlin owns.
71const max_not_yet_migrated: usize = 82;
71/// with the database pair, the first to borrow a connection the Kotlin owns;
72/// 79 with the shared-item trio.
73const max_not_yet_migrated: usize = 79;
7274
7375/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
7476///