ReviewOS

also looking at this

craft-native/craft

feat(android): dbExecute and dbQuery in Zig — ratchet 84 → 82

#160
Merged glennmichael123 wants to merge feat/android-sqlite into main
7 files +541 -3
packages/android/templates/CraftBridge.kt.templatemodified+8-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2843,6 +2843,12 @@ class CraftBridge(
28432843 database = activity.openOrCreateDatabase("craft.db", Context.MODE_PRIVATE, null)
28442844 }
28452845
2846 // After the open, so Zig borrows this connection rather than
2847 // starting a second one against the same file. Zig answers through
2848 // the reply channel, so returning is what stops the promise being
2849 // settled twice.
2850 database?.let { if (CraftNative.dbExecute(it, sql, paramsJson)) return }
2851
28462852 val params = JSONArray(paramsJson)
28472853 val args = Array(params.length()) { params.getString(it) }
28482854
@@ -2871,6 +2877,8 @@ class CraftBridge(
28712877 database = activity.openOrCreateDatabase("craft.db", Context.MODE_PRIVATE, null)
28722878 }
28732879
2880 database?.let { if (CraftNative.dbQuery(it, sql, paramsJson)) return }
2881
28742882 val params = JSONArray(paramsJson)
28752883 val args = Array(params.length()) { params.getString(it) }
28762884
packages/android/templates/CraftNative.kt.templatemodified+30-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -2,6 +2,7 @@ package com.craft.runtime
22
33import android.app.Activity
44import android.content.SharedPreferences
5import android.database.sqlite.SQLiteDatabase
56import org.json.JSONObject
67
78/**
@@ -110,6 +111,8 @@ object CraftNative {
110111 private external fun nativeDeleteCalendarEvent(activity: Activity, eventId: String): Boolean
111112 private external fun nativeGetCalendarEvents(activity: Activity, startDateMs: Long, endDateMs: Long): Boolean
112113 private external fun nativeCreateCalendarEvent(activity: Activity, eventJson: String): Boolean
114 private external fun nativeDbExecute(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean
115 private external fun nativeDbQuery(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean
113116
114117 /**
115118 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -385,4 +388,31 @@ object CraftNative {
385388 false
386389 }
387390 }
391
392 /**
393 * Returns whether Zig took the statement.
394 *
395 * The connection is passed in rather than opened: the Kotlin caches one
396 * `SQLiteDatabase` so that a BEGIN in one call and a COMMIT in the next
397 * reach the same connection, and a second one opened by Zig would take
398 * locks against the first for no visible reason.
399 */
400 fun dbExecute(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean {
401 if (!isAvailable) return false
402 return try {
403 nativeDbExecute(database, sql, paramsJson)
404 } catch (e: UnsatisfiedLinkError) {
405 false
406 }
407 }
408
409 /** The same, for a statement that returns rows. */
410 fun dbQuery(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean {
411 if (!isAvailable) return false
412 return try {
413 nativeDbQuery(database, sql, paramsJson)
414 } catch (e: UnsatisfiedLinkError) {
415 false
416 }
417 }
388418}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -458,6 +458,9 @@ pub fn build(b: *std.Build) void {
458458 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_calendar.zig", .{
459459 .root_source_file = b.path("src/bridge_android_calendar.zig"),
460460 });
461 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_db.zig", .{
462 .root_source_file = b.path("src/bridge_android_db.zig"),
463 });
461464 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
462465
463466 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+94-1
Changes to packages/zig/src/android_dispatch.zig
@@ -47,6 +47,7 @@ const haptics = ;
4747const notifcancel = ;
4848const events = ;
4949const calendar = ;
50const db = ;
5051
5152const Jni = jni.Jni;
5253
@@ -248,6 +249,19 @@ const natives = [_]jni.JNINativeMethod{
248249 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
249250 .fnPtr = @ptrCast(&nativeCreateCalendarEvent),
250251 },
252 // The database arrives as an argument rather than being opened here, so
253 // one connection serves both languages — the same shape the secure store
254 // uses for its SharedPreferences.
255 .{
256 .name = "nativeDbExecute",
257 .signature = "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;Ljava/lang/String;)Z",
258 .fnPtr = @ptrCast(&nativeDbExecute),
259 },
260 .{
261 .name = "nativeDbQuery",
262 .signature = "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;Ljava/lang/String;)Z",
263 .fnPtr = @ptrCast(&nativeDbQuery),
264 },
251265};
252266
253267/// How binding went. A value rather than a log line, so every path is
@@ -789,6 +803,85 @@ fn nativeCreateCalendarEvent(
789803 return jni.JNI_TRUE;
790804}
791805
806/// The parameters and SQL both actions start from, or null to decline.
807///
808/// Declining costs nothing here because it happens before any JNI call that
809/// could change the database — the shim then runs the whole method, including
810/// its own parse.
811fn dbCall(
812 j: Jni,
813 allocator: std.mem.Allocator,
814 sql: jni.jstring,
815 params_json: jni.jstring,
816 parsed: *std.json.Parsed(std.json.Value),
817) !?struct { sql: []const u8, args: [][]const u8 } {
818 const sql_text = try j.stringToUtf8(allocator, sql);
819 const params_text = try j.stringToUtf8(allocator, params_json);
820
821 parsed.* = std.json.parseFromSlice(std.json.Value, allocator, params_text, .{}) catch
822 return null;
823
824 const args = (try db.bindArgs(allocator, parsed.value)) orelse return null;
825 return .{ .sql = sql_text, .args = args };
826}
827
828/// `nativeDbExecute(database, sql, paramsJson)`.
829fn nativeDbExecute(
830 env: jni.JNIEnv,
831 _: jni.jobject,
832 database: jni.jobject,
833 sql: jni.jstring,
834 params_json: jni.jstring,
835) callconv(.c) jni.jboolean {
836 const j = Jni.init(env);
837 var arena = std.heap.ArenaAllocator.init(backing);
838 defer arena.deinit();
839 const allocator = arena.allocator();
840
841 var parsed: std.json.Parsed(std.json.Value) = undefined;
842 const call = (dbCall(j, allocator, sql, params_json, &parsed) catch return jni.JNI_FALSE) orelse
843 return jni.JNI_FALSE;
844
845 db.execute(j, allocator, database, call.sql, call.args) catch |err| {
846 // The shim catches and rejects with the exception's message; the
847 // throwable was described to logcat and cleared by `Jni.check` before
848 // this point, so the error name is what is left to say.
849 calendar.rejectOn(allocator, db.exec_reject_global, @errorName(err)) catch {};
850 return jni.JNI_TRUE;
851 };
852
853 events.settle(allocator, db.exec_resolve_global, db.exec_result) catch return jni.JNI_FALSE;
854 return jni.JNI_TRUE;
855}
856
857/// `nativeDbQuery(database, sql, paramsJson)`.
858fn nativeDbQuery(
859 env: jni.JNIEnv,
860 _: jni.jobject,
861 database: jni.jobject,
862 sql: jni.jstring,
863 params_json: jni.jstring,
864) callconv(.c) jni.jboolean {
865 const j = Jni.init(env);
866 var arena = std.heap.ArenaAllocator.init(backing);
867 defer arena.deinit();
868 const allocator = arena.allocator();
869
870 var parsed: std.json.Parsed(std.json.Value) = undefined;
871 const call = (dbCall(j, allocator, sql, params_json, &parsed) catch return jni.JNI_FALSE) orelse
872 return jni.JNI_FALSE;
873
874 var payload: std.ArrayListUnmanaged(u8) = .empty;
875 defer payload.deinit(allocator);
876 db.query(j, allocator, database, call.sql, call.args, &payload) catch |err| {
877 calendar.rejectOn(allocator, db.query_reject_global, @errorName(err)) catch {};
878 return jni.JNI_TRUE;
879 };
880
881 events.settle(allocator, db.query_resolve_global, payload.items) catch return jni.JNI_FALSE;
882 return jni.JNI_TRUE;
883}
884
792885// =============================================================================
793886// Tests
794887// =============================================================================
@@ -900,7 +993,7 @@ test "the registered natives name methods the Kotlin actually declares" {
900993 // A descriptor is checked by the JVM at registration, so a wrong one fails
901994 // at load rather than at call — but only if the *name* matches something.
902995 // These two strings are the contract with CraftBridge.kt.
903 try testing.expectEqual(@as(usize, 19), natives.len);
996 try testing.expectEqual(@as(usize, 21), natives.len);
904997 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
905998
906999 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_db.zigadded+386-0
Changes to packages/zig/src/bridge_android_db.zig
@@ -0,0 +1,386 @@
1//! `dbExecute` and `dbQuery` on Android.
2//!
3//! ## The connection stays Kotlin's
4//!
5//! The shim caches one `SQLiteDatabase` in a field and reuses it, which is
6//! what makes `BEGIN` in one call and `COMMIT` in the next work at all. Zig
7//! could open its own — `Context.openOrCreateDatabase` hands out a new
8//! connection each call — and then a page that mixed a served call with a
9//! declined one would have two connections to one file, taking each other's
10//! locks for no reason a reader could see.
11//!
12//! So the database arrives as an argument, the way `SharedPreferences` does
13//! for the secure store. The Kotlin opens it, the Kotlin owns it, and Zig
14//! borrows it for the length of one call.
15//!
16//! ## Which parameter shapes this serves
17//!
18//! The shim turns every parameter into a string with `JSONArray.getString`,
19//! which coerces: `1` becomes "1", `true` becomes "true", and an explicit null
20//! becomes the four characters "null" — the same `JSONObject.NULL.toString()`
21//! quirk the calendar module documents.
22//!
23//! Those four are reproduced. A float is not, because `Double.toString` picks
24//! the shortest form that round-trips and matching it exactly is not something
25//! to reproduce from memory; nor is a nested object or array, whose string
26//! form is `org.json`'s own printer. Those make `bindArgs` return null, the
27//! native returns false, and the shim binds them with the coercions it has.
28//!
29//! ## A NULL column disappears from its row
30//!
31//! `row.put(col, cursor.getString(index))` with a null value **removes** the
32//! mapping rather than storing a JSON null, so a row with a NULL column comes
33//! back with that key missing. It is not a rounding of the truth to say a page
34//! cannot tell a NULL column from an absent one — that is what the shim sends.
35
36const std = ;
37const jni = ;
38const bridge_error = ;
39
40const Jni = jni.Jni;
41const jobject = jni.jobject;
42
43pub const A = struct {
44 pub const db_execute = "dbExecute";
45 pub const db_query = "dbQuery";
46};
47
48pub const exec_resolve_global = "_craftDbExecResolve";
49pub const exec_reject_global = "_craftDbExecReject";
50pub const query_resolve_global = "_craftDbQueryResolve";
51pub const query_reject_global = "_craftDbQueryReject";
52
53/// What `dbExecute` resolves with, exactly as the shim writes it.
54///
55/// The 1 is a literal in the Kotlin, not a row count — `execSQL` returns void,
56/// so nothing here knows how many rows were touched. A `DELETE` matching
57/// nothing reports the same 1 as an `INSERT`. Ported rather than corrected,
58/// because correcting it means a different number reaching pages that already
59/// read this one. See #159.
60pub const exec_result = "{\"rowsAffected\":1}";
61
62/// `Array(params.length()) { params.getString(it) }`, or null to decline.
63///
64/// Most results borrow from `value`, which has to outlive the JNI calls that
65/// bind them; an integer is printed and so is allocated. Nothing is freed
66/// individually, because every caller is inside the per-call arena — passing a
67/// tracking allocator here reports the printed integers as leaks.
68pub fn bindArgs(allocator: std.mem.Allocator, value: std.json.Value) !?[][]const u8 {
69 const items = switch (value) {
70 .array => |a| a.items,
71 // `JSONArray(paramsJson)` throws on anything that is not an array, and
72 // the shim's catch rejects. Declining sends it there.
73 else => return null,
74 };
75
76 const args = try allocator.alloc([]const u8, items.len);
77 errdefer allocator.free(args);
78
79 for (items, 0..) |item, i| {
80 args[i] = switch (item) {
81 .string => |text| text,
82 .bool => |b| if (b) "true" else "false",
83 // `JSONObject.NULL.toString()`. A page sending `[null]` binds the
84 // four characters, not SQL NULL — which is worth knowing, and is
85 // the shim's behaviour either way.
86 .null => "null",
87 .integer => |n| try std.fmt.allocPrint(allocator, "{d}", .{n}),
88 // Double.toString, org.json's printer, and a number too large for
89 // an i64. Each has an exact answer that is not worth guessing.
90 .float, .object, .array, .number_string => {
91 allocator.free(args);
92 return null;
93 },
94 };
95 }
96 return args;
97}
98
99/// One row of a query result, as the shim's `JSONObject` would print it.
100///
101/// A null value removes its key rather than storing a JSON null, so `values`
102/// carrying a null emits an object without that column.
103pub fn appendRow(
104 allocator: std.mem.Allocator,
105 out: *std.ArrayListUnmanaged(u8),
106 columns: []const []const u8,
107 values: []const ?[]const u8,
108) !void {
109 std.debug.assert(columns.len == values.len);
110
111 try out.append(allocator, '{');
112 var written: usize = 0;
113 for (columns, values) |column, value| {
114 const text = value orelse continue;
115 if (written != 0) try out.append(allocator, ',');
116 try out.append(allocator, '"');
117 try bridge_error.appendJsonEscaped(allocator, out, column);
118 try out.appendSlice(allocator, "\":\"");
119 try bridge_error.appendJsonEscaped(allocator, out, text);
120 try out.append(allocator, '"');
121 written += 1;
122 }
123 try out.append(allocator, '}');
124}
125
126/// `database.execSQL(sql, args)`.
127pub fn execute(j: Jni, allocator: std.mem.Allocator, db: jobject, sql: []const u8, args: []const []const u8) !void {
128 try j.pushLocalFrame(16);
129 defer _ = j.popLocalFrame(null);
130
131 const bind = try stringArray(j, allocator, args);
132 const db_cls = try j.objectClass(db);
133
134 // `execSQL(String, Object[])` — a `String[]` is an `Object[]`, which is
135 // what the Kotlin passes too.
136 try j.callVoidMethodA(
137 db,
138 try j.methodId(db_cls, "execSQL", "(Ljava/lang/String;[Ljava/lang/Object;)V"),
139 &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } },
140 );
141}
142
143/// `database.rawQuery(sql, args)`, every row appended to `out` as JSON.
144pub fn query(
145 j: Jni,
146 allocator: std.mem.Allocator,
147 db: jobject,
148 sql: []const u8,
149 args: []const []const u8,
150 out: *std.ArrayListUnmanaged(u8),
151) !void {
152 try j.pushLocalFrame(24);
153 defer _ = j.popLocalFrame(null);
154
155 const bind = try stringArray(j, allocator, args);
156 const db_cls = try j.objectClass(db);
157 const cursor = try j.callObjectMethodA(
158 db,
159 try j.methodId(
160 db_cls,
161 "rawQuery",
162 "(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;",
163 ),
164 &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } },
165 );
166
167 try out.append(allocator, '[');
168 defer out.append(allocator, ']') catch {};
169
170 if (cursor == null) return;
171
172 const cursor_cls = try j.objectClass(cursor);
173 const move_to_next = try j.methodId(cursor_cls, "moveToNext", "()Z");
174 const get_string = try j.methodId(cursor_cls, "getString", "(I)Ljava/lang/String;");
175 const close = try j.methodId(cursor_cls, "close", "()V");
176
177 // `use` closes however the block leaves, error included.
178 defer j.callVoidMethodA(cursor, close, &.{}) catch {};
179
180 const names = try j.callObjectMethodA(
181 cursor,
182 try j.methodId(cursor_cls, "getColumnNames", "()[Ljava/lang/String;"),
183 &.{},
184 );
185 const column_count: usize = @intCast(try j.arrayLength(names));
186
187 const columns = try allocator.alloc([]const u8, column_count);
188 defer allocator.free(columns);
189 for (columns, 0..) |*column, i| {
190 const element = try j.objectArrayElement(names, i);
191 column.* = if (element == null) "" else try j.stringToUtf8(allocator, element);
192 }
193
194 const values = try allocator.alloc(?[]const u8, column_count);
195 defer allocator.free(values);
196
197 var row_count: usize = 0;
198 while (try j.callBooleanMethodA(cursor, move_to_next, &.{})) {
199 // Sixteen local slots is all the JVM guarantees, and a wide table
200 // makes one reference per column.
201 try j.pushLocalFrame(8);
202 defer _ = j.popLocalFrame(null);
203
204 for (values, 0..) |*slot, i| {
205 const text = try j.callObjectMethodA(cursor, get_string, &.{.{ .i = @intCast(i) }});
206 slot.* = if (text == null) null else try j.stringToUtf8(allocator, text);
207 }
208
209 if (row_count != 0) try out.append(allocator, ',');
210 try appendRow(allocator, out, columns, values);
211 row_count += 1;
212 }
213}
214
215fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
216 const terminated = try allocator.allocSentinel(u8, text.len, 0);
217 defer allocator.free(terminated);
218 @memcpy(terminated, text);
219 return j.newStringUtf(terminated.ptr);
220}
221
222fn stringArray(j: Jni, allocator: std.mem.Allocator, values: []const []const u8) !jobject {
223 const string_cls = try j.findClass("java/lang/String");
224 const array = try j.newObjectArray(values.len, string_cls);
225 for (values, 0..) |value, i| {
226 try j.pushLocalFrame(4);
227 defer _ = j.popLocalFrame(null);
228 try j.setObjectArrayElement(array, i, try javaString(j, allocator, value));
229 }
230 return array;
231}
232
233// =============================================================================
234// Tests
235// =============================================================================
236
237const testing = std.testing;
238
239fn boundArgs(json: []const u8) !?[][]const u8 {
240 // An arena, because that is what the native passes — `bindArgs` prints
241 // integers into it and frees nothing individually.
242 var arena = std.heap.ArenaAllocator.init(testing.allocator);
243 defer arena.deinit();
244 const allocator = arena.allocator();
245
246 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
247 defer parsed.deinit();
248
249 const args = try bindArgs(allocator, parsed.value) orelse return null;
250
251 // Copied out, because everything above dies with the arena.
252 const copy = try testing.allocator.alloc([]const u8, args.len);
253 for (args, 0..) |arg, i| copy[i] = try testing.allocator.dupe(u8, arg);
254 return copy;
255}
256
257fn freeArgs(args: [][]const u8) void {
258 for (args) |arg| testing.allocator.free(arg);
259 testing.allocator.free(args);
260}
261
262test "the parameter shapes this serves are the strings getString would produce" {
263 const args = (try boundArgs(
264 \\["a", 1, -2, true, false, null, ""]
265 )).?;
266 defer freeArgs(args);
267
268 try testing.expectEqual(@as(usize, 7), args.len);
269 try testing.expectEqualStrings("a", args[0]);
270 try testing.expectEqualStrings("1", args[1]);
271 try testing.expectEqualStrings("-2", args[2]);
272 try testing.expectEqualStrings("true", args[3]);
273 try testing.expectEqualStrings("false", args[4]);
274 // `JSONArray.getString` on an explicit null gives the text, not SQL NULL.
275 try testing.expectEqualStrings("null", args[5]);
276 try testing.expectEqualStrings("", args[6]);
277}
278
279test "an empty parameter list is served rather than declined" {
280 // `execSQL(sql, arrayOf())` is the common case — a statement with no
281 // placeholders — so this is the path most calls take.
282 const args = (try boundArgs("[]")).?;
283 defer freeArgs(args);
284 try testing.expectEqual(@as(usize, 0), args.len);
285}
286
287test "a shape only org.json would stringify is handed back" {
288 for ([_][]const u8{
289 // Double.toString picks the shortest round-tripping form.
290 \\[1.5]
291 ,
292 \\[1.0]
293 ,
294 // org.json's own printer, key order included.
295 \\[{"a":1}]
296 ,
297 \\[[1,2]]
298 ,
299 // Past i64, where std.json hands back the text and org.json a Double.
300 \\[99999999999999999999]
301 ,
302 // JSONArray(paramsJson) throws on anything that is not an array.
303 \\{"0":"a"}
304 ,
305 \\"a"
306 ,
307 \\null
308 ,
309 }) |payload| {
310 if (try boundArgs(payload)) |args| {
311 freeArgs(args);
312 std.debug.print("payload was served rather than handed back: {s}\n", .{payload});
313 return error.CoercedShapeAccepted;
314 }
315 }
316}
317
318fn renderRow(columns: []const []const u8, values: []const ?[]const u8) ![]u8 {
319 var out: std.ArrayListUnmanaged(u8) = .empty;
320 errdefer out.deinit(testing.allocator);
321 try appendRow(testing.allocator, &out, columns, values);
322 return out.toOwnedSlice(testing.allocator);
323}
324
325test "a row prints its columns in cursor order" {
326 const json = try renderRow(
327 &.{ "id", "name" },
328 &.{ "1", "Ada" },
329 );
330 defer testing.allocator.free(json);
331 try testing.expectEqualStrings("{\"id\":\"1\",\"name\":\"Ada\"}", json);
332}
333
334test "a NULL column is absent from the row rather than null" {
335 // `put(col, null)` removes the mapping. A page cannot tell a NULL column
336 // from one the query never selected, and that is what the shim sends.
337 const json = try renderRow(
338 &.{ "id", "name", "note" },
339 &.{ "1", null, "hi" },
340 );
341 defer testing.allocator.free(json);
342 try testing.expectEqualStrings("{\"id\":\"1\",\"note\":\"hi\"}", json);
343
344 // Including when it is the first column, where a naive comma would show.
345 const leading = try renderRow(&.{ "a", "b" }, &.{ null, "x" });
346 defer testing.allocator.free(leading);
347 try testing.expectEqualStrings("{\"b\":\"x\"}", leading);
348
349 // And when every column is null.
350 const all_null = try renderRow(&.{ "a", "b" }, &.{ null, null });
351 defer testing.allocator.free(all_null);
352 try testing.expectEqualStrings("{}", all_null);
353}
354
355test "a column name or value carrying a quote survives as JSON" {
356 // Column names come from the caller's own SQL — `select 1 as "a\"b"` is
357 // legal — and values are whatever is in the database.
358 const json = try renderRow(
359 &.{"a\"b"},
360 &.{"it's \"fine\""},
361 );
362 defer testing.allocator.free(json);
363
364 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
365 defer parsed.deinit();
366 try testing.expectEqualStrings("it's \"fine\"", parsed.value.object.get("a\"b").?.string);
367}
368
369test "dbExecute resolves with the shim's constant, not a row count" {
370 // `execSQL` returns void, so the 1 is a literal in the Kotlin and stays a
371 // literal here. Asserted as the exact bytes because the page parses them.
372 try testing.expectEqualStrings("{\"rowsAffected\":1}", exec_result);
373
374 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, exec_result, .{});
375 defer parsed.deinit();
376 try testing.expectEqual(@as(i64, 1), parsed.value.object.get("rowsAffected").?.integer);
377}
378
379test "the actions and globals match the shim exactly" {
380 try testing.expectEqualStrings("dbExecute", A.db_execute);
381 try testing.expectEqualStrings("dbQuery", A.db_query);
382 try testing.expectEqualStrings("_craftDbExecResolve", exec_resolve_global);
383 try testing.expectEqualStrings("_craftDbExecReject", exec_reject_global);
384 try testing.expectEqualStrings("_craftDbQueryResolve", query_resolve_global);
385 try testing.expectEqualStrings("_craftDbQueryReject", query_reject_global);
386}
packages/zig/src/jni_runtime.zigmodified+16-0
Changes to packages/zig/src/jni_runtime.zig
@@ -756,6 +756,22 @@ pub const Jni = struct {
756756 return array;
757757 }
758758
759 pub fn arrayLength(self: Self, array: jobject) JniError!usize {
760 const len: *const fn (JNIEnv, jobject) callconv(.c) jsize =
761 @ptrCast(self.table().GetArrayLength orelse return JniError.NotFound);
762 const result = len(self.env, array);
763 try self.check();
764 return @intCast(result);
765 }
766
767 pub fn objectArrayElement(self: Self, array: jobject, index: usize) JniError!jobject {
768 const get: *const fn (JNIEnv, jobject, jsize) callconv(.c) jobject =
769 @ptrCast(self.table().GetObjectArrayElement orelse return JniError.NotFound);
770 const value = get(self.env, array, @intCast(index));
771 try self.check();
772 return value;
773 }
774
759775 pub fn setObjectArrayElement(self: Self, array: jobject, index: usize, value: jobject) JniError!void {
760776 const set: *const fn (JNIEnv, jobject, jsize, jobject) callconv(.c) void =
761777 @ptrCast(self.table().SetObjectArrayElement orelse return JniError.NotFound);
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -48,6 +48,7 @@ const zig_sources = [_][]const u8{
4848 @embedFile("src/bridge_android_haptics.zig"),
4949 @embedFile("src/bridge_android_notifcancel.zig"),
5050 @embedFile("src/bridge_android_calendar.zig"),
51 @embedFile("src/bridge_android_db.zig"),
5152};
5253
5354/// How many spec actions Zig does not serve yet.
@@ -65,8 +66,9 @@ const zig_sources = [_][]const u8{
6566/// notification cancels; 86 with deleteCalendarEvent, the first to answer
6667/// through the reply channel rather than by returning; 85 with
6768/// getCalendarEvents, the first to send the page a payload it built rather
68/// than a constant; 84 with createCalendarEvent, the first to read one.
69const max_not_yet_migrated: usize = 84;
69/// than a constant; 84 with createCalendarEvent, the first to read one; 82
70/// with the database pair, the first to borrow a connection the Kotlin owns.
71const max_not_yet_migrated: usize = 82;
7072
7173/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
7274///