ReviewOS

also looking at this

craft-native/craft

feat(android): haptic and vibrate — ratchet 91 -> 89

#150
Merged chris wants to merge feat/android-haptics into main
7 files +410 -3
packages/android/templates/CraftBridge.kt.templatemodified+4-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1296,6 +1296,8 @@ class CraftBridge(
12961296
12971297 @JavascriptInterface
12981298 fun haptic(style: String) {
1299 if (CraftNative.haptic(activity, style)) return
1300
12991301 val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
13001302 val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
13011303 vibratorManager.defaultVibrator
@@ -2044,6 +2046,8 @@ class CraftBridge(
20442046
20452047 @JavascriptInterface
20462048 fun vibrate(patternJson: String) {
2049 if (CraftNative.vibrate(activity, patternJson)) return
2050
20472051 val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
20482052 val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
20492053 vibratorManager.defaultVibrator
packages/android/templates/CraftNative.kt.templatemodified+29-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -70,6 +70,8 @@ object CraftNative {
7070 private external fun nativeSecureGet(prefs: SharedPreferences, key: String): String?
7171 private external fun nativeSecureRemove(prefs: SharedPreferences, key: String): Boolean
7272 private external fun nativeSecureClear(prefs: SharedPreferences): Boolean
73 private external fun nativeHaptic(activity: Activity, style: String): Boolean
74 private external fun nativeVibrate(activity: Activity, patternJson: String): Boolean
7375
7476 /**
7577 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -249,4 +251,31 @@ object CraftNative {
249251 false
250252 }
251253 }
254
255 /** Returns whether Zig played the haptic; false falls through. */
256 fun haptic(activity: Activity, style: String): Boolean {
257 if (!isAvailable) return false
258 return try {
259 nativeHaptic(activity, style)
260 } catch (e: UnsatisfiedLinkError) {
261 false
262 }
263 }
264
265 /**
266 * Returns whether Zig played the pattern.
267 *
268 * False also covers a pattern Zig could not parse — deliberately, so the
269 * Kotlin runs, throws the JSONException it would have thrown anyway, and
270 * writes its "Vibration error" line. Handling it here would reach the same
271 * silence and lose the log.
272 */
273 fun vibrate(activity: Activity, patternJson: String): Boolean {
274 if (!isAvailable) return false
275 return try {
276 nativeVibrate(activity, patternJson)
277 } catch (e: UnsatisfiedLinkError) {
278 false
279 }
280 }
252281}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -449,6 +449,9 @@ pub fn build(b: *std.Build) void {
449449 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_securestore.zig", .{
450450 .root_source_file = b.path("src/bridge_android_securestore.zig"),
451451 });
452 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_haptics.zig", .{
453 .root_source_file = b.path("src/bridge_android_haptics.zig"),
454 });
452455 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
453456
454457 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+56-1
Changes to packages/zig/src/android_dispatch.zig
@@ -42,6 +42,7 @@ const clipboard = ;
4242const intents = ;
4343const network = ;
4444const securestore = ;
45const haptics = ;
4546
4647const Jni = jni.Jni;
4748
@@ -190,6 +191,16 @@ const natives = [_]jni.JNINativeMethod{
190191 .signature = "(Landroid/content/SharedPreferences;)Z",
191192 .fnPtr = @ptrCast(&nativeSecureClear),
192193 },
194 .{
195 .name = "nativeHaptic",
196 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
197 .fnPtr = @ptrCast(&nativeHaptic),
198 },
199 .{
200 .name = "nativeVibrate",
201 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
202 .fnPtr = @ptrCast(&nativeVibrate),
203 },
193204};
194205
195206/// How binding went. A value rather than a log line, so every path is
@@ -490,6 +501,50 @@ fn nativeSecureClear(env: jni.JNIEnv, _: jni.jobject, prefs: jni.jobject) callco
490501 return jni.JNI_TRUE;
491502}
492503
504fn nativeHaptic(
505 env: jni.JNIEnv,
506 _: jni.jobject,
507 activity: jni.jobject,
508 style: jni.jstring,
509) callconv(.c) jni.jboolean {
510 const j = Jni.init(env);
511 var arena = std.heap.ArenaAllocator.init(backing);
512 defer arena.deinit();
513
514 const text = j.stringToUtf8(arena.allocator(), style) catch return jni.JNI_FALSE;
515 haptics.play(j, activity, haptics.effectForStyle(text)) catch |err| {
516 std.log.warn("craft: haptic fell through to the shim ({s})", .{@errorName(err)});
517 return jni.JNI_FALSE;
518 };
519 return jni.JNI_TRUE;
520}
521
522fn nativeVibrate(
523 env: jni.JNIEnv,
524 _: jni.jobject,
525 activity: jni.jobject,
526 pattern_json: jni.jstring,
527) callconv(.c) jni.jboolean {
528 const j = Jni.init(env);
529 var arena = std.heap.ArenaAllocator.init(backing);
530 defer arena.deinit();
531 const allocator = arena.allocator();
532
533 const json = j.stringToUtf8(allocator, pattern_json) catch return jni.JNI_FALSE;
534
535 // Null is a pattern the shim would throw on. Declining lets it throw, log
536 // "Vibration error", and vibrate nothing — the same outcome with the log
537 // kept. See bridge_android_haptics.parsePattern.
538 const timings = haptics.parsePattern(allocator, json) catch return jni.JNI_FALSE;
539 if (timings == null) return jni.JNI_FALSE;
540
541 haptics.play(j, activity, .{ .waveform = timings.? }) catch |err| {
542 std.log.warn("craft: vibrate fell through to the shim ({s})", .{@errorName(err)});
543 return jni.JNI_FALSE;
544 };
545 return jni.JNI_TRUE;
546}
547
493548// =============================================================================
494549// Tests
495550// =============================================================================
@@ -601,7 +656,7 @@ test "the registered natives name methods the Kotlin actually declares" {
601656 // A descriptor is checked by the JVM at registration, so a wrong one fails
602657 // at load rather than at call — but only if the *name* matches something.
603658 // These two strings are the contract with CraftBridge.kt.
604 try testing.expectEqual(@as(usize, 12), natives.len);
659 try testing.expectEqual(@as(usize, 14), natives.len);
605660 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
606661
607662 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_haptics.zigadded+291-0
Changes to packages/zig/src/bridge_android_haptics.zig
@@ -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
29const std = ;
30const jni = ;
31
32const Jni = jni.Jni;
33const jobject = jni.jobject;
34
35pub 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.
41const sdk_s: jni.jint = 31; // Build.VERSION_CODES.S — VibratorManager
42const 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.
49pub 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.
61pub 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.
80pub 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
91fn 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.
117fn 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.
153fn 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.
163pub 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
222const testing = std.testing;
223
224test "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
237test "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
246test "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
275test "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
288test "the action names match the Kotlin methods exactly" {
289 try testing.expectEqualStrings("haptic", A.haptic);
290 try testing.expectEqualStrings("vibrate", A.vibrate);
291}
packages/zig/src/jni_runtime.zigmodified+24-0
Changes to packages/zig/src/jni_runtime.zig
@@ -696,6 +696,30 @@ pub const Jni = struct {
696696 return value;
697697 }
698698
699 // --- Arrays -----------------------------------------------------------
700 //
701 // Only `long[]` so far, because only `VibrationEffect.createWaveform`
702 // needs one. `SetLongArrayRegion` copies from a Zig slice in one call
703 // rather than element by element, which is both faster and the only form
704 // that cannot leave a partially-filled array behind on an error.
705
706 pub fn newLongArray(self: Self, values: []const jlong) JniError!jobject {
707 const new: *const fn (JNIEnv, jsize) callconv(.c) jobject =
708 @ptrCast(self.table().NewLongArray orelse return JniError.NotFound);
709 const array = new(self.env, @intCast(values.len)) orelse {
710 try self.check();
711 return JniError.OutOfMemory;
712 };
713
714 if (values.len == 0) return array;
715
716 const set: *const fn (JNIEnv, jobject, jsize, jsize, [*]const jlong) callconv(.c) void =
717 @ptrCast(self.table().SetLongArrayRegion orelse return JniError.NotFound);
718 set(self.env, array, 0, @intCast(values.len), values.ptr);
719 try self.check();
720 return array;
721 }
722
699723 /// Make a Java `String` from UTF-8.
700724 ///
701725 /// The JVM wants *modified* UTF-8 here, the same encoding `stringToUtf8`
packages/zig/test/android_conformance_test.zigmodified+3-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -45,6 +45,7 @@ const zig_sources = [_][]const u8{
4545 @embedFile("src/bridge_android_intents.zig"),
4646 @embedFile("src/bridge_android_network.zig"),
4747 @embedFile("src/bridge_android_securestore.zig"),
48 @embedFile("src/bridge_android_haptics.zig"),
4849};
4950
5051/// How many spec actions Zig does not serve yet.
@@ -58,8 +59,8 @@ const zig_sources = [_][]const u8{
5859/// clipboard pair, the first that do; 96 with openURL and share; 95 with
5960/// getNetworkStatus, which iOS declares unavailable and Android can answer;
6061/// 91 with the secure-storage quartet, the first to take a Kotlin-held object
61/// rather than the Activity.
62const max_not_yet_migrated: usize = 91;
62/// rather than the Activity; 89 with haptic and vibrate.
63const max_not_yet_migrated: usize = 89;
6364
6465/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
6566///