ReviewOS

also looking at this

craft-native/craft

feat(android): the recording store's two reads in Zig — ratchet 67 → 65

#177
Merged glennmichael123 wants to merge feat/android-location-store into main
8 files +538 -5
packages/android/templates/CraftBridge.kt.templatemodified+12-2
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1817,10 +1817,20 @@ class CraftBridge(
18171817 }
18181818
18191819 @JavascriptInterface
1820 fun getLocationRecordingState(): String = CraftLocationRecordingStore.state(activity).toString()
1820 fun getLocationRecordingState(): String {
1821 // Null is "Zig declined", not "no recording" — an absent recording is
1822 // a state object saying so, and returning it here is the answer.
1823 CraftNative.getLocationRecordingState(activity)?.let { return it }
1824
1825 return CraftLocationRecordingStore.state(activity).toString()
1826 }
18211827
18221828 @JavascriptInterface
1823 fun readLocationRecording(): String = CraftLocationRecordingStore.locations(activity).toString()
1829 fun readLocationRecording(): String {
1830 CraftNative.readLocationRecording(activity)?.let { return it }
1831
1832 return CraftLocationRecordingStore.locations(activity).toString()
1833 }
18241834
18251835 // ==================== Clipboard ====================
18261836
packages/android/templates/CraftNative.kt.templatemodified+24-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -131,6 +131,8 @@ object CraftNative {
131131 private external fun nativeSetKeepAwake(activity: Activity, enabled: Boolean): Boolean
132132 private external fun nativeDownloadFile(activity: Activity, url: String, filename: String): Boolean
133133 private external fun nativeSaveFile(activity: Activity, data: String, filename: String): Boolean
134 private external fun nativeGetLocationRecordingState(activity: Activity): String?
135 private external fun nativeReadLocationRecording(activity: Activity): String?
134136
135137 /**
136138 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -650,4 +652,26 @@ object CraftNative {
650652 false
651653 }
652654 }
655
656 /**
657 * The read side of the recording store. Null is "Zig declined", not "no
658 * recording" — an absent recording is a state object saying so.
659 */
660 fun getLocationRecordingState(activity: Activity): String? {
661 if (!isAvailable) return null
662 return try {
663 nativeGetLocationRecordingState(activity)
664 } catch (e: UnsatisfiedLinkError) {
665 null
666 }
667 }
668
669 fun readLocationRecording(activity: Activity): String? {
670 if (!isAvailable) return null
671 return try {
672 nativeReadLocationRecording(activity)
673 } catch (e: UnsatisfiedLinkError) {
674 null
675 }
676 }
653677}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -479,6 +479,9 @@ pub fn build(b: *std.Build) void {
479479 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_files.zig", .{
480480 .root_source_file = b.path("src/bridge_android_files.zig"),
481481 });
482 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_locationstore.zig", .{
483 .root_source_file = b.path("src/bridge_android_locationstore.zig"),
484 });
482485 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
483486
484487 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+68-1
Changes to packages/zig/src/android_dispatch.zig
@@ -54,6 +54,7 @@ const widgets = ;
5454const shortcuts = ;
5555const screen = ;
5656const files = ;
57const locationstore = ;
5758const main_thread = ;
5859
5960const Jni = jni.Jni;
@@ -358,6 +359,18 @@ const natives = [_]jni.JNINativeMethod{
358359 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
359360 .fnPtr = @ptrCast(&nativeSaveFile),
360361 },
362 // Both answer by returning a String, like clipboardRead: null means "ask
363 // the shim" rather than "there is nothing".
364 .{
365 .name = "nativeGetLocationRecordingState",
366 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
367 .fnPtr = @ptrCast(&nativeGetLocationRecordingState),
368 },
369 .{
370 .name = "nativeReadLocationRecording",
371 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
372 .fnPtr = @ptrCast(&nativeReadLocationRecording),
373 },
361374};
362375
363376/// How binding went. A value rather than a log line, so every path is
@@ -1472,6 +1485,60 @@ fn nativeSaveFile(
14721485 return jni.JNI_TRUE;
14731486}
14741487
1488/// `nativeGetLocationRecordingState(activity)`.
1489///
1490/// Null means "ask the shim". A recording that never started is not null — it
1491/// is a state object saying so, which is a different answer.
1492fn nativeGetLocationRecordingState(
1493 env: jni.JNIEnv,
1494 _: jni.jobject,
1495 activity: jni.jobject,
1496) callconv(.c) jni.jstring {
1497 const j = Jni.init(env);
1498 var arena = std.heap.ArenaAllocator.init(backing);
1499 defer arena.deinit();
1500 const allocator = arena.allocator();
1501
1502 const contents = locationstore.readFile(j, allocator, activity) catch |err| {
1503 std.log.warn("craft: getLocationRecordingState fell through to the shim ({s})", .{@errorName(err)});
1504 return null;
1505 };
1506
1507 // A line the shim would drop and this cannot parse: hand the whole read
1508 // back rather than disagreeing about how many samples there are.
1509 const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse
1510 return null;
1511
1512 const state = locationstore.readState(j, allocator, activity, locations.count) catch |err| {
1513 std.log.warn("craft: getLocationRecordingState fell through to the shim ({s})", .{@errorName(err)});
1514 return null;
1515 };
1516
1517 const json = locationstore.renderState(allocator, state) catch return null;
1518 return j.newStringUtf8(allocator, json) catch null;
1519}
1520
1521/// `nativeReadLocationRecording(activity)`.
1522fn nativeReadLocationRecording(
1523 env: jni.JNIEnv,
1524 _: jni.jobject,
1525 activity: jni.jobject,
1526) callconv(.c) jni.jstring {
1527 const j = Jni.init(env);
1528 var arena = std.heap.ArenaAllocator.init(backing);
1529 defer arena.deinit();
1530 const allocator = arena.allocator();
1531
1532 const contents = locationstore.readFile(j, allocator, activity) catch |err| {
1533 std.log.warn("craft: readLocationRecording fell through to the shim ({s})", .{@errorName(err)});
1534 return null;
1535 };
1536
1537 const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse
1538 return null;
1539 return j.newStringUtf8(allocator, locations.json) catch null;
1540}
1541
14751542// =============================================================================
14761543// Tests
14771544// =============================================================================
@@ -1583,7 +1650,7 @@ test "the registered natives name methods the Kotlin actually declares" {
15831650 // A descriptor is checked by the JVM at registration, so a wrong one fails
15841651 // at load rather than at call — but only if the *name* matches something.
15851652 // These two strings are the contract with CraftBridge.kt.
1586 try testing.expectEqual(@as(usize, 37), natives.len);
1653 try testing.expectEqual(@as(usize, 39), natives.len);
15871654 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
15881655
15891656 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_prefs.zigmodified+39-0
Changes to packages/zig/src/android_prefs.zig
@@ -136,6 +136,45 @@ pub fn getString(
136136 return try j.stringToUtf8(allocator, value);
137137}
138138
139/// `prefs.getBoolean(key, fallback)`.
140pub fn getBoolean(
141 j: Jni,
142 allocator: std.mem.Allocator,
143 prefs: jobject,
144 key: []const u8,
145 fallback: bool,
146) !bool {
147 try j.pushLocalFrame(8);
148 defer _ = j.popLocalFrame(null);
149
150 return j.callBooleanMethodA(
151 prefs,
152 try j.methodId(try j.objectClass(prefs), "getBoolean", "(Ljava/lang/String;Z)Z"),
153 &.{
154 .{ .l = try j.newStringUtf8(allocator, key) },
155 .{ .z = if (fallback) jni.JNI_TRUE else jni.JNI_FALSE },
156 },
157 );
158}
159
160/// `prefs.getLong(key, fallback)`.
161pub fn getLong(
162 j: Jni,
163 allocator: std.mem.Allocator,
164 prefs: jobject,
165 key: []const u8,
166 fallback: i64,
167) !i64 {
168 try j.pushLocalFrame(8);
169 defer _ = j.popLocalFrame(null);
170
171 return j.callLongMethodA(
172 prefs,
173 try j.methodId(try j.objectClass(prefs), "getLong", "(Ljava/lang/String;J)J"),
174 &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .j = fallback } },
175 );
176}
177
139178// =============================================================================
140179// Tests
141180// =============================================================================
packages/zig/src/bridge_android_locationstore.zigadded+355-0
Changes to packages/zig/src/bridge_android_locationstore.zig
@@ -0,0 +1,355 @@
1//! `getLocationRecordingState` and `readLocationRecording` on Android.
2//!
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.
7//!
8//! So this migrates the half that is a file and a preferences read, and leaves
9//! the half that is a service alone.
10//!
11//! ## Both return a String rather than answering through the channel
12//!
13//! `fun getLocationRecordingState(): String` — synchronous, like
14//! `clipboardRead`. So a null from the native means "ask the shim" and a
15//! string means "this is the answer", with no promise involved at either end.
16//!
17//! ## A malformed line makes this decline rather than skip
18//!
19//! The store's reader is `forEachLine { if (line.isNotBlank()) runCatching {
20//! result.put(JSONObject(line)) } }` — a line that does not parse is dropped
21//! silently and the rest are kept.
22//!
23//! Zig does not reproduce the dropping. `std.json` is stricter than
24//! `JSONTokener`, so a line one accepts and the other refuses would make the
25//! two disagree about how many samples there are — and `sampleCount` is a
26//! number a page shows to a user. Declining hands the whole read back to the
27//! shim, which drops the line the way it always did.
28//!
29//! ## Lines are passed through, not re-serialized
30//!
31//! The shim parses each line and prints it again, and `append` wrote each line
32//! with the same printer — so re-printing is the identity for every file this
33//! app produced. Passing the bytes through avoids reproducing `org.json`'s
34//! number formatting, which is the `Double.toString` problem the calendar and
35//! database modules both decline to guess at.
36
37const std = ;
38const jni = ;
39const prefs_api = ;
40const bridge_error = ;
41
42const Jni = jni.Jni;
43const jobject = jni.jobject;
44
45pub const A = struct {
46 pub const get_location_recording_state = "getLocationRecordingState";
47 pub const read_location_recording = "readLocationRecording";
48};
49
50/// `CraftLocationRecordingStore.PREFS` and `FILE`.
51const prefs_name = "craft_location_recording";
52const file_name = "craft-location-recording.jsonl";
53
54/// What a recording's preferences hold.
55pub const State = struct {
56 /// Absent rather than null when the key has never been written — the
57 /// store's `put("id", getString("id", null))` *removes* the mapping.
58 id: ?[]const u8,
59 active: bool,
60 paused: bool,
61 /// `getLong("startedAt", 0).takeIf { it > 0 } ?: JSONObject.NULL` — so
62 /// zero and every negative become an explicit JSON null, and the key
63 /// stays.
64 started_at: ?i64,
65 sample_count: usize,
66};
67
68/// `state(context)` as `org.json` would print it.
69///
70/// The two nulls are not the same null, which is the whole reason this is a
71/// function with a test rather than a format string: a missing `id` has no
72/// key at all, and a missing `startedAt` is present and null.
73pub fn renderState(allocator: std.mem.Allocator, state: State) ![]u8 {
74 var out: std.ArrayListUnmanaged(u8) = .empty;
75 errdefer out.deinit(allocator);
76
77 try out.append(allocator, '{');
78 if (state.id) |id| {
79 try out.appendSlice(allocator, "\"id\":\"");
80 try bridge_error.appendJsonEscaped(allocator, &out, id);
81 try out.appendSlice(allocator, "\",");
82 }
83 try out.appendSlice(allocator, "\"active\":");
84 try out.appendSlice(allocator, if (state.active) "true" else "false");
85 try out.appendSlice(allocator, ",\"paused\":");
86 try out.appendSlice(allocator, if (state.paused) "true" else "false");
87
88 try out.appendSlice(allocator, ",\"startedAt\":");
89 if (state.started_at) |started_at| {
90 try out.print(allocator, "{d}", .{started_at});
91 } else {
92 try out.appendSlice(allocator, "null");
93 }
94
95 try out.print(allocator, ",\"sampleCount\":{d}}}", .{state.sample_count});
96 return out.toOwnedSlice(allocator);
97}
98
99/// The array and how many samples are in it.
100///
101/// One function rather than two, so `sampleCount` and the array a page reads
102/// next cannot disagree — that agreement is a property of the code here
103/// rather than something a test has to keep checking.
104pub const Locations = struct { json: []u8, count: usize };
105
106/// `locations(context)` as a JSON array, or null if any line does not parse.
107///
108/// The lines are emitted as they were stored — see the note at the top about
109/// why this does not re-serialize them.
110pub fn renderLocations(allocator: std.mem.Allocator, contents: []const u8) !?Locations {
111 var out: std.ArrayListUnmanaged(u8) = .empty;
112 errdefer out.deinit(allocator);
113
114 try out.append(allocator, '[');
115
116 var written: usize = 0;
117 var lines = std.mem.splitScalar(u8, contents, '\n');
118 while (lines.next()) |raw| {
119 // `isNotBlank()` — a line of spaces is skipped, not parsed. Kotlin's
120 // definition is "no non-whitespace character", and `\r` from a file
121 // written on another platform is whitespace to both.
122 const line = std.mem.trim(u8, raw, " \t\r\n");
123 if (line.len == 0) continue;
124
125 var parsed = std.json.parseFromSlice(std.json.Value, allocator, line, .{}) catch {
126 out.deinit(allocator);
127 return null;
128 };
129 parsed.deinit();
130
131 if (written != 0) try out.append(allocator, ',');
132 try out.appendSlice(allocator, line);
133 written += 1;
134 }
135
136 try out.append(allocator, ']');
137 return Locations{ .json = try out.toOwnedSlice(allocator), .count = written };
138}
139
140/// The recording file's contents, or an empty slice when it does not exist.
141///
142/// Read through Java rather than with Zig's own file API, which on this
143/// toolchain needs an `Io` a JNI native has nowhere to get.
144pub fn readFile(j: Jni, allocator: std.mem.Allocator, activity: jobject) ![]u8 {
145 try j.pushLocalFrame(16);
146 defer _ = j.popLocalFrame(null);
147
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 );
159
160 // `if (!source.exists()) return result` — an empty array rather than an
161 // error, because a recording that never started has no file.
162 const exists = try j.callBooleanMethodA(
163 file,
164 try j.methodId(file_cls, "exists", "()Z"),
165 &.{},
166 );
167 if (!exists) return allocator.alloc(u8, 0);
168
169 const stream_cls = try j.findClass("java/io/FileInputStream");
170 const stream = try j.newObjectA(
171 stream_cls,
172 try j.methodId(stream_cls, "<init>", "(Ljava/io/File;)V"),
173 &.{.{ .l = file }},
174 );
175 const close = try j.methodId(stream_cls, "close", "()V");
176 defer j.callVoidMethodA(stream, close, &.{}) catch {};
177
178 // `readAllBytes` is API 33. `File.length()` plus one `read` is the form
179 // that works everywhere this app runs, and a short read is a truncated
180 // file rather than an error — which is what `forEachLine` would also see.
181 const length = try j.callLongMethod(file, try j.methodId(file_cls, "length", "()J"));
182 if (length <= 0) return allocator.alloc(u8, 0);
183
184 const buffer = try j.newByteArray(@intCast(length));
185 const read = try j.callIntMethodA(
186 stream,
187 try j.methodId(stream_cls, "read", "([B)I"),
188 &.{.{ .l = buffer }},
189 );
190 if (read <= 0) return allocator.alloc(u8, 0);
191
192 const bytes = try j.byteArrayToOwned(allocator, buffer);
193 return allocator.realloc(bytes, @intCast(read));
194}
195
196/// Read the four preference values a state is built from.
197pub fn readState(
198 j: Jni,
199 allocator: std.mem.Allocator,
200 activity: jobject,
201 sample_count: usize,
202) !State {
203 try j.pushLocalFrame(16);
204 defer _ = j.popLocalFrame(null);
205
206 const prefs = try prefs_api.open(j, allocator, activity, prefs_name);
207 const started_at = try prefs_api.getLong(j, allocator, prefs, "startedAt", 0);
208
209 return .{
210 .id = try prefs_api.getString(j, allocator, prefs, "id"),
211 .active = try prefs_api.getBoolean(j, allocator, prefs, "active", false),
212 .paused = try prefs_api.getBoolean(j, allocator, prefs, "paused", false),
213 .started_at = if (started_at > 0) started_at else null,
214 .sample_count = sample_count,
215 };
216}
217
218// =============================================================================
219// Tests
220// =============================================================================
221
222const testing = std.testing;
223
224test "a state that has never recorded has no id and a null startedAt" {
225 // The two nulls the store produces are different nulls: `put("id", null)`
226 // removes the mapping, and `startedAt` is set to JSONObject.NULL
227 // explicitly, so its key stays.
228 const json = try renderState(testing.allocator, .{
229 .id = null,
230 .active = false,
231 .paused = false,
232 .started_at = null,
233 .sample_count = 0,
234 });
235 defer testing.allocator.free(json);
236
237 try testing.expectEqualStrings(
238 \\{"active":false,"paused":false,"startedAt":null,"sampleCount":0}
239 , json);
240}
241
242test "a running recording carries every field, in the store's order" {
243 const json = try renderState(testing.allocator, .{
244 .id = "run-7",
245 .active = true,
246 .paused = true,
247 .started_at = 1_700_000_000_000,
248 .sample_count = 42,
249 });
250 defer testing.allocator.free(json);
251
252 try testing.expectEqualStrings(
253 \\{"id":"run-7","active":true,"paused":true,"startedAt":1700000000000,"sampleCount":42}
254 , json);
255}
256
257test "startedAt of zero or below is the same null as never having started" {
258 // `takeIf { it > 0 } ?: JSONObject.NULL`. A clock that went backwards
259 // writes a negative, and the store reports null rather than the number.
260 for ([_]?i64{
261 null,
262 }) |started_at| {
263 const json = try renderState(testing.allocator, .{
264 .id = "run-7",
265 .active = true,
266 .paused = false,
267 .started_at = started_at,
268 .sample_count = 1,
269 });
270 defer testing.allocator.free(json);
271 try testing.expect(std.mem.indexOf(u8, json, "\"startedAt\":null") != null);
272 }
273}
274
275test "an id carrying a quote survives as JSON" {
276 const json = try renderState(testing.allocator, .{
277 .id = "it's \"run\"",
278 .active = false,
279 .paused = false,
280 .started_at = null,
281 .sample_count = 0,
282 });
283 defer testing.allocator.free(json);
284
285 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
286 defer parsed.deinit();
287 try testing.expectEqualStrings("it's \"run\"", parsed.value.object.get("id").?.string);
288}
289
290test "locations are the file's lines, in order, with the blanks dropped" {
291 const contents =
292 "{\"latitude\":1,\"longitude\":2}\n" ++
293 "\n" ++
294 " \n" ++
295 "{\"latitude\":3,\"longitude\":4}\n";
296
297 const locations = (try renderLocations(testing.allocator, contents)).?;
298 defer testing.allocator.free(locations.json);
299
300 try testing.expectEqualStrings(
301 \\[{"latitude":1,"longitude":2},{"latitude":3,"longitude":4}]
302 , locations.json);
303 try testing.expectEqual(@as(usize, 2), locations.count);
304}
305
306test "an empty or missing file is an empty array" {
307 const empty = (try renderLocations(testing.allocator, "")).?;
308 defer testing.allocator.free(empty.json);
309 try testing.expectEqualStrings("[]", empty.json);
310 try testing.expectEqual(@as(usize, 0), empty.count);
311
312 const blank = (try renderLocations(testing.allocator, "\n\n \n")).?;
313 defer testing.allocator.free(blank.json);
314 try testing.expectEqualStrings("[]", blank.json);
315}
316
317test "a malformed line makes the whole read decline" {
318 // The store drops it and keeps the rest. Zig does not reproduce the
319 // dropping, because std.json and JSONTokener disagree about what parses —
320 // and disagreeing about how many samples there are is worse than handing
321 // the read back.
322 try testing.expect(try renderLocations(testing.allocator, "{\"a\":1}\nnot json\n") == null);
323 try testing.expect(try renderLocations(testing.allocator, "{oops}\n") == null);
324
325 // A truncated final line, which is what a file cut off mid-write looks
326 // like — the case most likely to actually happen.
327 try testing.expect(try renderLocations(testing.allocator, "{\"a\":1}\n{\"b\":") == null);
328}
329
330test "sampleCount is the length of the array it was counted from" {
331 // A page showing `sampleCount` and then reading the samples must not see
332 // two different numbers, so the count comes back from the same pass.
333 const contents = "{\"a\":1}\n\n{\"b\":2}\n \n{\"c\":3}\n";
334
335 const locations = (try renderLocations(testing.allocator, contents)).?;
336 defer testing.allocator.free(locations.json);
337 try testing.expectEqual(@as(usize, 3), locations.count);
338
339 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, locations.json, .{});
340 defer parsed.deinit();
341 try testing.expectEqual(locations.count, parsed.value.array.items.len);
342}
343
344test "the store's names are the ones the service writes" {
345 // A different preferences file or filename here reads an empty recording
346 // and reports it as an empty one, which looks exactly like a recording
347 // that has not started.
348 try testing.expectEqualStrings("craft_location_recording", prefs_name);
349 try testing.expectEqualStrings("craft-location-recording.jsonl", file_name);
350}
351
352test "the actions match the shim exactly" {
353 try testing.expectEqualStrings("getLocationRecordingState", A.get_location_recording_state);
354 try testing.expectEqualStrings("readLocationRecording", A.read_location_recording);
355}
packages/zig/src/jni_runtime.zigmodified+33-0
Changes to packages/zig/src/jni_runtime.zig
@@ -782,6 +782,39 @@ pub const Jni = struct {
782782 try self.check();
783783 }
784784
785 /// `new byte[len]`.
786 pub fn newByteArray(self: Self, len: usize) JniError!jobject {
787 const new: *const fn (JNIEnv, jsize) callconv(.c) jobject =
788 @ptrCast(self.table().NewByteArray orelse return JniError.NotFound);
789 const array = new(self.env, @intCast(len)) orelse {
790 try self.check();
791 return JniError.OutOfMemory;
792 };
793 try self.check();
794 return array;
795 }
796
797 /// Copy a `byte[]` into caller-owned memory.
798 ///
799 /// `GetByteArrayRegion` rather than `GetByteArrayElements`: the region
800 /// form copies into a buffer this side already owns, so there is no
801 /// pinned-or-copied ambiguity and no `Release` call that has to happen on
802 /// every path out.
803 pub fn byteArrayToOwned(self: Self, allocator: std.mem.Allocator, array: jobject) JniError![]u8 {
804 if (array == null) return JniError.NullReference;
805
806 const len = try self.arrayLength(array);
807 const bytes = allocator.alloc(u8, len) catch return JniError.OutOfMemory;
808 errdefer allocator.free(bytes);
809 if (len == 0) return bytes;
810
811 const get: *const fn (JNIEnv, jobject, jsize, jsize, [*]jbyte) callconv(.c) void =
812 @ptrCast(self.table().GetByteArrayRegion orelse return JniError.NotFound);
813 get(self.env, array, 0, @intCast(len), @ptrCast(bytes.ptr));
814 try self.check();
815 return bytes;
816 }
817
785818 /// Make a Java `String` from text already in *modified* UTF-8.
786819 ///
787820 /// Use this only for ASCII the source file holds as a literal — a class
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -55,6 +55,7 @@ const zig_sources = [_][]const u8{
5555 @embedFile("src/bridge_android_shortcuts.zig"),
5656 @embedFile("src/bridge_android_screen.zig"),
5757 @embedFile("src/bridge_android_files.zig"),
58 @embedFile("src/bridge_android_locationstore.zig"),
5859};
5960
6061/// How many spec actions Zig does not serve yet.
@@ -84,8 +85,9 @@ const zig_sources = [_][]const u8{
8485/// the Kotlin holder owns and a token that says what to do; 69 with
8586/// setKeepAwake, whose Kotlin field turns out to be written and never read;
8687/// 67 with downloadFile and saveFile, neither of which needed the main thread
87/// at all.
88const max_not_yet_migrated: usize = 67;
88/// 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;
8991
9092/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
9193///