also looking at this
feat(android): the clipboard pair — ratchet 100 -> 98
#144
6 files
+414
-3
| @@ -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 | ||
| 28 | const std = ; | |
| 29 | const jni = ; | |
| 30 | ||
| 31 | const Jni = jni.Jni; | |
| 32 | const jobject = jni.jobject; | |
| 33 | ||
| 34 | /// The action names, spelled exactly as `CraftBridge.kt` spells them. | |
| 35 | pub 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. | |
| 45 | fn 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. | |
| 67 | pub 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. | |
| 123 | pub 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 | ||
| 157 | const testing = std.testing; | |
| 158 | ||
| 159 | test "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 | ||
| 177 | const 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 | ||
| 188 | var scenario: Scenario = .has_text; | |
| 189 | var fake_storage: [8]u8 = undefined; | |
| 190 | var fake_text: [*:0]const u8 = "copied"; | |
| 191 | var fake_frames_pushed: usize = 0; | |
| 192 | var fake_frames_popped: usize = 0; | |
| 193 | ||
| 194 | fn obj(tag: usize) jobject { | |
| 195 | return @ptrCast(&fake_storage[tag]); | |
| 196 | } | |
| 197 | ||
| 198 | fn methodName(id: jni.jmethodID) []const u8 { | |
| 199 | return std.mem.span(@as([*:0]const u8, @ptrCast(id.?))); | |
| 200 | } | |
| 201 | ||
| 202 | fn fakeFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass { | |
| 203 | return obj(0); | |
| 204 | } | |
| 205 | fn fakeObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass { | |
| 206 | return obj(0); | |
| 207 | } | |
| 208 | fn fakeStaticFieldId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID { | |
| 209 | return obj(1); | |
| 210 | } | |
| 211 | fn fakeStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject { | |
| 212 | return obj(2); // the "clipboard" service name | |
| 213 | } | |
| 214 | fn fakeMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID { | |
| 215 | return @ptrCast(@constCast(name)); | |
| 216 | } | |
| 217 | fn fakeExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject { | |
| 218 | return null; | |
| 219 | } | |
| 220 | fn fakePushLocalFrame(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint { | |
| 221 | fake_frames_pushed += 1; | |
| 222 | return 0; | |
| 223 | } | |
| 224 | fn fakePopLocalFrame(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject { | |
| 225 | fake_frames_popped += 1; | |
| 226 | return keep; | |
| 227 | } | |
| 228 | ||
| 229 | fn 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 | ||
| 239 | fn 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 | ||
| 246 | fn 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 | ||
| 252 | fn fakeGetStringUTFChars(_: jni.JNIEnv, _: jni.jstring, _: ?*jni.jboolean) callconv(.c) ?[*:0]const u8 { | |
| 253 | return fake_text; | |
| 254 | } | |
| 255 | fn fakeReleaseStringUTFChars(_: jni.JNIEnv, _: jni.jstring, _: [*:0]const u8) callconv(.c) void {} | |
| 256 | ||
| 257 | fn 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 | ||
| 276 | fn 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 | ||
| 284 | test "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 | ||
| 290 | test "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 | ||
| 302 | test "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 | } | |