ReviewOS

also looking at this

craft-native/craft

feat(android): openURL and share — ratchet 98 -> 96

#145
Merged glennmichael123 wants to merge feat/android-intents into main
6 files +460 -3
packages/android/templates/CraftBridge.kt.templatemodified+4-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1412,6 +1412,8 @@ class CraftBridge(
14121412
14131413 @JavascriptInterface
14141414 fun share(text: String, title: String) {
1415 if (CraftNative.share(activity, text, title)) return
1416
14151417 val intent = Intent(Intent.ACTION_SEND).apply {
14161418 type = "text/plain"
14171419 putExtra(Intent.EXTRA_TEXT, text)
@@ -2011,6 +2013,8 @@ class CraftBridge(
20112013
20122014 @JavascriptInterface
20132015 fun openURL(url: String): Boolean {
2016 if (CraftNative.openURL(activity, url)) return true
2017
20142018 return try {
20152019 val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
20162020 activity.startActivity(intent)
packages/android/templates/CraftNative.kt.templatemodified+31-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -61,6 +61,8 @@ object CraftNative {
6161 private external fun nativeLog(message: String): Boolean
6262 private external fun nativeClipboardRead(activity: Activity): String?
6363 private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
64 private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
65 private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
6466
6567 /**
6668 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -134,4 +136,33 @@ object CraftNative {
134136 false
135137 }
136138 }
139
140 /**
141 * Returns whether Zig opened the URL.
142 *
143 * False is ambiguous here in a way it is not elsewhere: it covers both
144 * "Zig did not serve this" and "nothing on the device handles that
145 * scheme". Both lead to the same place — the Kotlin runs its own
146 * implementation and answers false for the second reason itself — so the
147 * ambiguity costs nothing and a richer return would have to be unpacked
148 * at every call site to reach the same outcome.
149 */
150 fun openURL(activity: Activity, url: String): Boolean {
151 if (!isAvailable) return false
152 return try {
153 nativeOpenUrl(activity, url)
154 } catch (e: UnsatisfiedLinkError) {
155 false
156 }
157 }
158
159 /** Returns whether Zig launched the share sheet; false falls through. */
160 fun share(activity: Activity, text: String, title: String): Boolean {
161 if (!isAvailable) return false
162 return try {
163 nativeShare(activity, text, title)
164 } catch (e: UnsatisfiedLinkError) {
165 false
166 }
167 }
137168}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -440,6 +440,9 @@ pub fn build(b: *std.Build) void {
440440 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_clipboard.zig", .{
441441 .root_source_file = b.path("src/bridge_android_clipboard.zig"),
442442 });
443 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_intents.zig", .{
444 .root_source_file = b.path("src/bridge_android_intents.zig"),
445 });
443446 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
444447
445448 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+67-1
Changes to packages/zig/src/android_dispatch.zig
@@ -39,6 +39,7 @@ const jni = ;
3939const device = ;
4040const system = ;
4141const clipboard = ;
42const intents = ;
4243
4344const Jni = jni.Jni;
4445
@@ -148,6 +149,16 @@ const natives = [_]jni.JNINativeMethod{
148149 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
149150 .fnPtr = @ptrCast(&nativeClipboardWrite),
150151 },
152 .{
153 .name = "nativeOpenUrl",
154 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
155 .fnPtr = @ptrCast(&nativeOpenUrl),
156 },
157 .{
158 .name = "nativeShare",
159 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
160 .fnPtr = @ptrCast(&nativeShare),
161 },
151162};
152163
153164/// How binding went. A value rather than a log line, so every path is
@@ -299,6 +310,61 @@ fn nativeClipboardWrite(
299310 return jni.JNI_TRUE;
300311}
301312
313/// Copy a `jstring` into NUL-terminated arena memory, or null.
314///
315/// Every native below needs this and none of them can share the result, since
316/// each arena dies with its call.
317fn ownedUtf8(j: Jni, allocator: std.mem.Allocator, str: jni.jstring) ?[:0]u8 {
318 const text = j.stringToUtf8(allocator, str) catch return null;
319 const owned = allocator.allocSentinel(u8, text.len, 0) catch return null;
320 @memcpy(owned, text);
321 return owned;
322}
323
324fn nativeOpenUrl(
325 env: jni.JNIEnv,
326 _: jni.jobject,
327 activity: jni.jobject,
328 url: jni.jstring,
329) callconv(.c) jni.jboolean {
330 const j = Jni.init(env);
331 var arena = std.heap.ArenaAllocator.init(backing);
332 defer arena.deinit();
333
334 const value = ownedUtf8(j, arena.allocator(), url) orelse return jni.JNI_FALSE;
335 intents.openUrl(j, activity, value.ptr) catch |err| {
336 // Not a warning. `openURL` answering false is the documented outcome
337 // when nothing on the device handles the scheme, and the Kotlin
338 // catches exactly this — logging it at warn would put a line in
339 // logcat for a page doing something reasonable.
340 std.log.debug("craft: openURL declined ({s})", .{@errorName(err)});
341 return jni.JNI_FALSE;
342 };
343 return jni.JNI_TRUE;
344}
345
346fn nativeShare(
347 env: jni.JNIEnv,
348 _: jni.jobject,
349 activity: jni.jobject,
350 text: jni.jstring,
351 title: jni.jstring,
352) callconv(.c) jni.jboolean {
353 const j = Jni.init(env);
354 var arena = std.heap.ArenaAllocator.init(backing);
355 defer arena.deinit();
356 const allocator = arena.allocator();
357
358 const body = ownedUtf8(j, allocator, text) orelse return jni.JNI_FALSE;
359 const subject = ownedUtf8(j, allocator, title) orelse return jni.JNI_FALSE;
360
361 intents.share(j, activity, body.ptr, subject.ptr) catch |err| {
362 std.log.warn("craft: share fell through to the shim ({s})", .{@errorName(err)});
363 return jni.JNI_FALSE;
364 };
365 return jni.JNI_TRUE;
366}
367
302368// =============================================================================
303369// Tests
304370// =============================================================================
@@ -410,7 +476,7 @@ test "the registered natives name methods the Kotlin actually declares" {
410476 // A descriptor is checked by the JVM at registration, so a wrong one fails
411477 // at load rather than at call — but only if the *name* matches something.
412478 // These two strings are the contract with CraftBridge.kt.
413 try testing.expectEqual(@as(usize, 5), natives.len);
479 try testing.expectEqual(@as(usize, 7), natives.len);
414480 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
415481
416482 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_intents.zigadded+352-0
Changes to packages/zig/src/bridge_android_intents.zig
@@ -0,0 +1,352 @@
1//! The Android actions that hand something to another app: `openURL` and
2//! `share`.
3//!
4//! Both build an `Intent` and call `startActivity`, which is why they are one
5//! module — they share every JNI call between them, and the only thing that
6//! differs is which extras go on the intent.
7//!
8//! ## Neither reports whether anything happened
9//!
10//! `startActivity` throws `ActivityNotFoundException` when nothing on the
11//! device handles the intent, and the Kotlin's `openURL` catches it and answers
12//! `false`. That is the *only* failure either action can see. Once another app
13//! is launched, whether the user did anything with it is unobservable from
14//! here — `share` in particular returns before the chooser is even drawn.
15//!
16//! So `true` means "the intent was accepted", never "the user shared
17//! something", and a page that treats it as the latter is wrong on both
18//! platforms. Kept as-is rather than improved: the shim's answer is the
19//! contract while the shim still exists.
20//!
21//! ## `share` has one branch, and it is the empty title
22//!
23//! `putExtra(EXTRA_SUBJECT, …)` is skipped when the title is empty, and that
24//! is not cosmetic: an empty subject on a mail intent produces a message whose
25//! subject line is blank rather than absent, and some clients then refuse to
26//! send it. The Kotlin guards it and so does this.
27
28const std = ;
29const jni = ;
30
31const Jni = jni.Jni;
32const jobject = jni.jobject;
33
34/// The action names, spelled exactly as `CraftBridge.kt` spells them.
35pub const A = struct {
36 pub const open_url = "openURL";
37 pub const share = "share";
38};
39
40/// A `String` constant off `android.content.Intent` — `ACTION_VIEW`,
41/// `EXTRA_TEXT` and friends.
42///
43/// Read rather than spelled, for the reason the clipboard module gives for
44/// `CLIPBOARD_SERVICE`: these are stable values, and a stable value copied
45/// into another language is one that can only ever be wrong later.
46fn intentConstant(j: Jni, intent_cls: jni.jclass, name: [*:0]const u8) !jobject {
47 return j.staticObjectField(
48 intent_cls,
49 try j.staticFieldId(intent_cls, name, "Ljava/lang/String;"),
50 );
51}
52
53/// `activity.startActivity(intent)`.
54fn startActivity(j: Jni, activity: jobject, intent: jobject) !void {
55 const activity_cls = try j.objectClass(activity);
56 try j.callVoidMethodA(
57 activity,
58 try j.methodId(activity_cls, "startActivity", "(Landroid/content/Intent;)V"),
59 &.{.{ .l = intent }},
60 );
61}
62
63/// `startActivity(Intent(ACTION_VIEW, Uri.parse(url)))`.
64///
65/// `Uri.parse` does not validate and does not throw — it accepts anything and
66/// produces a Uri with a null scheme for nonsense. The failure that matters
67/// comes later, from `startActivity` finding nothing to handle the result, and
68/// that arrives here as `JavaException` from the `check` after the call.
69pub fn openUrl(j: Jni, activity: jobject, url: [*:0]const u8) !void {
70 try j.pushLocalFrame(16);
71 defer _ = j.popLocalFrame(null);
72
73 const uri_cls = try j.findClass("android/net/Uri");
74 const url_string = try j.newStringUtf(url);
75 const uri = try j.callStaticObjectMethodA(
76 uri_cls,
77 try j.staticMethodId(uri_cls, "parse", "(Ljava/lang/String;)Landroid/net/Uri;"),
78 &.{.{ .l = url_string }},
79 );
80
81 const intent_cls = try j.findClass("android/content/Intent");
82 const action = try intentConstant(j, intent_cls, "ACTION_VIEW");
83 const intent = try j.newObjectA(
84 intent_cls,
85 try j.methodId(intent_cls, "<init>", "(Ljava/lang/String;Landroid/net/Uri;)V"),
86 &.{ .{ .l = action }, .{ .l = uri } },
87 );
88
89 try startActivity(j, activity, intent);
90}
91
92/// `startActivity(Intent.createChooser(Intent(ACTION_SEND)…, "Share"))`.
93///
94/// An empty `title` skips `EXTRA_SUBJECT` entirely rather than setting it to
95/// the empty string — see the module comment.
96pub fn share(j: Jni, activity: jobject, text: [*:0]const u8, title: [*:0]const u8) !void {
97 try j.pushLocalFrame(24);
98 defer _ = j.popLocalFrame(null);
99
100 const intent_cls = try j.findClass("android/content/Intent");
101 const action = try intentConstant(j, intent_cls, "ACTION_SEND");
102 const intent = try j.newObjectA(
103 intent_cls,
104 try j.methodId(intent_cls, "<init>", "(Ljava/lang/String;)V"),
105 &.{.{ .l = action }},
106 );
107
108 // `setType` and `putExtra` both return the intent for chaining, which the
109 // Kotlin's `apply` block hides. The returns are discarded here: they are
110 // the same object, and keeping them would suggest otherwise.
111 const plain = try j.newStringUtf("text/plain");
112 _ = try j.callObjectMethodA(
113 intent,
114 try j.methodId(intent_cls, "setType", "(Ljava/lang/String;)Landroid/content/Intent;"),
115 &.{.{ .l = plain }},
116 );
117
118 const put_extra = try j.methodId(
119 intent_cls,
120 "putExtra",
121 "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
122 );
123
124 const extra_text = try intentConstant(j, intent_cls, "EXTRA_TEXT");
125 const body = try j.newStringUtf(text);
126 _ = try j.callObjectMethodA(intent, put_extra, &.{ .{ .l = extra_text }, .{ .l = body } });
127
128 if (title[0] != 0) {
129 const extra_subject = try intentConstant(j, intent_cls, "EXTRA_SUBJECT");
130 const subject = try j.newStringUtf(title);
131 _ = try j.callObjectMethodA(intent, put_extra, &.{ .{ .l = extra_subject }, .{ .l = subject } });
132 }
133
134 // The chooser label is the Kotlin's `"Share"`, and it is what the user
135 // reads at the top of the sheet.
136 const chooser_label = try j.newStringUtf("Share");
137 const chooser = try j.callStaticObjectMethodA(
138 intent_cls,
139 try j.staticMethodId(
140 intent_cls,
141 "createChooser",
142 "(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
143 ),
144 &.{ .{ .l = intent }, .{ .l = chooser_label } },
145 );
146
147 try startActivity(j, activity, chooser);
148}
149
150// =============================================================================
151// Tests
152//
153// The same fake-JVM shape the clipboard module uses: `GetMethodID` hands back
154// the method name as the id, so one Call can stand in for many and the test
155// can record the sequence rather than guess at it.
156// =============================================================================
157
158const testing = std.testing;
159
160var fake_storage: [16]u8 = undefined;
161var fake_calls: std.ArrayListUnmanaged([]const u8) = .empty;
162var fake_field_names: std.ArrayListUnmanaged([]const u8) = .empty;
163var fake_strings: std.ArrayListUnmanaged([]const u8) = .empty;
164var fake_throw_on_start = false;
165var fake_pending: jobject = null;
166
167fn obj(tag: usize) jobject {
168 return @ptrCast(&fake_storage[tag]);
169}
170fn nameOf(id: ?*anyopaque) []const u8 {
171 return std.mem.span(@as([*:0]const u8, @ptrCast(id.?)));
172}
173
174fn fFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
175 return obj(0);
176}
177fn fObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass {
178 return obj(0);
179}
180fn fMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
181 return @ptrCast(@constCast(name));
182}
183fn fStaticFieldId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID {
184 fake_field_names.append(testing.allocator, std.mem.span(name)) catch {};
185 return obj(1);
186}
187fn fStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject {
188 return obj(2);
189}
190fn fNewStringUTF(_: jni.JNIEnv, text: [*:0]const u8) callconv(.c) jni.jstring {
191 fake_strings.append(testing.allocator, std.mem.span(text)) catch {};
192 return obj(3);
193}
194fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
195 fake_calls.append(testing.allocator, nameOf(id)) catch {};
196 return obj(4);
197}
198fn fCallObjectMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
199 fake_calls.append(testing.allocator, nameOf(id)) catch {};
200 return obj(5);
201}
202fn fNewObjectA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
203 fake_calls.append(testing.allocator, nameOf(id)) catch {};
204 return obj(6);
205}
206fn fCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) void {
207 fake_calls.append(testing.allocator, nameOf(id)) catch {};
208 if (fake_throw_on_start) fake_pending = obj(7);
209}
210fn fExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
211 return fake_pending;
212}
213fn fExceptionDescribe(_: jni.JNIEnv) callconv(.c) void {}
214fn fExceptionClear(_: jni.JNIEnv) callconv(.c) void {
215 fake_pending = null;
216}
217fn fDeleteLocalRef(_: jni.JNIEnv, _: jobject) callconv(.c) void {}
218fn fPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
219 return 0;
220}
221fn fPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject {
222 return keep;
223}
224
225fn fakeEnv(table: *jni.JNINativeInterface) void {
226 table.* = std.mem.zeroes(jni.JNINativeInterface);
227 table.FindClass = @ptrCast(&fFindClass);
228 table.GetObjectClass = @ptrCast(&fObjectClass);
229 table.GetMethodID = @ptrCast(&fMethodId);
230 table.GetStaticMethodID = @ptrCast(&fMethodId);
231 table.GetStaticFieldID = @ptrCast(&fStaticFieldId);
232 table.GetStaticObjectField = @ptrCast(&fStaticObjectField);
233 table.NewStringUTF = @ptrCast(&fNewStringUTF);
234 table.CallStaticObjectMethodA = @ptrCast(&fCallStaticObjectMethodA);
235 table.CallObjectMethodA = @ptrCast(&fCallObjectMethodA);
236 table.NewObjectA = @ptrCast(&fNewObjectA);
237 table.CallVoidMethodA = @ptrCast(&fCallVoidMethodA);
238 table.ExceptionOccurred = @ptrCast(&fExceptionOccurred);
239 table.ExceptionDescribe = @ptrCast(&fExceptionDescribe);
240 table.ExceptionClear = @ptrCast(&fExceptionClear);
241 table.DeleteLocalRef = @ptrCast(&fDeleteLocalRef);
242 table.PushLocalFrame = @ptrCast(&fPush);
243 table.PopLocalFrame = @ptrCast(&fPop);
244}
245
246fn resetFakes() void {
247 fake_calls.clearRetainingCapacity();
248 fake_field_names.clearRetainingCapacity();
249 fake_strings.clearRetainingCapacity();
250 fake_throw_on_start = false;
251 fake_pending = null;
252}
253
254fn freeFakes() void {
255 fake_calls.deinit(testing.allocator);
256 fake_field_names.deinit(testing.allocator);
257 fake_strings.deinit(testing.allocator);
258 fake_calls = .empty;
259 fake_field_names = .empty;
260 fake_strings = .empty;
261}
262
263fn sawCall(name: []const u8) bool {
264 for (fake_calls.items) |c| if (std.mem.eql(u8, c, name)) return true;
265 return false;
266}
267fn sawString(text: []const u8) bool {
268 for (fake_strings.items) |c| if (std.mem.eql(u8, c, text)) return true;
269 return false;
270}
271fn sawField(name: []const u8) bool {
272 for (fake_field_names.items) |c| if (std.mem.eql(u8, c, name)) return true;
273 return false;
274}
275
276test "openURL parses the url and hands the intent to the activity" {
277 resetFakes();
278 defer freeFakes();
279 var table: jni.JNINativeInterface = undefined;
280 fakeEnv(&table);
281 const ptr: *const jni.JNINativeInterface = &table;
282
283 try openUrl(Jni.init(&ptr), obj(8), "https://example.com/x?y=1");
284
285 try testing.expect(sawCall("parse"));
286 try testing.expect(sawCall("<init>"));
287 try testing.expect(sawCall("startActivity"));
288 // The action is read off Intent rather than spelled as a string literal.
289 try testing.expect(sawField("ACTION_VIEW"));
290 try testing.expect(sawString("https://example.com/x?y=1"));
291}
292
293test "a URL nothing can open reports the exception rather than success" {
294 // `Uri.parse` accepts anything, so the only failure either action sees is
295 // startActivity finding no handler — ActivityNotFoundException, which the
296 // Kotlin catches and answers false for.
297 resetFakes();
298 defer freeFakes();
299 var table: jni.JNINativeInterface = undefined;
300 fakeEnv(&table);
301 const ptr: *const jni.JNINativeInterface = &table;
302
303 fake_throw_on_start = true;
304 try testing.expectError(
305 jni.JniError.JavaException,
306 openUrl(Jni.init(&ptr), obj(8), "definitely-not-a-scheme://x"),
307 );
308}
309
310test "share sets the text extra and the chooser" {
311 resetFakes();
312 defer freeFakes();
313 var table: jni.JNINativeInterface = undefined;
314 fakeEnv(&table);
315 const ptr: *const jni.JNINativeInterface = &table;
316
317 try share(Jni.init(&ptr), obj(8), "hello", "Subject");
318
319 try testing.expect(sawField("ACTION_SEND"));
320 try testing.expect(sawField("EXTRA_TEXT"));
321 try testing.expect(sawField("EXTRA_SUBJECT"));
322 try testing.expect(sawCall("setType"));
323 try testing.expect(sawCall("putExtra"));
324 try testing.expect(sawCall("createChooser"));
325 try testing.expect(sawCall("startActivity"));
326 try testing.expect(sawString("text/plain"));
327 try testing.expect(sawString("Share"));
328}
329
330test "an empty title omits the subject rather than setting it empty" {
331 // The one branch in either action. An empty EXTRA_SUBJECT produces a mail
332 // draft with a blank subject line rather than none, and some clients then
333 // refuse to send it — so "skip" and "set to empty" are different outcomes.
334 resetFakes();
335 defer freeFakes();
336 var table: jni.JNINativeInterface = undefined;
337 fakeEnv(&table);
338 const ptr: *const jni.JNINativeInterface = &table;
339
340 try share(Jni.init(&ptr), obj(8), "hello", "");
341
342 try testing.expect(sawField("EXTRA_TEXT"));
343 try testing.expect(!sawField("EXTRA_SUBJECT"));
344 // And the rest of the intent is still built.
345 try testing.expect(sawCall("createChooser"));
346 try testing.expect(sawCall("startActivity"));
347}
348
349test "the action names match the Kotlin methods exactly" {
350 try testing.expectEqualStrings("openURL", A.open_url);
351 try testing.expectEqualStrings("share", A.share);
352}
packages/zig/test/android_conformance_test.zigmodified+3-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -42,6 +42,7 @@ const zig_sources = [_][]const u8{
4242 @embedFile("src/bridge_android_device.zig"),
4343 @embedFile("src/bridge_android_system.zig"),
4444 @embedFile("src/bridge_android_clipboard.zig"),
45 @embedFile("src/bridge_android_intents.zig"),
4546};
4647
4748/// How many spec actions Zig does not serve yet.
@@ -52,8 +53,8 @@ const zig_sources = [_][]const u8{
5253///
5354/// History: 103 at the seam; 102 with getDeviceInfo; 100 with getMemoryUsage
5455/// and log — the first two that need no Activity at all; 98 with the
55/// clipboard pair, the first that do.
56const max_not_yet_migrated: usize = 98;
56/// clipboard pair, the first that do; 96 with openURL and share.
57const max_not_yet_migrated: usize = 96;
5758
5859/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
5960///