ReviewOS

also looking at this

craft-native/craft

feat(android): the shortcut pair in Zig — ratchet 75 → 73

#171
Merged chris wants to merge feat/android-shortcuts into main
6 files +602 -3

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/templates/CraftBridge.kt.templatemodified+6-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -3258,6 +3258,10 @@ class CraftBridge(
32583258
32593259 @JavascriptInterface
32603260 fun setShortcuts(shortcutsJson: String) {
3261 // Zig answers through the reply channel, so returning here is what
3262 // stops the promise being settled twice.
3263 if (CraftNative.setShortcuts(activity, shortcutsJson)) return
3264
32613265 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
32623266 activity.runOnUiThread {
32633267 webView.evaluateJavascript(
@@ -3313,6 +3317,8 @@ class CraftBridge(
33133317
33143318 @JavascriptInterface
33153319 fun clearShortcuts() {
3320 if (CraftNative.clearShortcuts(activity)) return
3321
33163322 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
33173323 try {
33183324 val shortcutManager = activity.getSystemService(android.content.pm.ShortcutManager::class.java)
packages/android/templates/CraftNative.kt.templatemodified+26-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -120,6 +120,8 @@ object CraftNative {
120120 private external fun nativeAddContact(activity: Activity, contactJson: String): Boolean
121121 private external fun nativeUpdateWidget(activity: Activity, action: String, dataJson: String): Boolean
122122 private external fun nativeReloadWidgets(activity: Activity, action: String): Boolean
123 private external fun nativeSetShortcuts(activity: Activity, shortcutsJson: String): Boolean
124 private external fun nativeClearShortcuts(activity: Activity): Boolean
123125
124126 /**
125127 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -511,4 +513,28 @@ object CraftNative {
511513 false
512514 }
513515 }
516
517 /**
518 * The launcher's long-press menu.
519 *
520 * Both handle the pre-25 case themselves, and asymmetrically, because the
521 * Kotlin does: setting rejects with a message and clearing resolves true.
522 */
523 fun setShortcuts(activity: Activity, shortcutsJson: String): Boolean {
524 if (!isAvailable) return false
525 return try {
526 nativeSetShortcuts(activity, shortcutsJson)
527 } catch (e: UnsatisfiedLinkError) {
528 false
529 }
530 }
531
532 fun clearShortcuts(activity: Activity): Boolean {
533 if (!isAvailable) return false
534 return try {
535 nativeClearShortcuts(activity)
536 } catch (e: UnsatisfiedLinkError) {
537 false
538 }
539 }
514540}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -470,6 +470,9 @@ pub fn build(b: *std.Build) void {
470470 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_widgets.zig", .{
471471 .root_source_file = b.path("src/bridge_android_widgets.zig"),
472472 });
473 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_shortcuts.zig", .{
474 .root_source_file = b.path("src/bridge_android_shortcuts.zig"),
475 });
473476 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
474477
475478 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+95-1
Changes to packages/zig/src/android_dispatch.zig
@@ -51,6 +51,7 @@ const db = ;
5151const shareditem = ;
5252const contacts = ;
5353const widgets = ;
54const shortcuts = ;
5455
5556const Jni = jni.Jni;
5657
@@ -307,6 +308,16 @@ const natives = [_]jni.JNINativeMethod{
307308 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
308309 .fnPtr = @ptrCast(&nativeReloadWidgets),
309310 },
311 .{
312 .name = "nativeSetShortcuts",
313 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
314 .fnPtr = @ptrCast(&nativeSetShortcuts),
315 },
316 .{
317 .name = "nativeClearShortcuts",
318 .signature = "(Landroid/app/Activity;)Z",
319 .fnPtr = @ptrCast(&nativeClearShortcuts),
320 },
310321};
311322
312323/// How binding went. A value rather than a log line, so every path is
@@ -1152,6 +1163,89 @@ fn nativeReloadWidgets(
11521163 return jni.JNI_TRUE;
11531164}
11541165
1166/// `nativeSetShortcuts(activity, shortcutsJson)`.
1167fn nativeSetShortcuts(
1168 env: jni.JNIEnv,
1169 _: jni.jobject,
1170 activity: jni.jobject,
1171 shortcuts_json: jni.jstring,
1172) callconv(.c) jni.jboolean {
1173 const j = Jni.init(env);
1174 var arena = std.heap.ArenaAllocator.init(backing);
1175 defer arena.deinit();
1176 const allocator = arena.allocator();
1177
1178 const sdk = shortcuts.sdkInt(j) catch |err| {
1179 std.log.warn("craft: setShortcuts fell through to the shim ({s})", .{@errorName(err)});
1180 return jni.JNI_FALSE;
1181 };
1182
1183 if (sdk < shortcuts.n_mr1) {
1184 const payload = shortcuts.jsonString(allocator, shortcuts.unsupported_message) catch
1185 return jni.JNI_FALSE;
1186 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
1187 return jni.JNI_TRUE;
1188 }
1189
1190 const text = j.stringToUtf8(allocator, shortcuts_json) catch return jni.JNI_FALSE;
1191 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
1192 return jni.JNI_FALSE;
1193 defer parsed.deinit();
1194
1195 // `getString` raises where `optString` defaults, so a payload missing a
1196 // title is a rejection rather than a shape to hand back — and the shim
1197 // sets nothing at all, including the entries that parsed.
1198 const list = shortcuts.parseShortcuts(allocator, parsed.value) catch |err| {
1199 const payload = shortcuts.jsonString(allocator, @errorName(err)) catch
1200 return jni.JNI_FALSE;
1201 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
1202 return jni.JNI_TRUE;
1203 } orelse return jni.JNI_FALSE;
1204
1205 shortcuts.set(j, allocator, activity, list) catch |err| {
1206 const payload = shortcuts.jsonString(allocator, @errorName(err)) catch
1207 return jni.JNI_FALSE;
1208 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
1209 return jni.JNI_TRUE;
1210 };
1211
1212 const payload = shortcuts.countPayload(allocator, list.len) catch return jni.JNI_FALSE;
1213 events.settle(allocator, shortcuts.resolve_global, payload) catch return jni.JNI_FALSE;
1214 return jni.JNI_TRUE;
1215}
1216
1217/// `nativeClearShortcuts(activity)`.
1218///
1219/// Resolves `true` below API 25 without touching anything, because that is
1220/// what the shim does: nothing to clear is the same as having cleared it.
1221fn nativeClearShortcuts(
1222 env: jni.JNIEnv,
1223 _: jni.jobject,
1224 activity: jni.jobject,
1225) callconv(.c) jni.jboolean {
1226 const j = Jni.init(env);
1227 var arena = std.heap.ArenaAllocator.init(backing);
1228 defer arena.deinit();
1229 const allocator = arena.allocator();
1230
1231 const sdk = shortcuts.sdkInt(j) catch |err| {
1232 std.log.warn("craft: clearShortcuts fell through to the shim ({s})", .{@errorName(err)});
1233 return jni.JNI_FALSE;
1234 };
1235
1236 if (sdk >= shortcuts.n_mr1) {
1237 shortcuts.clear(j, activity) catch |err| {
1238 const payload = shortcuts.jsonString(allocator, @errorName(err)) catch
1239 return jni.JNI_FALSE;
1240 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
1241 return jni.JNI_TRUE;
1242 };
1243 }
1244
1245 events.settle(allocator, shortcuts.resolve_global, "true") catch return jni.JNI_FALSE;
1246 return jni.JNI_TRUE;
1247}
1248
11551249// =============================================================================
11561250// Tests
11571251// =============================================================================
@@ -1263,7 +1357,7 @@ test "the registered natives name methods the Kotlin actually declares" {
12631357 // A descriptor is checked by the JVM at registration, so a wrong one fails
12641358 // at load rather than at call — but only if the *name* matches something.
12651359 // These two strings are the contract with CraftBridge.kt.
1266 try testing.expectEqual(@as(usize, 28), natives.len);
1360 try testing.expectEqual(@as(usize, 30), natives.len);
12671361 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
12681362
12691363 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_shortcuts.zigadded+467-0
Changes to packages/zig/src/bridge_android_shortcuts.zig
@@ -0,0 +1,467 @@
1//! `setShortcuts` and `clearShortcuts` on Android — the long-press menu on the
2//! launcher icon.
3//!
4//! ## The version check is not symmetric, and that is the shim's choice
5//!
6//! Dynamic shortcuts arrived in API 25. Below it, `setShortcuts` **rejects**
7//! with "App shortcuts require Android 7.1+" and `clearShortcuts` **resolves**
8//! with `true`. Both are defensible on their own — there is nothing to set,
9//! and nothing to clear is the same as having cleared it — and together they
10//! mean a page cannot use one answer to predict the other. Ported as written.
11//!
12//! ## `getString` throws where `optString` would default
13//!
14//! `type` and `title` are read with `getString`, which raises `JSONException`
15//! when the key is missing. So a shortcut without a title does not become a
16//! shortcut with an empty title: the whole call rejects and *no* shortcut is
17//! set, including the ones that parsed. `subtitle` is guarded by `has` and so
18//! is genuinely optional.
19//!
20//! That all-or-nothing behaviour is worth stating because it is not obvious
21//! from the call site — `setDynamicShortcuts` runs once, after the loop.
22//!
23//! ## The id and the extra are the same string
24//!
25//! `ShortcutInfo.Builder(activity, type)` uses `type` as the shortcut's id,
26//! and `putExtra("shortcut_type", type)` puts it in the intent as well. So two
27//! shortcuts sharing a `type` are one shortcut, silently — the second replaces
28//! the first in a `List` the framework keys by id.
29
30const std = ;
31const jni = ;
32const bridge_error = ;
33
34const Jni = jni.Jni;
35const jobject = jni.jobject;
36
37pub const A = struct {
38 pub const set_shortcuts = "setShortcuts";
39 pub const clear_shortcuts = "clearShortcuts";
40};
41
42pub const resolve_global = "_craftShortcutsResolve";
43pub const reject_global = "_craftShortcutsReject";
44
45/// `Build.VERSION_CODES.N_MR1`, where dynamic shortcuts arrived.
46///
47/// A compile-time constant in Java, so the shim's DEX holds the literal 25.
48pub const n_mr1: i32 = 25;
49
50/// What the shim rejects with below API 25.
51pub const unsupported_message = "App shortcuts require Android 7.1+";
52
53/// The extra `setShortcuts` puts on every intent it builds.
54const extra_shortcut_type = "shortcut_type";
55
56pub const Shortcut = struct {
57 /// Both the shortcut's id and its `shortcut_type` extra.
58 type: []const u8,
59 title: []const u8,
60 subtitle: ?[]const u8,
61};
62
63/// Read the declared shape, or null where only `org.json` would.
64///
65/// Distinct from the calendar's and the widget's version in one way: `type`
66/// and `title` are *required*, because the shim reads them with `getString`
67/// and that raises rather than defaulting. A payload missing either is not a
68/// shape this declines — it is a payload the shim rejects, so it is an error
69/// here rather than a null.
70pub fn parseShortcuts(
71 allocator: std.mem.Allocator,
72 value: std.json.Value,
73) !?[]Shortcut {
74 const items = switch (value) {
75 .array => |a| a.items,
76 // `JSONArray(shortcutsJson)` throws on anything else, and the shim's
77 // catch rejects. Declining sends it there.
78 else => return null,
79 };
80
81 const shortcuts = try allocator.alloc(Shortcut, items.len);
82 errdefer allocator.free(shortcuts);
83
84 for (items, 0..) |item, i| {
85 const object = switch (item) {
86 .object => |o| o,
87 else => return null,
88 };
89
90 shortcuts[i] = .{
91 .type = (try required(object, "type")) orelse return null,
92 .title = (try required(object, "title")) orelse return null,
93 .subtitle = optional(object, "subtitle") orelse return null,
94 };
95 }
96 return shortcuts;
97}
98
99/// `object.getString(name)` — an error when absent, null when only a
100/// coercion would answer.
101fn required(object: std.json.ObjectMap, name: []const u8) !?[]const u8 {
102 const value = object.get(name) orelse return error.MissingField;
103 return switch (value) {
104 .string => |text| text,
105 // `JSON.toString(NULL)` is "null", and `getString` returns it rather
106 // than throwing — a shortcut really can be titled "null".
107 .null => "null",
108 else => null,
109 };
110}
111
112/// `if (has(name)) getString(name) else null`.
113///
114/// The outer null means "a shape this declines"; the inner one means the key
115/// was absent and `setLongLabel` is never called.
116fn optional(object: std.json.ObjectMap, name: []const u8) ??[]const u8 {
117 const value = object.get(name) orelse return @as(?[]const u8, null);
118 return switch (value) {
119 .string => |text| @as(?[]const u8, text),
120 .null => @as(?[]const u8, "null"),
121 else => null,
122 };
123}
124
125/// `Build.VERSION.SDK_INT`.
126pub fn sdkInt(j: Jni) !i32 {
127 try j.pushLocalFrame(4);
128 defer _ = j.popLocalFrame(null);
129
130 const version_cls = try j.findClass("android/os/Build$VERSION");
131 return j.staticIntField(version_cls, try j.staticFieldId(version_cls, "SDK_INT", "I"));
132}
133
134/// `activity.getSystemService(ShortcutManager::class.java)`.
135///
136/// The `jclass` `FindClass` returns *is* the `java.lang.Class` object the
137/// overload wants, so no extra call is needed to reach for one.
138fn shortcutManager(j: Jni, activity: jobject) !jobject {
139 const manager_cls = try j.findClass("android/content/pm/ShortcutManager");
140 return j.callObjectMethodA(
141 activity,
142 try j.methodId(
143 try j.objectClass(activity),
144 "getSystemService",
145 "(Ljava/lang/Class;)Ljava/lang/Object;",
146 ),
147 &.{.{ .l = manager_cls }},
148 );
149}
150
151/// `shortcutManager?.removeAllDynamicShortcuts()`.
152///
153/// The `?.` matters: `getSystemService` can hand back null, and the shim then
154/// does nothing and still resolves `true`.
155pub fn clear(j: Jni, activity: jobject) !void {
156 try j.pushLocalFrame(8);
157 defer _ = j.popLocalFrame(null);
158
159 const manager = try shortcutManager(j, activity);
160 if (manager == null) return;
161
162 try j.callVoidMethodA(
163 manager,
164 try j.methodId(try j.objectClass(manager), "removeAllDynamicShortcuts", "()V"),
165 &.{},
166 );
167}
168
169/// `shortcutManager?.dynamicShortcuts = shortcuts`.
170pub fn set(
171 j: Jni,
172 allocator: std.mem.Allocator,
173 activity: jobject,
174 shortcuts: []const Shortcut,
175) !void {
176 try j.pushLocalFrame(32);
177 defer _ = j.popLocalFrame(null);
178
179 const list_cls = try j.findClass("java/util/ArrayList");
180 const list = try j.newObjectA(list_cls, try j.methodId(list_cls, "<init>", "()V"), &.{});
181 const add = try j.methodId(list_cls, "add", "(Ljava/lang/Object;)Z");
182
183 for (shortcuts) |shortcut| {
184 try j.pushLocalFrame(16);
185 defer _ = j.popLocalFrame(null);
186 _ = try j.callBooleanMethodA(list, add, &.{
187 .{ .l = try buildShortcut(j, allocator, activity, shortcut) },
188 });
189 }
190
191 // The list is set once, after the loop — which is why a payload the shim
192 // rejects part-way through leaves the previous shortcuts in place.
193 const manager = try shortcutManager(j, activity);
194 if (manager == null) return;
195
196 try j.callVoidMethodA(
197 manager,
198 try j.methodId(
199 try j.objectClass(manager),
200 "setDynamicShortcuts",
201 "(Ljava/util/List;)V",
202 ),
203 &.{.{ .l = list }},
204 );
205}
206
207fn buildShortcut(
208 j: Jni,
209 allocator: std.mem.Allocator,
210 activity: jobject,
211 shortcut: Shortcut,
212) !jobject {
213 const intent = try launchIntent(j, allocator, activity, shortcut.type);
214
215 const builder_cls = try j.findClass("android/content/pm/ShortcutInfo$Builder");
216 const builder = try j.newObjectA(
217 builder_cls,
218 try j.methodId(builder_cls, "<init>", "(Landroid/content/Context;Ljava/lang/String;)V"),
219 &.{ .{ .l = activity }, .{ .l = try j.newStringUtf8(allocator, shortcut.type) } },
220 );
221
222 _ = try j.callObjectMethodA(
223 builder,
224 try j.methodId(
225 builder_cls,
226 "setShortLabel",
227 "(Ljava/lang/CharSequence;)Landroid/content/pm/ShortcutInfo$Builder;",
228 ),
229 &.{.{ .l = try j.newStringUtf8(allocator, shortcut.title) }},
230 );
231
232 _ = try j.callObjectMethodA(
233 builder,
234 try j.methodId(
235 builder_cls,
236 "setIntent",
237 "(Landroid/content/Intent;)Landroid/content/pm/ShortcutInfo$Builder;",
238 ),
239 &.{.{ .l = intent }},
240 );
241
242 if (shortcut.subtitle) |subtitle| {
243 _ = try j.callObjectMethodA(
244 builder,
245 try j.methodId(
246 builder_cls,
247 "setLongLabel",
248 "(Ljava/lang/CharSequence;)Landroid/content/pm/ShortcutInfo$Builder;",
249 ),
250 &.{.{ .l = try j.newStringUtf8(allocator, subtitle) }},
251 );
252 }
253
254 return j.callObjectMethod(
255 builder,
256 try j.methodId(builder_cls, "build", "()Landroid/content/pm/ShortcutInfo;"),
257 );
258}
259
260/// `Intent(activity, activity::class.java)` with `ACTION_VIEW` and the extra.
261fn launchIntent(
262 j: Jni,
263 allocator: std.mem.Allocator,
264 activity: jobject,
265 shortcut_type: []const u8,
266) !jobject {
267 const intent_cls = try j.findClass("android/content/Intent");
268 const intent = try j.newObjectA(
269 intent_cls,
270 try j.methodId(intent_cls, "<init>", "(Landroid/content/Context;Ljava/lang/Class;)V"),
271 // `activity::class.java` — the activity's own class, so the shortcut
272 // reopens the app rather than naming a class this library cannot know.
273 &.{ .{ .l = activity }, .{ .l = try j.objectClass(activity) } },
274 );
275
276 // Read off `Intent` rather than spelled as a literal, the same way
277 // `bridge_android_intents` reads it.
278 const action_view = try j.staticObjectField(
279 intent_cls,
280 try j.staticFieldId(intent_cls, "ACTION_VIEW", "Ljava/lang/String;"),
281 );
282 _ = try j.callObjectMethodA(
283 intent,
284 try j.methodId(intent_cls, "setAction", "(Ljava/lang/String;)Landroid/content/Intent;"),
285 &.{.{ .l = action_view }},
286 );
287
288 _ = try j.callObjectMethodA(
289 intent,
290 try j.methodId(
291 intent_cls,
292 "putExtra",
293 "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
294 ),
295 &.{
296 .{ .l = try j.newStringUtf(extra_shortcut_type) },
297 .{ .l = try j.newStringUtf8(allocator, shortcut_type) },
298 },
299 );
300
301 return intent;
302}
303
304/// `{count: n}` — what `setShortcuts` resolves with.
305pub fn countPayload(allocator: std.mem.Allocator, count: usize) ![]u8 {
306 return std.fmt.allocPrint(allocator, "", .{count});
307}
308
309/// `message` as a JSON string.
310pub fn jsonString(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
311 var out: std.ArrayListUnmanaged(u8) = .empty;
312 errdefer out.deinit(allocator);
313
314 try out.append(allocator, '"');
315 try bridge_error.appendJsonEscaped(allocator, &out, text);
316 try out.append(allocator, '"');
317 return out.toOwnedSlice(allocator);
318}
319
320// =============================================================================
321// Tests
322// =============================================================================
323
324const testing = std.testing;
325
326fn parsedShortcuts(json: []const u8) !?[]Shortcut {
327 var arena = std.heap.ArenaAllocator.init(testing.allocator);
328 defer arena.deinit();
329
330 var doc = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json, .{});
331 defer doc.deinit();
332
333 const shortcuts = (try parseShortcuts(arena.allocator(), doc.value)) orelse return null;
334
335 const copy = try testing.allocator.alloc(Shortcut, shortcuts.len);
336 for (shortcuts, 0..) |s, i| {
337 copy[i] = .{
338 .type = try testing.allocator.dupe(u8, s.type),
339 .title = try testing.allocator.dupe(u8, s.title),
340 .subtitle = if (s.subtitle) |sub| try testing.allocator.dupe(u8, sub) else null,
341 };
342 }
343 return copy;
344}
345
346fn freeShortcuts(shortcuts: []Shortcut) void {
347 for (shortcuts) |s| {
348 testing.allocator.free(s.type);
349 testing.allocator.free(s.title);
350 if (s.subtitle) |sub| testing.allocator.free(sub);
351 }
352 testing.allocator.free(shortcuts);
353}
354
355test "the declared shape reads straight through" {
356 const shortcuts = (try parsedShortcuts(
357 \\[{"type":"cart","title":"Cart","subtitle":"Your basket"},
358 \\ {"type":"orders","title":"Orders"}]
359 )).?;
360 defer freeShortcuts(shortcuts);
361
362 try testing.expectEqual(@as(usize, 2), shortcuts.len);
363 try testing.expectEqualStrings("cart", shortcuts[0].type);
364 try testing.expectEqualStrings("Cart", shortcuts[0].title);
365 try testing.expectEqualStrings("Your basket", shortcuts[0].subtitle.?);
366
367 // `has("subtitle")` is false, so `setLongLabel` is never called — which is
368 // different from calling it with an empty string.
369 try testing.expect(shortcuts[1].subtitle == null);
370}
371
372test "a missing type or title rejects the whole call rather than defaulting" {
373 // `getString` raises JSONException where `optString` would default, and
374 // `setDynamicShortcuts` runs once after the loop — so a payload missing a
375 // title sets nothing at all, including the entries that parsed.
376 for ([_][]const u8{
377 \\[{"title":"Cart"}]
378 ,
379 \\[{"type":"cart"}]
380 ,
381 \\[{"type":"cart","title":"Cart"},{"type":"orders"}]
382 ,
383 }) |payload| {
384 try testing.expectError(error.MissingField, parsedShortcuts(payload));
385 }
386}
387
388test "an empty list is set rather than declined" {
389 // `craft.shortcuts.set([])` is how a page removes them without reaching
390 // for `clear`, and the shim resolves `{count: 0}`.
391 const shortcuts = (try parsedShortcuts("[]")).?;
392 defer freeShortcuts(shortcuts);
393 try testing.expectEqual(@as(usize, 0), shortcuts.len);
394}
395
396test "a shape only org.json would coerce is handed back" {
397 for ([_][]const u8{
398 \\[{"type":1,"title":"Cart"}]
399 ,
400 \\[{"type":"cart","title":2.5}]
401 ,
402 \\[{"type":"cart","title":"Cart","subtitle":true}]
403 ,
404 \\[["cart","Cart"]]
405 ,
406 \\{"type":"cart"}
407 ,
408 \\"cart"
409 ,
410 }) |payload| {
411 if (try parsedShortcuts(payload)) |shortcuts| {
412 freeShortcuts(shortcuts);
413 std.debug.print("payload was served rather than handed back: {s}\n", .{payload});
414 return error.CoercedShapeAccepted;
415 }
416 }
417}
418
419test "an explicit null is the string null, here as everywhere else" {
420 const shortcuts = (try parsedShortcuts(
421 \\[{"type":"cart","title":null,"subtitle":null}]
422 )).?;
423 defer freeShortcuts(shortcuts);
424
425 try testing.expectEqualStrings("null", shortcuts[0].title);
426 // `has` is true, so `setLongLabel("null")` really is called.
427 try testing.expectEqualStrings("null", shortcuts[0].subtitle.?);
428}
429
430test "the resolve carries the count the shim counts" {
431 for ([_]usize{ 0, 1, 4 }) |count| {
432 const payload = try countPayload(testing.allocator, count);
433 defer testing.allocator.free(payload);
434
435 var doc = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
436 defer doc.deinit();
437 try testing.expectEqual(@as(i64, @intCast(count)), doc.value.object.get("count").?.integer);
438 }
439}
440
441test "the unsupported message is the shim's, character for character" {
442 // A page may well be matching on it: there is no code in the rejection,
443 // only this string.
444 try testing.expectEqualStrings("App shortcuts require Android 7.1+", unsupported_message);
445
446 const payload = try jsonString(testing.allocator, unsupported_message);
447 defer testing.allocator.free(payload);
448 try testing.expectEqualStrings(
449 \\"App shortcuts require Android 7.1+"
450 , payload);
451}
452
453test "N_MR1 is 25, and the two actions disagree about what to do below it" {
454 try testing.expectEqual(@as(i32, 25), n_mr1);
455
456 // Recorded rather than reconciled: setShortcuts rejects below 25 and
457 // clearShortcuts resolves true, so a page cannot use one answer to
458 // predict the other.
459 try testing.expectEqualStrings("shortcut_type", extra_shortcut_type);
460}
461
462test "the actions and globals match the shim exactly" {
463 try testing.expectEqualStrings("setShortcuts", A.set_shortcuts);
464 try testing.expectEqualStrings("clearShortcuts", A.clear_shortcuts);
465 try testing.expectEqualStrings("_craftShortcutsResolve", resolve_global);
466 try testing.expectEqualStrings("_craftShortcutsReject", reject_global);
467}
packages/zig/test/android_conformance_test.zigmodified+5-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -52,6 +52,7 @@ const zig_sources = [_][]const u8{
5252 @embedFile("src/bridge_android_shareditem.zig"),
5353 @embedFile("src/bridge_android_contacts.zig"),
5454 @embedFile("src/bridge_android_widgets.zig"),
55 @embedFile("src/bridge_android_shortcuts.zig"),
5556};
5657
5758/// How many spec actions Zig does not serve yet.
@@ -73,8 +74,10 @@ const zig_sources = [_][]const u8{
7374/// with the database pair, the first to borrow a connection the Kotlin owns;
7475/// 79 with the shared-item trio; 77 with the contacts pair; 75 with the
7576/// widget pair, the first to take a constant across from the Kotlin because
76/// deriving it here would be silently wrong under an applicationIdSuffix.
77const max_not_yet_migrated: usize = 75;
77/// deriving it here would be silently wrong under an applicationIdSuffix; 73
78/// with the shortcut pair, whose two halves disagree about what to do below
79/// API 25 and are ported disagreeing.
80const max_not_yet_migrated: usize = 73;
7881
7982/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
8083///