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