ReviewOS

also looking at this

craft-native/craft

feat(android): the clipboard pair — ratchet 100 -> 98

#144
Merged glennmichael123 wants to merge feat/android-clipboard into main
6 files +414 -3
packages/android/templates/CraftBridge.kt.templatemodified+6-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1784,6 +1784,10 @@ class CraftBridge(
17841784
17851785 @JavascriptInterface
17861786 fun clipboardRead(): String {
1787 // Null is "Zig declined", not "empty" — an empty clipboard comes back
1788 // as "" and returns here rather than falling through to be read twice.
1789 CraftNative.clipboardRead(activity)?.let { return it }
1790
17871791 val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
17881792 val clip = clipboard.primaryClip
17891793 return if (clip != null && clip.itemCount > 0) {
@@ -1795,6 +1799,8 @@ class CraftBridge(
17951799
17961800 @JavascriptInterface
17971801 fun clipboardWrite(text: String): Boolean {
1802 if (CraftNative.clipboardWrite(activity, text)) return true
1803
17981804 return try {
17991805 val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
18001806 val clip = ClipData.newPlainText("Craft", text)
packages/android/templates/CraftNative.kt.templatemodified+29-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -59,6 +59,8 @@ object CraftNative {
5959 private external fun nativeGetDeviceInfo(activity: Activity): String?
6060 private external fun nativeGetMemoryUsage(): String?
6161 private external fun nativeLog(message: String): Boolean
62 private external fun nativeClipboardRead(activity: Activity): String?
63 private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
6264
6365 /**
6466 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -105,4 +107,31 @@ object CraftNative {
105107 false
106108 }
107109 }
110
111 /**
112 * The clipboard's text, or null to use the Kotlin implementation.
113 *
114 * Null and `""` are different answers and both are normal: null means Zig
115 * did not serve this call, `""` means the clipboard is empty — which since
116 * Android 10 is what an app without focus sees every time. Collapsing them
117 * would make an empty clipboard fall through and be read twice.
118 */
119 fun clipboardRead(activity: Activity): String? {
120 if (!isAvailable) return null
121 return try {
122 nativeClipboardRead(activity)
123 } catch (e: UnsatisfiedLinkError) {
124 null
125 }
126 }
127
128 /** Returns whether Zig wrote the clipboard; false falls through. */
129 fun clipboardWrite(activity: Activity, text: String): Boolean {
130 if (!isAvailable) return false
131 return try {
132 nativeClipboardWrite(activity, text)
133 } catch (e: UnsatisfiedLinkError) {
134 false
135 }
136 }
108137}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -437,6 +437,9 @@ pub fn build(b: *std.Build) void {
437437 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_system.zig", .{
438438 .root_source_file = b.path("src/bridge_android_system.zig"),
439439 });
440 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_clipboard.zig", .{
441 .root_source_file = b.path("src/bridge_android_clipboard.zig"),
442 });
440443 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
441444
442445 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+58-1
Changes to packages/zig/src/android_dispatch.zig
@@ -38,6 +38,7 @@ const builtin = ;
3838const jni = ;
3939const device = ;
4040const system = ;
41const clipboard = ;
4142
4243const Jni = jni.Jni;
4344
@@ -137,6 +138,16 @@ const natives = [_]jni.JNINativeMethod{
137138 .signature = "(Ljava/lang/String;)Z",
138139 .fnPtr = @ptrCast(&nativeLog),
139140 },
141 .{
142 .name = "nativeClipboardRead",
143 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
144 .fnPtr = @ptrCast(&nativeClipboardRead),
145 },
146 .{
147 .name = "nativeClipboardWrite",
148 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
149 .fnPtr = @ptrCast(&nativeClipboardWrite),
150 },
140151};
141152
142153/// How binding went. A value rather than a log line, so every path is
@@ -242,6 +253,52 @@ fn nativeLog(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c)
242253 return jni.JNI_TRUE;
243254}
244255
256/// `nativeClipboardRead(activity)`.
257///
258/// Null here means "ask the shim", and an empty *string* means "the clipboard
259/// is empty" — two different answers that a nullable String is exactly able to
260/// carry, and that a plain String would collapse into one.
261fn nativeClipboardRead(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) callconv(.c) jni.jstring {
262 const j = Jni.init(env);
263
264 var arena = std.heap.ArenaAllocator.init(backing);
265 defer arena.deinit();
266 const allocator = arena.allocator();
267
268 const text = clipboard.read(allocator, j, activity) catch |err| {
269 std.log.warn("craft: clipboardRead fell through to the shim ({s})", .{@errorName(err)});
270 return null;
271 };
272
273 const owned = allocator.allocSentinel(u8, text.len, 0) catch return null;
274 @memcpy(owned, text);
275 return j.newStringUtf(owned.ptr) catch null;
276}
277
278/// `nativeClipboardWrite(activity, text)`.
279fn nativeClipboardWrite(
280 env: jni.JNIEnv,
281 _: jni.jobject,
282 activity: jni.jobject,
283 text: jni.jstring,
284) callconv(.c) jni.jboolean {
285 const j = Jni.init(env);
286
287 var arena = std.heap.ArenaAllocator.init(backing);
288 defer arena.deinit();
289 const allocator = arena.allocator();
290
291 const value = j.stringToUtf8(allocator, text) catch return jni.JNI_FALSE;
292 const owned = allocator.allocSentinel(u8, value.len, 0) catch return jni.JNI_FALSE;
293 @memcpy(owned, value);
294
295 clipboard.write(j, activity, owned.ptr) catch |err| {
296 std.log.warn("craft: clipboardWrite fell through to the shim ({s})", .{@errorName(err)});
297 return jni.JNI_FALSE;
298 };
299 return jni.JNI_TRUE;
300}
301
245302// =============================================================================
246303// Tests
247304// =============================================================================
@@ -353,7 +410,7 @@ test "the registered natives name methods the Kotlin actually declares" {
353410 // A descriptor is checked by the JVM at registration, so a wrong one fails
354411 // at load rather than at call — but only if the *name* matches something.
355412 // These two strings are the contract with CraftBridge.kt.
356 try testing.expectEqual(@as(usize, 3), natives.len);
413 try testing.expectEqual(@as(usize, 5), natives.len);
357414 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
358415
359416 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_clipboard.zigadded+314-0
Changes to packages/zig/src/bridge_android_clipboard.zig
@@ -0,0 +1,314 @@
1//! `clipboardRead` and `clipboardWrite` on Android.
2//!
3//! The first actions here that need the `Activity` — `getSystemService` is a
4//! `Context` method — and the first with a null path that is not an error.
5//!
6//! ## `getPrimaryClip()` returning null is the normal case, not the sad one
7//!
8//! Since Android 10 an app may only read the clipboard while it holds focus,
9//! or if it is the default input method. Everywhere else `getPrimaryClip()`
10//! answers null and the system logs a line the app cannot suppress. That is
11//! not a failure to report: the Kotlin returns `""` for it, and so does this.
12//! An error here would turn a routine platform restriction into a rejected
13//! promise on a page that did nothing wrong.
14//!
15//! Three states collapse to the same empty string, and the Kotlin spells all
16//! three out: no clip at all, a clip holding no items, and an item whose text
17//! is null. The last is reachable — a `ClipData` can carry a URI or an Intent
18//! with no text representation — so it is a branch rather than a formality.
19//!
20//! ## `CLIPBOARD_SERVICE` is read, not spelled
21//!
22//! The value is `"clipboard"` and has been since API 1, so hardcoding it would
23//! work. It is read off `android.content.Context` anyway, because a constant
24//! copied into another language is a constant that can only ever be wrong
25//! later, and reading it costs one static field access on a path that already
26//! makes five calls.
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 clipboard_read = "clipboardRead";
37 pub const clipboard_write = "clipboardWrite";
38};
39
40/// `activity.getSystemService(Context.<name>)`.
41///
42/// Split out because both actions need it and because the constant lookup is
43/// the fiddly half: `getSystemService` takes the *value* of the constant, not
44/// its name, so the field has to be read before the call can be made.
45fn systemService(j: Jni, activity: jobject, constant: [*:0]const u8) !jobject {
46 const context_cls = try j.findClass("android/content/Context");
47 const name = try j.staticObjectField(
48 context_cls,
49 try j.staticFieldId(context_cls, constant, "Ljava/lang/String;"),
50 );
51
52 const activity_cls = try j.objectClass(activity);
53 return j.callObjectMethodA(
54 activity,
55 try j.methodId(
56 activity_cls,
57 "getSystemService",
58 "(Ljava/lang/String;)Ljava/lang/Object;",
59 ),
60 &.{.{ .l = name }},
61 );
62}
63
64/// The clipboard's first item as text, or empty when there is nothing to read.
65///
66/// Caller owns the result.
67pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) ![]u8 {
68 try j.pushLocalFrame(16);
69 defer _ = j.popLocalFrame(null);
70
71 const manager = try systemService(j, activity, "CLIPBOARD_SERVICE");
72 const manager_cls = try j.objectClass(manager);
73
74 const clip = try j.callObjectMethod(
75 manager,
76 try j.methodId(manager_cls, "getPrimaryClip", "()Landroid/content/ClipData;"),
77 );
78 // The Android 10 path. Not an error — see the module comment.
79 if (clip == null) return allocator.dupe(u8, "");
80
81 const clip_cls = try j.objectClass(clip);
82 const count = try j.callIntMethod(
83 clip,
84 try j.methodId(clip_cls, "getItemCount", "()I"),
85 );
86 if (count <= 0) return allocator.dupe(u8, "");
87
88 const item = try j.callObjectMethodA(
89 clip,
90 try j.methodId(clip_cls, "getItemAt", "(I)Landroid/content/ClipData$Item;"),
91 &.{.{ .i = 0 }},
92 );
93 if (item == null) return allocator.dupe(u8, "");
94
95 const item_cls = try j.objectClass(item);
96 const text = try j.callObjectMethod(
97 item,
98 try j.methodId(item_cls, "getText", "()Ljava/lang/CharSequence;"),
99 );
100 // A ClipData carrying a URI or an Intent has no text, and `?.toString()`
101 // in the Kotlin is what makes that an empty string rather than "null".
102 if (text == null) return allocator.dupe(u8, "");
103
104 // `toString` off the object's own class rather than off CharSequence: the
105 // interface declares it, but the implementation is on String or
106 // SpannableString, and asking the concrete class is what the JVM resolves
107 // anyway.
108 const text_cls = try j.objectClass(text);
109 const string = try j.callObjectMethod(
110 text,
111 try j.methodId(text_cls, "toString", "()Ljava/lang/String;"),
112 );
113 if (string == null) return allocator.dupe(u8, "");
114
115 return j.stringToUtf8(allocator, string);
116}
117
118/// Put `text` on the clipboard under the label the Kotlin uses.
119///
120/// The label is `"Craft"`, and it is user-visible: Android 13 and later show a
121/// preview toast naming it. Changing it because Zig now does the writing would
122/// change what the user sees.
123pub fn write(j: Jni, activity: jobject, text: [*:0]const u8) !void {
124 try j.pushLocalFrame(16);
125 defer _ = j.popLocalFrame(null);
126
127 const manager = try systemService(j, activity, "CLIPBOARD_SERVICE");
128 const manager_cls = try j.objectClass(manager);
129
130 const clip_cls = try j.findClass("android/content/ClipData");
131 const label = try j.newStringUtf("Craft");
132 const value = try j.newStringUtf(text);
133
134 // `newPlainText` takes two CharSequences; a String is one, and the JVM
135 // accepts the subtype without a cast.
136 const clip = try j.callStaticObjectMethodA(
137 clip_cls,
138 try j.staticMethodId(
139 clip_cls,
140 "newPlainText",
141 "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
142 ),
143 &.{ .{ .l = label }, .{ .l = value } },
144 );
145
146 try j.callVoidMethodA(
147 manager,
148 try j.methodId(manager_cls, "setPrimaryClip", "(Landroid/content/ClipData;)V"),
149 &.{.{ .l = clip }},
150 );
151}
152
153// =============================================================================
154// Tests
155// =============================================================================
156
157const testing = std.testing;
158
159test "the action names match the Kotlin methods exactly" {
160 try testing.expectEqualStrings("clipboardRead", A.clipboard_read);
161 try testing.expectEqualStrings("clipboardWrite", A.clipboard_write);
162}
163
164// --- A fake clipboard ------------------------------------------------------
165//
166// `read` walks six Java objects, and the branch that matters — a null clip —
167// is the one a real device takes most of the time. None of that is reachable
168// from a host without a JVM, except that a `JNIEnv` is a table of pointers, so
169// the JVM can be written in Zig.
170//
171// The trick that keeps it readable: `GetMethodID` returns the method *name*
172// pointer as the `jmethodID`. The id is opaque to JNI and only ever handed
173// back to a Call, so the fake can read it as a string and dispatch on it —
174// which is what lets one `CallObjectMethod` stand in for six real methods
175// without a table of its own.
176
177const Scenario = enum {
178 /// `getPrimaryClip()` answers null. The Android 10+ default.
179 no_clip,
180 /// A clip with `getItemCount() == 0`.
181 empty_clip,
182 /// An item whose `getText()` is null — a URI or Intent clip.
183 no_text,
184 /// Text, present and readable.
185 has_text,
186};
187
188var scenario: Scenario = .has_text;
189var fake_storage: [8]u8 = undefined;
190var fake_text: [*:0]const u8 = "copied";
191var fake_frames_pushed: usize = 0;
192var fake_frames_popped: usize = 0;
193
194fn obj(tag: usize) jobject {
195 return @ptrCast(&fake_storage[tag]);
196}
197
198fn methodName(id: jni.jmethodID) []const u8 {
199 return std.mem.span(@as([*:0]const u8, @ptrCast(id.?)));
200}
201
202fn fakeFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
203 return obj(0);
204}
205fn fakeObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass {
206 return obj(0);
207}
208fn fakeStaticFieldId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID {
209 return obj(1);
210}
211fn fakeStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject {
212 return obj(2); // the "clipboard" service name
213}
214fn fakeMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
215 return @ptrCast(@constCast(name));
216}
217fn fakeExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
218 return null;
219}
220fn fakePushLocalFrame(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
221 fake_frames_pushed += 1;
222 return 0;
223}
224fn fakePopLocalFrame(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject {
225 fake_frames_popped += 1;
226 return keep;
227}
228
229fn fakeCallObjectMethod(_: jni.JNIEnv, _: jobject, id: jni.jmethodID) callconv(.c) jobject {
230 const name = methodName(id);
231 if (std.mem.eql(u8, name, "getPrimaryClip"))
232 return if (scenario == .no_clip) null else obj(3);
233 if (std.mem.eql(u8, name, "getText"))
234 return if (scenario == .no_text) null else obj(5);
235 if (std.mem.eql(u8, name, "toString")) return obj(6);
236 return null;
237}
238
239fn fakeCallObjectMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
240 const name = methodName(id);
241 if (std.mem.eql(u8, name, "getSystemService")) return obj(4);
242 if (std.mem.eql(u8, name, "getItemAt")) return obj(5);
243 return null;
244}
245
246fn fakeCallIntMethod(_: jni.JNIEnv, _: jobject, id: jni.jmethodID) callconv(.c) jni.jint {
247 if (std.mem.eql(u8, methodName(id), "getItemCount"))
248 return if (scenario == .empty_clip) 0 else 1;
249 return 0;
250}
251
252fn fakeGetStringUTFChars(_: jni.JNIEnv, _: jni.jstring, _: ?*jni.jboolean) callconv(.c) ?[*:0]const u8 {
253 return fake_text;
254}
255fn fakeReleaseStringUTFChars(_: jni.JNIEnv, _: jni.jstring, _: [*:0]const u8) callconv(.c) void {}
256
257fn fakeEnv(table: *jni.JNINativeInterface) void {
258 table.* = std.mem.zeroes(jni.JNINativeInterface);
259 table.FindClass = @ptrCast(&fakeFindClass);
260 table.GetObjectClass = @ptrCast(&fakeObjectClass);
261 table.GetStaticFieldID = @ptrCast(&fakeStaticFieldId);
262 table.GetStaticObjectField = @ptrCast(&fakeStaticObjectField);
263 table.GetMethodID = @ptrCast(&fakeMethodId);
264 table.CallObjectMethod = @ptrCast(&fakeCallObjectMethod);
265 table.CallObjectMethodA = @ptrCast(&fakeCallObjectMethodA);
266 table.CallIntMethod = @ptrCast(&fakeCallIntMethod);
267 table.GetStringUTFChars = @ptrCast(&fakeGetStringUTFChars);
268 table.ReleaseStringUTFChars = @ptrCast(&fakeReleaseStringUTFChars);
269 table.ExceptionOccurred = @ptrCast(&fakeExceptionOccurred);
270 table.PushLocalFrame = @ptrCast(&fakePushLocalFrame);
271 table.PopLocalFrame = @ptrCast(&fakePopLocalFrame);
272 fake_frames_pushed = 0;
273 fake_frames_popped = 0;
274}
275
276fn readWith(s: Scenario) ![]u8 {
277 scenario = s;
278 var table: jni.JNINativeInterface = undefined;
279 fakeEnv(&table);
280 const ptr: *const jni.JNINativeInterface = &table;
281 return read(testing.allocator, Jni.init(&ptr), obj(7));
282}
283
284test "a clipboard with text reads as that text" {
285 const text = try readWith(.has_text);
286 defer testing.allocator.free(text);
287 try testing.expectEqualStrings("copied", text);
288}
289
290test "every way the clipboard can be empty answers with an empty string" {
291 // All three are what the Kotlin answers, and none is an error. The first
292 // is the one that matters: since Android 10 an app that does not hold
293 // focus gets null from getPrimaryClip, so this is the branch a real device
294 // takes most of the time.
295 for ([_]Scenario{ .no_clip, .empty_clip, .no_text }) |s| {
296 const text = try readWith(s);
297 defer testing.allocator.free(text);
298 try testing.expectEqualStrings("", text);
299 }
300}
301
302test "the local frame is closed on every path, including the early returns" {
303 // Three of the four paths return before the end of the function, and each
304 // leaves a frame open if the defer is ever moved below them. Sixteen slots
305 // leak per call, and the JVM aborts the process on overflow rather than
306 // failing a call — so this is a crash, not a leak, and it only shows up
307 // after enough reads.
308 for ([_]Scenario{ .no_clip, .empty_clip, .no_text, .has_text }) |s| {
309 const text = try readWith(s);
310 testing.allocator.free(text);
311 try testing.expectEqual(fake_frames_pushed, fake_frames_popped);
312 try testing.expectEqual(@as(usize, 1), fake_frames_popped);
313 }
314}
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -41,6 +41,7 @@ const dispatch_source = @embedFile("src/android_dispatch.zig");
4141const zig_sources = [_][]const u8{
4242 @embedFile("src/bridge_android_device.zig"),
4343 @embedFile("src/bridge_android_system.zig"),
44 @embedFile("src/bridge_android_clipboard.zig"),
4445};
4546
4647/// How many spec actions Zig does not serve yet.
@@ -50,8 +51,9 @@ const zig_sources = [_][]const u8{
5051/// Kotlin, and that is the conversation this constant exists to force.
5152///
5253/// History: 103 at the seam; 102 with getDeviceInfo; 100 with getMemoryUsage
53/// and log — the first two that need no Activity at all.
54const max_not_yet_migrated: usize = 100;
54/// 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;
5557
5658/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
5759///