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
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}