ReviewOS

also looking at this

craft-native/craft

feat(android): the location-recording controls in Zig — ratchet 65 → 61

#181
Merged glennmichael123 wants to merge feat/android-recording-controls into main
7 files +568 -22
packages/android/templates/CraftBridge.kt.templatemodified+10-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1772,6 +1772,10 @@ class CraftBridge(
17721772
17731773 @JavascriptInterface
17741774 fun startLocationRecording(optionsJson: String): String {
1775 // Null is "Zig declined". A permission-denied answer is not null — it
1776 // is a state object saying so, and returning it here is the answer.
1777 CraftNative.startLocationRecording(activity)?.let { return it }
1778
17751779 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
17761780 return JSONObject().apply {
17771781 put("id", JSONObject.NULL)
@@ -1792,12 +1796,16 @@ class CraftBridge(
17921796
17931797 @JavascriptInterface
17941798 fun pauseLocationRecording(): String {
1799 CraftNative.pauseLocationRecording(activity)?.let { return it }
1800
17951801 CraftLocationRecordingStore.setPaused(activity, true)
17961802 return CraftLocationRecordingStore.state(activity).toString()
17971803 }
17981804
17991805 @JavascriptInterface
18001806 fun resumeLocationRecording(): String {
1807 CraftNative.resumeLocationRecording(activity)?.let { return it }
1808
18011809 CraftLocationRecordingStore.setPaused(activity, false)
18021810 if (CraftLocationRecordingStore.isActive(activity)) {
18031811 ContextCompat.startForegroundService(
@@ -1810,6 +1818,8 @@ class CraftBridge(
18101818
18111819 @JavascriptInterface
18121820 fun stopLocationRecording(): String {
1821 CraftNative.stopLocationRecording(activity)?.let { return it }
1822
18131823 CraftLocationRecordingStore.stop(activity)
18141824 val result = CraftLocationRecordingStore.state(activity, includeLocations = true).toString()
18151825 activity.stopService(Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP))
packages/android/templates/CraftNative.kt.templatemodified+73-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,8 @@
11package com.craft.runtime
22
33import android.app.Activity
4import androidx.core.content.ContextCompat
5import android.content.Intent
46import android.os.Handler
57import android.os.Looper
68import android.content.SharedPreferences
@@ -133,6 +135,10 @@ object CraftNative {
133135 private external fun nativeSaveFile(activity: Activity, data: String, filename: String): Boolean
134136 private external fun nativeGetLocationRecordingState(activity: Activity): String?
135137 private external fun nativeReadLocationRecording(activity: Activity): String?
138 private external fun nativeStartLocationRecording(activity: Activity): String?
139 private external fun nativeStopLocationRecording(activity: Activity): String?
140 private external fun nativePauseLocationRecording(activity: Activity): String?
141 private external fun nativeResumeLocationRecording(activity: Activity): String?
136142
137143 /**
138144 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -674,4 +680,71 @@ object CraftNative {
674680 null
675681 }
676682 }
683
684 /**
685 * The four controls. Each returns the store's state as JSON, or null when
686 * Zig declined and the Kotlin below should answer instead.
687 */
688 fun startLocationRecording(activity: Activity): String? {
689 if (!isAvailable) return null
690 return try {
691 nativeStartLocationRecording(activity)
692 } catch (e: UnsatisfiedLinkError) {
693 null
694 }
695 }
696
697 fun stopLocationRecording(activity: Activity): String? {
698 if (!isAvailable) return null
699 return try {
700 nativeStopLocationRecording(activity)
701 } catch (e: UnsatisfiedLinkError) {
702 null
703 }
704 }
705
706 fun pauseLocationRecording(activity: Activity): String? {
707 if (!isAvailable) return null
708 return try {
709 nativePauseLocationRecording(activity)
710 } catch (e: UnsatisfiedLinkError) {
711 null
712 }
713 }
714
715 fun resumeLocationRecording(activity: Activity): String? {
716 if (!isAvailable) return null
717 return try {
718 nativeResumeLocationRecording(activity)
719 } catch (e: UnsatisfiedLinkError) {
720 null
721 }
722 }
723
724 /**
725 * Start and stop the recording service.
726 *
727 * Called from Zig, and here rather than there because
728 * `LocationRecordingService::class.java` names a class whose package is
729 * templated per app — `FindClass` on the native side has no name to look
730 * up, and assembling one from `getPackageName()` is the trap the widget
731 * broadcast action already documents: an `applicationIdSuffix` moves the
732 * runtime package and leaves the class where it was.
733 */
734 @JvmStatic
735 fun startRecordingService(activity: Activity) {
736 ContextCompat.startForegroundService(
737 activity,
738 Intent(activity, LocationRecordingService::class.java)
739 .setAction(LocationRecordingService.ACTION_START)
740 )
741 }
742
743 @JvmStatic
744 fun stopRecordingService(activity: Activity) {
745 activity.stopService(
746 Intent(activity, LocationRecordingService::class.java)
747 .setAction(LocationRecordingService.ACTION_STOP)
748 )
749 }
677750}
packages/zig/src/android_dispatch.zigmodified+161-1
Changes to packages/zig/src/android_dispatch.zig
@@ -371,6 +371,26 @@ const natives = [_]jni.JNINativeMethod{
371371 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
372372 .fnPtr = @ptrCast(&nativeReadLocationRecording),
373373 },
374 .{
375 .name = "nativeStartLocationRecording",
376 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
377 .fnPtr = @ptrCast(&nativeStartLocationRecording),
378 },
379 .{
380 .name = "nativeStopLocationRecording",
381 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
382 .fnPtr = @ptrCast(&nativeStopLocationRecording),
383 },
384 .{
385 .name = "nativePauseLocationRecording",
386 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
387 .fnPtr = @ptrCast(&nativePauseLocationRecording),
388 },
389 .{
390 .name = "nativeResumeLocationRecording",
391 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
392 .fnPtr = @ptrCast(&nativeResumeLocationRecording),
393 },
374394};
375395
376396/// How binding went. A value rather than a log line, so every path is
@@ -1539,6 +1559,146 @@ fn nativeReadLocationRecording(
15391559 return j.newStringUtf8(allocator, locations.json) catch null;
15401560}
15411561
1562/// The current state as a Java String, or null if any part of the read
1563/// declines. Shared by every control, since all four answer with one.
1564fn recordingStateString(
1565 j: Jni,
1566 allocator: std.mem.Allocator,
1567 activity: jni.jobject,
1568 include_locations: bool,
1569) jni.jstring {
1570 const contents = locationstore.readFile(j, allocator, activity) catch return null;
1571 const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse
1572 return null;
1573 const state = locationstore.readState(j, allocator, activity, locations.count) catch return null;
1574
1575 const json = locationstore.renderStateWith(
1576 allocator,
1577 state,
1578 if (include_locations) locations.json else null,
1579 ) catch return null;
1580 return j.newStringUtf8(allocator, json) catch null;
1581}
1582
1583/// `nativeStartLocationRecording(activity)`.
1584fn nativeStartLocationRecording(
1585 env: jni.JNIEnv,
1586 _: jni.jobject,
1587 activity: jni.jobject,
1588) callconv(.c) jni.jstring {
1589 const j = Jni.init(env);
1590 var arena = std.heap.ArenaAllocator.init(backing);
1591 defer arena.deinit();
1592 const allocator = arena.allocator();
1593
1594 const granted = permissions.isGranted(j, activity, permissions.access_fine_location) catch
1595 return null;
1596
1597 if (!granted) {
1598 // A different object from "never recorded": this path builds a fresh
1599 // JSONObject with an explicit JSONObject.NULL id, so the key is
1600 // present and null rather than absent.
1601 const json = locationstore.renderDenied(allocator) catch return null;
1602 return j.newStringUtf8(allocator, json) catch null;
1603 }
1604
1605 const id = locationstore.newRecordingId(j, allocator) catch return null;
1606 const started_at = locationstore.nowMillis(j) catch return null;
1607 locationstore.startStore(j, allocator, activity, id, started_at) catch return null;
1608
1609 // Kotlin names the service class, because its package is templated.
1610 startRecordingService(j, activity) catch return null;
1611
1612 return recordingStateString(j, allocator, activity, false);
1613}
1614
1615/// `nativeStopLocationRecording(activity)`.
1616///
1617/// The state is read *before* the service is stopped, as the shim reads it —
1618/// so the array it returns is the recording as it stood at the call.
1619fn nativeStopLocationRecording(
1620 env: jni.JNIEnv,
1621 _: jni.jobject,
1622 activity: jni.jobject,
1623) callconv(.c) jni.jstring {
1624 const j = Jni.init(env);
1625 var arena = std.heap.ArenaAllocator.init(backing);
1626 defer arena.deinit();
1627 const allocator = arena.allocator();
1628
1629 locationstore.stopStore(j, allocator, activity) catch return null;
1630
1631 // Declining after the store write is safe: `stop` is idempotent, so the
1632 // shim runs the whole method again and reaches `stopService` itself.
1633 const result = recordingStateString(j, allocator, activity, true);
1634 if (result == null) return null;
1635
1636 stopRecordingService(j, activity) catch return null;
1637 return result;
1638}
1639
1640/// `nativePauseLocationRecording(activity)`.
1641fn nativePauseLocationRecording(
1642 env: jni.JNIEnv,
1643 _: jni.jobject,
1644 activity: jni.jobject,
1645) callconv(.c) jni.jstring {
1646 const j = Jni.init(env);
1647 var arena = std.heap.ArenaAllocator.init(backing);
1648 defer arena.deinit();
1649 const allocator = arena.allocator();
1650
1651 locationstore.setPaused(j, allocator, activity, true) catch return null;
1652 return recordingStateString(j, allocator, activity, false);
1653}
1654
1655/// `nativeResumeLocationRecording(activity)`.
1656///
1657/// Restarts the service only when a recording is active, as the shim does — a
1658/// page resuming something it never started should not raise a foreground
1659/// notification.
1660fn nativeResumeLocationRecording(
1661 env: jni.JNIEnv,
1662 _: jni.jobject,
1663 activity: jni.jobject,
1664) callconv(.c) jni.jstring {
1665 const j = Jni.init(env);
1666 var arena = std.heap.ArenaAllocator.init(backing);
1667 defer arena.deinit();
1668 const allocator = arena.allocator();
1669
1670 locationstore.setPaused(j, allocator, activity, false) catch return null;
1671
1672 const active = locationstore.isActive(j, allocator, activity) catch return null;
1673 if (active) startRecordingService(j, activity) catch return null;
1674
1675 return recordingStateString(j, allocator, activity, false);
1676}
1677
1678fn startRecordingService(j: Jni, activity: jni.jobject) !void {
1679 try j.pushLocalFrame(8);
1680 defer _ = j.popLocalFrame(null);
1681
1682 const holder = try j.findClass(holder_class);
1683 try j.callStaticVoidMethodA(
1684 holder,
1685 try j.staticMethodId(holder, "startRecordingService", "(Landroid/app/Activity;)V"),
1686 &.{.{ .l = activity }},
1687 );
1688}
1689
1690fn stopRecordingService(j: Jni, activity: jni.jobject) !void {
1691 try j.pushLocalFrame(8);
1692 defer _ = j.popLocalFrame(null);
1693
1694 const holder = try j.findClass(holder_class);
1695 try j.callStaticVoidMethodA(
1696 holder,
1697 try j.staticMethodId(holder, "stopRecordingService", "(Landroid/app/Activity;)V"),
1698 &.{.{ .l = activity }},
1699 );
1700}
1701
15421702// =============================================================================
15431703// Tests
15441704// =============================================================================
@@ -1650,7 +1810,7 @@ test "the registered natives name methods the Kotlin actually declares" {
16501810 // A descriptor is checked by the JVM at registration, so a wrong one fails
16511811 // at load rather than at call — but only if the *name* matches something.
16521812 // These two strings are the contract with CraftBridge.kt.
1653 try testing.expectEqual(@as(usize, 39), natives.len);
1813 try testing.expectEqual(@as(usize, 43), natives.len);
16541814 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
16551815
16561816 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_permissions.zigmodified+2-0
Changes to packages/zig/src/android_permissions.zig
@@ -40,6 +40,7 @@ pub const read_calendar = "android.permission.READ_CALENDAR";
4040pub const write_calendar = "android.permission.WRITE_CALENDAR";
4141pub const read_contacts = "android.permission.READ_CONTACTS";
4242pub const write_contacts = "android.permission.WRITE_CONTACTS";
43pub const access_fine_location = "android.permission.ACCESS_FINE_LOCATION";
4344
4445/// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`.
4546pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool {
@@ -92,6 +93,7 @@ test "the permission names match Manifest.permission exactly" {
9293 try testing.expectEqualStrings("android.permission.WRITE_CALENDAR", write_calendar);
9394 try testing.expectEqualStrings("android.permission.READ_CONTACTS", read_contacts);
9495 try testing.expectEqualStrings("android.permission.WRITE_CONTACTS", write_contacts);
96 try testing.expectEqualStrings("android.permission.ACCESS_FINE_LOCATION", access_fine_location);
9597}
9698
9799test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" {
packages/zig/src/android_prefs.zigmodified+47-0
Changes to packages/zig/src/android_prefs.zig
@@ -84,6 +84,53 @@ pub fn putString(
8484 );
8585}
8686
87/// `editor.putBoolean(key, value)`.
88pub fn putBoolean(
89 j: Jni,
90 allocator: std.mem.Allocator,
91 editor: jobject,
92 key: []const u8,
93 value: bool,
94) !void {
95 try j.pushLocalFrame(4);
96 defer _ = j.popLocalFrame(null);
97
98 _ = try j.callObjectMethodA(
99 editor,
100 try j.methodId(
101 try j.objectClass(editor),
102 "putBoolean",
103 "(Ljava/lang/String;Z)Landroid/content/SharedPreferences$Editor;",
104 ),
105 &.{
106 .{ .l = try j.newStringUtf8(allocator, key) },
107 .{ .z = if (value) jni.JNI_TRUE else jni.JNI_FALSE },
108 },
109 );
110}
111
112/// `editor.putLong(key, value)`.
113pub fn putLong(
114 j: Jni,
115 allocator: std.mem.Allocator,
116 editor: jobject,
117 key: []const u8,
118 value: i64,
119) !void {
120 try j.pushLocalFrame(4);
121 defer _ = j.popLocalFrame(null);
122
123 _ = try j.callObjectMethodA(
124 editor,
125 try j.methodId(
126 try j.objectClass(editor),
127 "putLong",
128 "(Ljava/lang/String;J)Landroid/content/SharedPreferences$Editor;",
129 ),
130 &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .j = value } },
131 );
132}
133
87134/// `editor.remove(key)`.
88135pub fn remove(j: Jni, allocator: std.mem.Allocator, editor: jobject, key: []const u8) !void {
89136 try j.pushLocalFrame(4);
packages/zig/src/bridge_android_locationstore.zigmodified+272-19
Changes to packages/zig/src/bridge_android_locationstore.zig
@@ -1,12 +1,26 @@
1//! `getLocationRecordingState` and `readLocationRecording` on Android.
1//! The location-recording actions on Android: the two reads and the four
2//! controls.
23//!
3//! Both are the *read* side of `CraftLocationRecordingStore`, which a
4//! foreground service writes while a recording runs. Neither starts, stops or
5//! samples anything — those need the service, and the service needs a
6//! `LocationCallback`, which is a Java interface Zig cannot implement.
4//! `CraftLocationRecordingStore` is a preferences file and a JSONL file, and
5//! everything here is one or both of those — plus, for two of the controls, a
6//! foreground service that has to be started or stopped.
77//!
8//! So this migrates the half that is a file and a preferences read, and leaves
9//! the half that is a service alone.
8//! What stays with the shim is the *sampling*: the service holds a
9//! `LocationCallback`, a Java interface Zig cannot implement. So Zig owns the
10//! record of a recording and Kotlin owns the thing that fills it.
11//!
12//! ## The service Intent is built by Kotlin
13//!
14//! `Intent(activity, LocationRecordingService::class.java)` names a class
15//! whose package is `` — templated per app, so `FindClass`
16//! here has no name to look up. Zig could assemble one from
17//! `getPackageName()`, and that is the same trap the widget broadcast action
18//! sits in: an `applicationIdSuffix` moves the runtime package and leaves the
19//! class where it was.
20//!
21//! So `CraftNative` starts and stops the service with its own class
22//! reference, the way it passes its own broadcast constant across. One side
23//! decides.
1024//!
1125//! ## Both return a String rather than answering through the channel
1226//!
@@ -45,6 +59,10 @@ const jobject = jni.jobject;
4559pub const A = struct {
4660 pub const get_location_recording_state = "getLocationRecordingState";
4761 pub const read_location_recording = "readLocationRecording";
62 pub const start_location_recording = "startLocationRecording";
63 pub const stop_location_recording = "stopLocationRecording";
64 pub const pause_location_recording = "pauseLocationRecording";
65 pub const resume_location_recording = "resumeLocationRecording";
4866};
4967
5068/// `CraftLocationRecordingStore.PREFS` and `FILE`.
@@ -71,6 +89,19 @@ pub const State = struct {
7189/// function with a test rather than a format string: a missing `id` has no
7290/// key at all, and a missing `startedAt` is present and null.
7391pub fn renderState(allocator: std.mem.Allocator, state: State) ![]u8 {
92 return renderStateWith(allocator, state, null);
93}
94
95/// The same, with `includeLocations = true`.
96///
97/// `stopLocationRecording` is the only caller that asks for it, and it asks
98/// *before* stopping the service — so the array it returns is the recording
99/// as it stood at the moment of the call.
100pub fn renderStateWith(
101 allocator: std.mem.Allocator,
102 state: State,
103 locations_json: ?[]const u8,
104) ![]u8 {
74105 var out: std.ArrayListUnmanaged(u8) = .empty;
75106 errdefer out.deinit(allocator);
76107
@@ -92,10 +123,30 @@ pub fn renderState(allocator: std.mem.Allocator, state: State) ![]u8 {
92123 try out.appendSlice(allocator, "null");
93124 }
94125
95 try out.print(allocator, ",\"sampleCount\":{d}}}", .{state.sample_count});
126 try out.print(allocator, ",\"sampleCount\":{d}", .{state.sample_count});
127 if (locations_json) |locations| {
128 try out.appendSlice(allocator, ",\"locations\":");
129 try out.appendSlice(allocator, locations);
130 }
131 try out.append(allocator, '}');
96132 return out.toOwnedSlice(allocator);
97133}
98134
135/// What `startLocationRecording` returns when the permission is not granted.
136///
137/// Not `renderState` with everything empty, and the difference is the point:
138/// this path builds a fresh `JSONObject` with `put("id", JSONObject.NULL)`, an
139/// **explicit** null whose key stays — while `state()` uses
140/// `put("id", getString("id", null))`, a Kotlin null that *removes* the key.
141///
142/// So "permission denied" and "never recorded" are two different objects, and
143/// a page can tell them apart only by whether `id` is present at all.
144pub fn renderDenied(allocator: std.mem.Allocator) ![]u8 {
145 return allocator.dupe(u8,
146 \\{"id":null,"active":false,"paused":false,"startedAt":null,"sampleCount":0}
147 );
148}
149
99150/// The array and how many samples are in it.
100151///
101152/// One function rather than two, so `sampleCount` and the array a page reads
@@ -145,17 +196,8 @@ pub fn readFile(j: Jni, allocator: std.mem.Allocator, activity: jobject) ![]u8 {
145196 try j.pushLocalFrame(16);
146197 defer _ = j.popLocalFrame(null);
147198
148 const files_dir = try j.callObjectMethod(
149 activity,
150 try j.methodId(try j.objectClass(activity), "getFilesDir", "()Ljava/io/File;"),
151 );
152
153 const file_cls = try j.findClass("java/io/File");
154 const file = try j.newObjectA(
155 file_cls,
156 try j.methodId(file_cls, "<init>", "(Ljava/io/File;Ljava/lang/String;)V"),
157 &.{ .{ .l = files_dir }, .{ .l = try j.newStringUtf(file_name) } },
158 );
199 const file = try recordingFile(j, activity);
200 const file_cls = try j.objectClass(file);
159201
160202 // `if (!source.exists()) return result` — an empty array rather than an
161203 // error, because a recording that never started has no file.
@@ -215,6 +257,132 @@ pub fn readState(
215257 };
216258}
217259
260/// `CraftLocationRecordingStore.start(context, id, startedAt)`.
261///
262/// Truncates the sample file first, as the store does — a new recording does
263/// not inherit the last one's points. Opening a `FileOutputStream` without the
264/// append flag is `writeText("")`: it creates or truncates and writes nothing.
265pub fn startStore(
266 j: Jni,
267 allocator: std.mem.Allocator,
268 activity: jobject,
269 id: []const u8,
270 started_at: i64,
271) !void {
272 try j.pushLocalFrame(24);
273 defer _ = j.popLocalFrame(null);
274
275 try truncateFile(j, activity);
276
277 const prefs = try prefs_api.open(j, allocator, activity, prefs_name);
278 const editor = try prefs_api.edit(j, prefs);
279 try prefs_api.putString(j, allocator, editor, "id", id);
280 try prefs_api.putBoolean(j, allocator, editor, "active", true);
281 try prefs_api.putBoolean(j, allocator, editor, "paused", false);
282 try prefs_api.putLong(j, allocator, editor, "startedAt", started_at);
283 try prefs_api.apply(j, editor);
284}
285
286/// `CraftLocationRecordingStore.setPaused(context, paused)`.
287pub fn setPaused(j: Jni, allocator: std.mem.Allocator, activity: jobject, paused: bool) !void {
288 try j.pushLocalFrame(16);
289 defer _ = j.popLocalFrame(null);
290
291 const prefs = try prefs_api.open(j, allocator, activity, prefs_name);
292 const editor = try prefs_api.edit(j, prefs);
293 try prefs_api.putBoolean(j, allocator, editor, "paused", paused);
294 try prefs_api.apply(j, editor);
295}
296
297/// `CraftLocationRecordingStore.stop(context)`.
298///
299/// Clears `paused` as well as `active`, so a recording stopped while paused
300/// does not come back paused. The sample file is left alone — `stop` is what
301/// makes the points readable, not what discards them.
302pub fn stopStore(j: Jni, allocator: std.mem.Allocator, activity: jobject) !void {
303 try j.pushLocalFrame(16);
304 defer _ = j.popLocalFrame(null);
305
306 const prefs = try prefs_api.open(j, allocator, activity, prefs_name);
307 const editor = try prefs_api.edit(j, prefs);
308 try prefs_api.putBoolean(j, allocator, editor, "active", false);
309 try prefs_api.putBoolean(j, allocator, editor, "paused", false);
310 try prefs_api.apply(j, editor);
311}
312
313/// `UUID.randomUUID().toString()`.
314///
315/// Through Java rather than from a Zig random source: the id is the shim's to
316/// define, and `UUID.toString` is a specific hyphenated format a page may well
317/// be matching on.
318pub fn newRecordingId(j: Jni, allocator: std.mem.Allocator) ![]u8 {
319 try j.pushLocalFrame(8);
320 defer _ = j.popLocalFrame(null);
321
322 const uuid_cls = try j.findClass("java/util/UUID");
323 const uuid = try j.callStaticObjectMethodA(
324 uuid_cls,
325 try j.staticMethodId(uuid_cls, "randomUUID", "()Ljava/util/UUID;"),
326 &.{},
327 );
328 const text = try j.callObjectMethod(
329 uuid,
330 try j.methodId(uuid_cls, "toString", "()Ljava/lang/String;"),
331 );
332 return j.stringToUtf8(allocator, text);
333}
334
335/// `System.currentTimeMillis()`.
336pub fn nowMillis(j: Jni) !i64 {
337 try j.pushLocalFrame(4);
338 defer _ = j.popLocalFrame(null);
339
340 const system_cls = try j.findClass("java/lang/System");
341 return j.callStaticLongMethodA(
342 system_cls,
343 try j.staticMethodId(system_cls, "currentTimeMillis", "()J"),
344 &.{},
345 );
346}
347
348/// `prefs.getBoolean("active", false)` — what `resumeLocationRecording` asks
349/// before restarting the service.
350pub fn isActive(j: Jni, allocator: std.mem.Allocator, activity: jobject) !bool {
351 try j.pushLocalFrame(8);
352 defer _ = j.popLocalFrame(null);
353
354 const prefs = try prefs_api.open(j, allocator, activity, prefs_name);
355 return prefs_api.getBoolean(j, allocator, prefs, "active", false);
356}
357
358fn recordingFile(j: Jni, activity: jobject) !jobject {
359 const files_dir = try j.callObjectMethod(
360 activity,
361 try j.methodId(try j.objectClass(activity), "getFilesDir", "()Ljava/io/File;"),
362 );
363
364 const file_cls = try j.findClass("java/io/File");
365 return j.newObjectA(
366 file_cls,
367 try j.methodId(file_cls, "<init>", "(Ljava/io/File;Ljava/lang/String;)V"),
368 &.{ .{ .l = files_dir }, .{ .l = try j.newStringUtf(file_name) } },
369 );
370}
371
372fn truncateFile(j: Jni, activity: jobject) !void {
373 try j.pushLocalFrame(8);
374 defer _ = j.popLocalFrame(null);
375
376 const file = try recordingFile(j, activity);
377 const stream_cls = try j.findClass("java/io/FileOutputStream");
378 const stream = try j.newObjectA(
379 stream_cls,
380 try j.methodId(stream_cls, "<init>", "(Ljava/io/File;)V"),
381 &.{.{ .l = file }},
382 );
383 try j.callVoidMethodA(stream, try j.methodId(stream_cls, "close", "()V"), &.{});
384}
385
218386// =============================================================================
219387// Tests
220388// =============================================================================
@@ -353,3 +521,88 @@ test "the actions match the shim exactly" {
353521 try testing.expectEqualStrings("getLocationRecordingState", A.get_location_recording_state);
354522 try testing.expectEqualStrings("readLocationRecording", A.read_location_recording);
355523}
524
525// --- the controls ----------------------------------------------------------
526
527test "permission denied is a different object from never having recorded" {
528 // The one place these two shapes differ, and the reason `renderDenied` is
529 // its own function: the denied path builds a fresh JSONObject with
530 // `put("id", JSONObject.NULL)` — an explicit null whose key stays — while
531 // `state()` uses `put("id", getString("id", null))`, a Kotlin null that
532 // removes the key. A page can only tell them apart by whether `id` is
533 // there at all.
534 const denied = try renderDenied(testing.allocator);
535 defer testing.allocator.free(denied);
536
537 const never = try renderState(testing.allocator, .{
538 .id = null,
539 .active = false,
540 .paused = false,
541 .started_at = null,
542 .sample_count = 0,
543 });
544 defer testing.allocator.free(never);
545
546 try testing.expectEqualStrings(
547 \\{"id":null,"active":false,"paused":false,"startedAt":null,"sampleCount":0}
548 , denied);
549 try testing.expectEqualStrings(
550 \\{"active":false,"paused":false,"startedAt":null,"sampleCount":0}
551 , never);
552
553 // Everything else about them agrees, which is what makes the one
554 // difference easy to lose.
555 try testing.expect(!std.mem.eql(u8, denied, never));
556
557 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, denied, .{});
558 defer parsed.deinit();
559 try testing.expect(parsed.value.object.get("id").? == .null);
560}
561
562test "only stopLocationRecording asks for the locations" {
563 // `state(context, includeLocations = false)` everywhere else, so the key
564 // is absent rather than empty — a page reading `locations` on a pause
565 // reply gets undefined, not [].
566 const without = try renderStateWith(testing.allocator, .{
567 .id = "run-7",
568 .active = true,
569 .paused = false,
570 .started_at = 1,
571 .sample_count = 2,
572 }, null);
573 defer testing.allocator.free(without);
574 try testing.expect(std.mem.indexOf(u8, without, "locations") == null);
575
576 const with = try renderStateWith(testing.allocator, .{
577 .id = "run-7",
578 .active = false,
579 .paused = false,
580 .started_at = 1,
581 .sample_count = 2,
582 }, "[{\"a\":1},{\"b\":2}]");
583 defer testing.allocator.free(with);
584
585 try testing.expectEqualStrings(
586 \\{"id":"run-7","active":false,"paused":false,"startedAt":1,"sampleCount":2,"locations":[{"a":1},{"b":2}]}
587 , with);
588
589 // `locations` comes last, after `sampleCount`, because the store's
590 // `if (includeLocations) put(...)` runs after every other put.
591 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, with, .{});
592 defer parsed.deinit();
593 try testing.expectEqual(
594 @as(i64, 2),
595 parsed.value.object.get("sampleCount").?.integer,
596 );
597 try testing.expectEqual(
598 @as(usize, 2),
599 parsed.value.object.get("locations").?.array.items.len,
600 );
601}
602
603test "the four controls name themselves as the shim does" {
604 try testing.expectEqualStrings("startLocationRecording", A.start_location_recording);
605 try testing.expectEqualStrings("stopLocationRecording", A.stop_location_recording);
606 try testing.expectEqualStrings("pauseLocationRecording", A.pause_location_recording);
607 try testing.expectEqualStrings("resumeLocationRecording", A.resume_location_recording);
608}
packages/zig/test/android_conformance_test.zigmodified+3-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -86,8 +86,9 @@ const zig_sources = [_][]const u8{
8686/// setKeepAwake, whose Kotlin field turns out to be written and never read;
8787/// 67 with downloadFile and saveFile, neither of which needed the main thread
8888/// at all; 65 with the recording store's two reads, whose writers stay with
89/// the service that owns them.
90const max_not_yet_migrated: usize = 65;
89/// the service that owns them; 61 with the four controls, once Kotlin agreed
90/// to name its own service class.
91const max_not_yet_migrated: usize = 61;
9192
9293/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
9394///