| @@ -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 | + |
| 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 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. |
| 46 | +fn 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)`. |
| 54 | +fn 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. |
| 69 | +pub 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. |
| 96 | +pub 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 | + |
| 158 | +const testing = std.testing; |
| 159 | + |
| 160 | +var fake_storage: [16]u8 = undefined; |
| 161 | +var fake_calls: std.ArrayListUnmanaged([]const u8) = .empty; |
| 162 | +var fake_field_names: std.ArrayListUnmanaged([]const u8) = .empty; |
| 163 | +var fake_strings: std.ArrayListUnmanaged([]const u8) = .empty; |
| 164 | +var fake_throw_on_start = false; |
| 165 | +var fake_pending: jobject = null; |
| 166 | + |
| 167 | +fn obj(tag: usize) jobject { |
| 168 | + return @ptrCast(&fake_storage[tag]); |
| 169 | +} |
| 170 | +fn nameOf(id: ?*anyopaque) []const u8 { |
| 171 | + return std.mem.span(@as([*:0]const u8, @ptrCast(id.?))); |
| 172 | +} |
| 173 | + |
| 174 | +fn fFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass { |
| 175 | + return obj(0); |
| 176 | +} |
| 177 | +fn fObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass { |
| 178 | + return obj(0); |
| 179 | +} |
| 180 | +fn fMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID { |
| 181 | + return @ptrCast(@constCast(name)); |
| 182 | +} |
| 183 | +fn 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 | +} |
| 187 | +fn fStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject { |
| 188 | + return obj(2); |
| 189 | +} |
| 190 | +fn 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 | +} |
| 194 | +fn 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 | +} |
| 198 | +fn 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 | +} |
| 202 | +fn 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 | +} |
| 206 | +fn 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 | +} |
| 210 | +fn fExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject { |
| 211 | + return fake_pending; |
| 212 | +} |
| 213 | +fn fExceptionDescribe(_: jni.JNIEnv) callconv(.c) void {} |
| 214 | +fn fExceptionClear(_: jni.JNIEnv) callconv(.c) void { |
| 215 | + fake_pending = null; |
| 216 | +} |
| 217 | +fn fDeleteLocalRef(_: jni.JNIEnv, _: jobject) callconv(.c) void {} |
| 218 | +fn fPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint { |
| 219 | + return 0; |
| 220 | +} |
| 221 | +fn fPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject { |
| 222 | + return keep; |
| 223 | +} |
| 224 | + |
| 225 | +fn 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 | + |
| 246 | +fn 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 | + |
| 254 | +fn 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 | + |
| 263 | +fn sawCall(name: []const u8) bool { |
| 264 | + for (fake_calls.items) |c| if (std.mem.eql(u8, c, name)) return true; |
| 265 | + return false; |
| 266 | +} |
| 267 | +fn sawString(text: []const u8) bool { |
| 268 | + for (fake_strings.items) |c| if (std.mem.eql(u8, c, text)) return true; |
| 269 | + return false; |
| 270 | +} |
| 271 | +fn 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 | + |
| 276 | +test "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 | + |
| 293 | +test "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 | + |
| 310 | +test "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 | + |
| 330 | +test "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 | + |
| 349 | +test "the action names match the Kotlin methods exactly" { |
| 350 | + try testing.expectEqualStrings("openURL", A.open_url); |
| 351 | + try testing.expectEqualStrings("share", A.share); |
| 352 | +} |