| @@ -0,0 +1,291 @@ |
| 1 | +//! `haptic` and `vibrate` on Android. |
| 2 | +//! |
| 3 | +//! ## Two SDK branches, both live |
| 4 | +//! |
| 5 | +//! The Kotlin picks its vibrator two ways — `VibratorManager.defaultVibrator` |
| 6 | +//! from API 31, the deprecated `VIBRATOR_SERVICE` below it — and its effect two |
| 7 | +//! ways, `VibrationEffect` from API 26 and a raw duration below that. The |
| 8 | +//! generator defaults `minSdk` to 26, so the pre-26 arm looks dead; a caller |
| 9 | +//! can pass `minSdk: 24` and it is not. Both are ported rather than assumed |
| 10 | +//! away, and the branch is a runtime read of `Build.VERSION.SDK_INT` exactly as |
| 11 | +//! the Kotlin's is. |
| 12 | +//! |
| 13 | +//! Worth knowing while reading the pre-26 arm: it vibrates for a flat 20ms |
| 14 | +//! whatever the style. On API 24 and 25 every haptic feels identical, and that |
| 15 | +//! is the shim's behaviour rather than a simplification made here. |
| 16 | +//! |
| 17 | +//! ## The pattern is parsed here and the effect is built there |
| 18 | +//! |
| 19 | +//! `vibrate` takes a JSON array of milliseconds. Parsing it in Zig and handing |
| 20 | +//! the platform a `long[]` keeps the split every module here uses: the decision |
| 21 | +//! is testable on a host, the call is not. |
| 22 | +//! |
| 23 | +//! A malformed pattern declines rather than erroring. `JSONArray.getLong` |
| 24 | +//! throws on an element it cannot convert, the Kotlin catches it and writes |
| 25 | +//! "Vibration error" to the log, and nothing vibrates. Declining reaches the |
| 26 | +//! same place — the Kotlin runs, throws, logs — and keeps the log line, which |
| 27 | +//! returning an error from here would swallow. |
| 28 | + |
| 29 | +const std = ; |
| 30 | +const jni = ; |
| 31 | + |
| 32 | +const Jni = jni.Jni; |
| 33 | +const jobject = jni.jobject; |
| 34 | + |
| 35 | +pub const A = struct { |
| 36 | + pub const haptic = "haptic"; |
| 37 | + pub const vibrate = "vibrate"; |
| 38 | +}; |
| 39 | + |
| 40 | +/// API levels the Kotlin branches on, by the constant it names. |
| 41 | +const sdk_s: jni.jint = 31; // Build.VERSION_CODES.S — VibratorManager |
| 42 | +const sdk_o: jni.jint = 26; // Build.VERSION_CODES.O — VibrationEffect |
| 43 | + |
| 44 | +/// One effect, as the Kotlin's `when` describes it. |
| 45 | +/// |
| 46 | +/// A tagged union rather than a duration plus a flag: `createOneShot` and |
| 47 | +/// `createWaveform` take different arguments, and collapsing them into one |
| 48 | +/// shape would mean carrying an unused field through every arm. |
| 49 | +pub const Effect = union(enum) { |
| 50 | + /// `createOneShot(milliseconds, DEFAULT_AMPLITUDE)`. |
| 51 | + one_shot: jni.jlong, |
| 52 | + /// `createWaveform(timings, -1)` — no repeat. |
| 53 | + waveform: []const jni.jlong, |
| 54 | +}; |
| 55 | + |
| 56 | +/// The Kotlin's `when (style)`, as a function. |
| 57 | +/// |
| 58 | +/// Split out because this is the whole of the action's decision and the only |
| 59 | +/// half a host can check. The timings are the shim's, to the millisecond: a |
| 60 | +/// page that has tuned its feedback around them would feel the difference. |
| 61 | +pub fn effectForStyle(style: []const u8) Effect { |
| 62 | + if (std.mem.eql(u8, style, "light")) return .{ .one_shot = 10 }; |
| 63 | + if (std.mem.eql(u8, style, "medium")) return .{ .one_shot = 20 }; |
| 64 | + if (std.mem.eql(u8, style, "heavy")) return .{ .one_shot = 50 }; |
| 65 | + if (std.mem.eql(u8, style, "success")) return .{ .waveform = &.{ 0, 30, 50, 30 } }; |
| 66 | + if (std.mem.eql(u8, style, "warning")) return .{ .waveform = &.{ 0, 50, 100, 50 } }; |
| 67 | + if (std.mem.eql(u8, style, "error")) return .{ .waveform = &.{ 0, 100, 50, 100 } }; |
| 68 | + if (std.mem.eql(u8, style, "selection")) return .{ .one_shot = 5 }; |
| 69 | + // The `else ->` arm. An unknown style is absorbed as medium rather than |
| 70 | + // refused, which is what the shim does and what the iOS side does too. |
| 71 | + return .{ .one_shot = 20 }; |
| 72 | +} |
| 73 | + |
| 74 | +/// The timings in a `vibrate` payload, or null when the shim would throw. |
| 75 | +/// |
| 76 | +/// `JSONArray.getLong` accepts an integer, truncates a double, and parses a |
| 77 | +/// numeric string; anything else throws. Matched here, because a page sending |
| 78 | +/// `["100"]` vibrates on the shim and would otherwise stop vibrating the day |
| 79 | +/// Zig took the action over. |
| 80 | +pub fn parsePattern(allocator: std.mem.Allocator, json: []const u8) !?[]jni.jlong { |
| 81 | + // The rejections go through an error rather than a `null` return, so that |
| 82 | + // `errdefer` actually fires. An earlier version returned null from inside |
| 83 | + // the loop with the slice already allocated, which `errdefer` does not |
| 84 | + // cover — the testing allocator caught it as four leaked allocations. |
| 85 | + return parseStrictly(allocator, json) catch |err| switch (err) { |
| 86 | + error.OutOfMemory => err, |
| 87 | + error.NotAPattern => null, |
| 88 | + }; |
| 89 | +} |
| 90 | + |
| 91 | +fn parseStrictly(allocator: std.mem.Allocator, json: []const u8) ![]jni.jlong { |
| 92 | + var parsed = std.json.parseFromSlice(std.json.Value, allocator, json, .{}) catch |
| 93 | + return error.NotAPattern; |
| 94 | + defer parsed.deinit(); |
| 95 | + |
| 96 | + const array = switch (parsed.value) { |
| 97 | + .array => |a| a, |
| 98 | + else => return error.NotAPattern, |
| 99 | + }; |
| 100 | + |
| 101 | + const out = try allocator.alloc(jni.jlong, array.items.len); |
| 102 | + errdefer allocator.free(out); |
| 103 | + |
| 104 | + for (array.items, 0..) |item, i| { |
| 105 | + out[i] = switch (item) { |
| 106 | + .integer => |n| n, |
| 107 | + .float => |f| @intFromFloat(std.math.trunc(f)), |
| 108 | + .string => |text| std.fmt.parseInt(jni.jlong, text, 10) catch |
| 109 | + return error.NotAPattern, |
| 110 | + else => return error.NotAPattern, |
| 111 | + }; |
| 112 | + } |
| 113 | + return out; |
| 114 | +} |
| 115 | + |
| 116 | +/// The device's vibrator, by whichever route this API level offers. |
| 117 | +fn vibrator(j: Jni, activity: jobject) !jobject { |
| 118 | + const version_cls = try j.findClass("android/os/Build$VERSION"); |
| 119 | + const sdk = try j.staticIntField( |
| 120 | + version_cls, |
| 121 | + try j.staticFieldId(version_cls, "SDK_INT", "I"), |
| 122 | + ); |
| 123 | + |
| 124 | + const context_cls = try j.findClass("android/content/Context"); |
| 125 | + const activity_cls = try j.objectClass(activity); |
| 126 | + const get_service = try j.methodId( |
| 127 | + activity_cls, |
| 128 | + "getSystemService", |
| 129 | + "(Ljava/lang/String;)Ljava/lang/Object;", |
| 130 | + ); |
| 131 | + |
| 132 | + if (sdk >= sdk_s) { |
| 133 | + const name = try j.staticObjectField( |
| 134 | + context_cls, |
| 135 | + try j.staticFieldId(context_cls, "VIBRATOR_MANAGER_SERVICE", "Ljava/lang/String;"), |
| 136 | + ); |
| 137 | + const manager = try j.callObjectMethodA(activity, get_service, &.{.{ .l = name }}); |
| 138 | + const manager_cls = try j.objectClass(manager); |
| 139 | + return j.callObjectMethod( |
| 140 | + manager, |
| 141 | + try j.methodId(manager_cls, "getDefaultVibrator", "()Landroid/os/Vibrator;"), |
| 142 | + ); |
| 143 | + } |
| 144 | + |
| 145 | + const name = try j.staticObjectField( |
| 146 | + context_cls, |
| 147 | + try j.staticFieldId(context_cls, "VIBRATOR_SERVICE", "Ljava/lang/String;"), |
| 148 | + ); |
| 149 | + return j.callObjectMethodA(activity, get_service, &.{.{ .l = name }}); |
| 150 | +} |
| 151 | + |
| 152 | +/// Whether this device builds `VibrationEffect`s. |
| 153 | +fn hasVibrationEffect(j: Jni) !bool { |
| 154 | + const version_cls = try j.findClass("android/os/Build$VERSION"); |
| 155 | + const sdk = try j.staticIntField( |
| 156 | + version_cls, |
| 157 | + try j.staticFieldId(version_cls, "SDK_INT", "I"), |
| 158 | + ); |
| 159 | + return sdk >= sdk_o; |
| 160 | +} |
| 161 | + |
| 162 | +/// Play `effect` on the device's vibrator. |
| 163 | +pub fn play(j: Jni, activity: jobject, effect: Effect) !void { |
| 164 | + try j.pushLocalFrame(24); |
| 165 | + defer _ = j.popLocalFrame(null); |
| 166 | + |
| 167 | + const device = try vibrator(j, activity); |
| 168 | + const device_cls = try j.objectClass(device); |
| 169 | + |
| 170 | + if (!try hasVibrationEffect(j)) { |
| 171 | + // The pre-26 arm. `vibrate(long)` takes a duration and nothing else, |
| 172 | + // so a waveform collapses to the shim's flat 20ms — see the module |
| 173 | + // comment. Not an approximation invented here. |
| 174 | + const millis: jni.jlong = switch (effect) { |
| 175 | + .one_shot => |ms| ms, |
| 176 | + .waveform => 20, |
| 177 | + }; |
| 178 | + try j.callVoidMethodA( |
| 179 | + device, |
| 180 | + try j.methodId(device_cls, "vibrate", "(J)V"), |
| 181 | + &.{.{ .j = millis }}, |
| 182 | + ); |
| 183 | + return; |
| 184 | + } |
| 185 | + |
| 186 | + const effect_cls = try j.findClass("android/os/VibrationEffect"); |
| 187 | + const built = switch (effect) { |
| 188 | + .one_shot => |ms| blk: { |
| 189 | + const amplitude = try j.staticIntField( |
| 190 | + effect_cls, |
| 191 | + try j.staticFieldId(effect_cls, "DEFAULT_AMPLITUDE", "I"), |
| 192 | + ); |
| 193 | + break :blk try j.callStaticObjectMethodA( |
| 194 | + effect_cls, |
| 195 | + try j.staticMethodId(effect_cls, "createOneShot", "(JI)Landroid/os/VibrationEffect;"), |
| 196 | + &.{ .{ .j = ms }, .{ .i = amplitude } }, |
| 197 | + ); |
| 198 | + }, |
| 199 | + .waveform => |timings| blk: { |
| 200 | + const array = try j.newLongArray(timings); |
| 201 | + break :blk try j.callStaticObjectMethodA( |
| 202 | + effect_cls, |
| 203 | + try j.staticMethodId(effect_cls, "createWaveform", "([JI)Landroid/os/VibrationEffect;"), |
| 204 | + // -1 is "do not repeat", and it is the shim's value on both |
| 205 | + // waveform paths. |
| 206 | + &.{ .{ .l = array }, .{ .i = -1 } }, |
| 207 | + ); |
| 208 | + }, |
| 209 | + }; |
| 210 | + |
| 211 | + try j.callVoidMethodA( |
| 212 | + device, |
| 213 | + try j.methodId(device_cls, "vibrate", "(Landroid/os/VibrationEffect;)V"), |
| 214 | + &.{.{ .l = built }}, |
| 215 | + ); |
| 216 | +} |
| 217 | + |
| 218 | +// ============================================================================= |
| 219 | +// Tests |
| 220 | +// ============================================================================= |
| 221 | + |
| 222 | +const testing = std.testing; |
| 223 | + |
| 224 | +test "every style maps to the timings the shim uses" { |
| 225 | + // Millisecond-exact, because a page that tuned its feedback around these |
| 226 | + // would feel a change that no test of "it vibrated" would catch. |
| 227 | + try testing.expectEqual(@as(jni.jlong, 10), effectForStyle("light").one_shot); |
| 228 | + try testing.expectEqual(@as(jni.jlong, 20), effectForStyle("medium").one_shot); |
| 229 | + try testing.expectEqual(@as(jni.jlong, 50), effectForStyle("heavy").one_shot); |
| 230 | + try testing.expectEqual(@as(jni.jlong, 5), effectForStyle("selection").one_shot); |
| 231 | + |
| 232 | + try testing.expectEqualSlices(jni.jlong, &.{ 0, 30, 50, 30 }, effectForStyle("success").waveform); |
| 233 | + try testing.expectEqualSlices(jni.jlong, &.{ 0, 50, 100, 50 }, effectForStyle("warning").waveform); |
| 234 | + try testing.expectEqualSlices(jni.jlong, &.{ 0, 100, 50, 100 }, effectForStyle("error").waveform); |
| 235 | +} |
| 236 | + |
| 237 | +test "an unknown style is absorbed as medium, not refused" { |
| 238 | + // The `else ->` arm. iOS does the same thing for the same reason: with no |
| 239 | + // reply channel there is nowhere to report a typo, so a wrong style is |
| 240 | + // indistinguishable from "medium" on both platforms. |
| 241 | + try testing.expectEqual(@as(jni.jlong, 20), effectForStyle("soft").one_shot); |
| 242 | + try testing.expectEqual(@as(jni.jlong, 20), effectForStyle("").one_shot); |
| 243 | + try testing.expectEqual(@as(jni.jlong, 20), effectForStyle("HEAVY").one_shot); |
| 244 | +} |
| 245 | + |
| 246 | +test "a pattern parses the way JSONArray.getLong reads it" { |
| 247 | + const alloc = testing.allocator; |
| 248 | + |
| 249 | + { |
| 250 | + const p = (try parsePattern(alloc, "[0,100,50,100]")).?; |
| 251 | + defer alloc.free(p); |
| 252 | + try testing.expectEqualSlices(jni.jlong, &.{ 0, 100, 50, 100 }, p); |
| 253 | + } |
| 254 | + // A double truncates rather than rounding — `getLong` casts. |
| 255 | + { |
| 256 | + const p = (try parsePattern(alloc, "[10.9]")).?; |
| 257 | + defer alloc.free(p); |
| 258 | + try testing.expectEqualSlices(jni.jlong, &.{10}, p); |
| 259 | + } |
| 260 | + // A numeric string parses. A page sending ["100"] vibrates on the shim, |
| 261 | + // and would stop the day Zig took the action over if this were refused. |
| 262 | + { |
| 263 | + const p = (try parsePattern(alloc, "[\"100\", 50]")).?; |
| 264 | + defer alloc.free(p); |
| 265 | + try testing.expectEqualSlices(jni.jlong, &.{ 100, 50 }, p); |
| 266 | + } |
| 267 | + // An empty array is a valid pattern that vibrates for no time at all. |
| 268 | + { |
| 269 | + const p = (try parsePattern(alloc, "[]")).?; |
| 270 | + defer alloc.free(p); |
| 271 | + try testing.expectEqual(@as(usize, 0), p.len); |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +test "a pattern the shim would throw on declines instead of erroring" { |
| 276 | + // Null here means "let the Kotlin run", which throws, logs |
| 277 | + // "Vibration error", and vibrates nothing. Returning an error instead |
| 278 | + // would reach the same silence and lose the log line. |
| 279 | + const alloc = testing.allocator; |
| 280 | + try testing.expect(try parsePattern(alloc, "not json") == null); |
| 281 | + try testing.expect(try parsePattern(alloc, "{\"a\":1}") == null); |
| 282 | + try testing.expect(try parsePattern(alloc, "[true]") == null); |
| 283 | + try testing.expect(try parsePattern(alloc, "[null]") == null); |
| 284 | + try testing.expect(try parsePattern(alloc, "[\"abc\"]") == null); |
| 285 | + try testing.expect(try parsePattern(alloc, "[[1,2]]") == null); |
| 286 | +} |
| 287 | + |
| 288 | +test "the action names match the Kotlin methods exactly" { |
| 289 | + try testing.expectEqualStrings("haptic", A.haptic); |
| 290 | + try testing.expectEqualStrings("vibrate", A.vibrate); |
| 291 | +} |