ReviewOS

also looking at this

craft-native/craft

feat(android): getNetworkStatus — the action iOS cannot serve

#146
Merged glennmichael123 wants to merge feat/android-network-status into main
7 files +407 -3
packages/android/templates/CraftBridge.kt.templatemodified+2-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1878,6 +1878,8 @@ class CraftBridge(
18781878
18791879 @JavascriptInterface
18801880 fun getNetworkStatus(): String {
1881 CraftNative.getNetworkStatus(activity)?.let { return it }
1882
18811883 val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
18821884 val network = connectivityManager.activeNetwork
18831885 val capabilities = connectivityManager.getNetworkCapabilities(network)
packages/android/templates/CraftNative.kt.templatemodified+11-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -63,6 +63,7 @@ object CraftNative {
6363 private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
6464 private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
6565 private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
66 private external fun nativeGetNetworkStatus(activity: Activity): String?
6667
6768 /**
6869 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -165,4 +166,14 @@ object CraftNative {
165166 false
166167 }
167168 }
169
170 /** The active network as JSON, or null to use the Kotlin implementation. */
171 fun getNetworkStatus(activity: Activity): String? {
172 if (!isAvailable) return null
173 return try {
174 nativeGetNetworkStatus(activity)
175 } catch (e: UnsatisfiedLinkError) {
176 null
177 }
178 }
168179}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -443,6 +443,9 @@ pub fn build(b: *std.Build) void {
443443 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_intents.zig", .{
444444 .root_source_file = b.path("src/bridge_android_intents.zig"),
445445 });
446 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_network.zig", .{
447 .root_source_file = b.path("src/bridge_android_network.zig"),
448 });
446449 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
447450
448451 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+24-1
Changes to packages/zig/src/android_dispatch.zig
@@ -40,6 +40,7 @@ const device = ;
4040const system = ;
4141const clipboard = ;
4242const intents = ;
43const network = ;
4344
4445const Jni = jni.Jni;
4546
@@ -159,6 +160,11 @@ const natives = [_]jni.JNINativeMethod{
159160 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
160161 .fnPtr = @ptrCast(&nativeShare),
161162 },
163 .{
164 .name = "nativeGetNetworkStatus",
165 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
166 .fnPtr = @ptrCast(&nativeGetNetworkStatus),
167 },
162168};
163169
164170/// How binding went. A value rather than a log line, so every path is
@@ -365,6 +371,23 @@ fn nativeShare(
365371 return jni.JNI_TRUE;
366372}
367373
374fn nativeGetNetworkStatus(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) callconv(.c) jni.jstring {
375 const j = Jni.init(env);
376 var arena = std.heap.ArenaAllocator.init(backing);
377 defer arena.deinit();
378 const allocator = arena.allocator();
379
380 const status = network.read(j, activity) catch |err| {
381 std.log.warn("craft: getNetworkStatus fell through to the shim ({s})", .{@errorName(err)});
382 return null;
383 };
384
385 const json = network.render(allocator, status) catch return null;
386 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
387 @memcpy(owned, json);
388 return j.newStringUtf(owned.ptr) catch null;
389}
390
368391// =============================================================================
369392// Tests
370393// =============================================================================
@@ -476,7 +499,7 @@ test "the registered natives name methods the Kotlin actually declares" {
476499 // A descriptor is checked by the JVM at registration, so a wrong one fails
477500 // at load rather than at call — but only if the *name* matches something.
478501 // These two strings are the contract with CraftBridge.kt.
479 try testing.expectEqual(@as(usize, 7), natives.len);
502 try testing.expectEqual(@as(usize, 8), natives.len);
480503 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
481504
482505 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_network.zigadded+353-0
Changes to packages/zig/src/bridge_android_network.zig
@@ -0,0 +1,353 @@
1//! `getNetworkStatus` on Android.
2//!
3//! ## The action iOS could not serve, for a reason that is genuinely platform-
4//! specific
5//!
6//! `bridge_mobile_device.zig` declares this `.status = .unavailable` on iOS,
7//! and the reason is not a shortcut: no synchronous iOS API can answer
8//! `isConnected` without fabricating it. `NWPathMonitor` delivers on a
9//! background queue, `SCNetworkReachability` is deprecated, cannot see wired
10//! ethernet, and reports the *host Mac's* reachability on the simulator, and
11//! `getifaddrs` knows which interfaces are up but not whether anything is
12//! reachable. The alternative there is `{"isConnected":true,"type":"unknown"}`,
13//! which a caller cannot tell from a real reading.
14//!
15//! Android has the API iOS lacks. `ConnectivityManager.getNetworkCapabilities`
16//! is synchronous and answers null when nothing is connected, so `isConnected`
17//! is a fact here rather than a guess. Same action name, opposite verdict, and
18//! the difference is the platform rather than the effort.
19//!
20//! ## `isConnected` and `type` are not the same question
21//!
22//! `capabilities != null` means *something* is connected. The transport is a
23//! separate lookup, and the Kotlin's `when` falls through to `"none"` when
24//! none of WIFI, CELLULAR or ETHERNET matches — which happens on Bluetooth
25//! tethering, on a VPN-only interface, and on emulators.
26//!
27//! So `{"isConnected":true,"type":"none"}` is a reachable and correct state,
28//! not a contradiction to smooth over. A reading that forced `type` to
29//! `"unknown"` whenever connected, or forced `isConnected` false whenever the
30//! transport was unrecognised, would each be wrong in a way the page could not
31//! detect.
32
33const std = ;
34const jni = ;
35
36const Jni = jni.Jni;
37const jobject = jni.jobject;
38
39pub const A = struct {
40 pub const get_network_status = "getNetworkStatus";
41};
42
43/// The transports the Kotlin's `when` distinguishes, in its order.
44///
45/// Order is contract: `when` takes the first match, so a connection that is
46/// both WIFI and ETHERNET — which a bridged emulator can report — answers
47/// `"wifi"` on both sides only if the checks run in the same sequence.
48pub const Transport = enum {
49 wifi,
50 cellular,
51 ethernet,
52 none,
53
54 pub fn name(self: Transport) []const u8 {
55 return switch (self) {
56 .wifi => "wifi",
57 .cellular => "cellular",
58 .ethernet => "ethernet",
59 .none => "none",
60 };
61 }
62};
63
64pub const NetworkStatus = struct {
65 connected: bool,
66 transport: Transport,
67};
68
69/// The reply bytes, in the Kotlin's `put` order.
70///
71/// `isWifi` and `isCellular` are derived from the transport rather than read
72/// again — that is what the Kotlin does (`connectionType == "wifi"`), and
73/// deriving them separately would let the three disagree.
74pub fn render(allocator: std.mem.Allocator, status: NetworkStatus) ![]u8 {
75 var out: std.ArrayListUnmanaged(u8) = .empty;
76 errdefer out.deinit(allocator);
77
78 try out.appendSlice(allocator, "{\"isConnected\":");
79 try out.appendSlice(allocator, if (status.connected) "true" else "false");
80 try out.appendSlice(allocator, ",\"type\":\"");
81 try out.appendSlice(allocator, status.transport.name());
82 try out.appendSlice(allocator, "\",\"isWifi\":");
83 try out.appendSlice(allocator, if (status.transport == .wifi) "true" else "false");
84 try out.appendSlice(allocator, ",\"isCellular\":");
85 try out.appendSlice(allocator, if (status.transport == .cellular) "true" else "false");
86 try out.append(allocator, '}');
87
88 return out.toOwnedSlice(allocator);
89}
90
91/// Ask `ConnectivityManager` what the active network can do.
92pub fn read(j: Jni, activity: jobject) !NetworkStatus {
93 try j.pushLocalFrame(16);
94 defer _ = j.popLocalFrame(null);
95
96 const context_cls = try j.findClass("android/content/Context");
97 const service_name = try j.staticObjectField(
98 context_cls,
99 try j.staticFieldId(context_cls, "CONNECTIVITY_SERVICE", "Ljava/lang/String;"),
100 );
101
102 const activity_cls = try j.objectClass(activity);
103 const manager = try j.callObjectMethodA(
104 activity,
105 try j.methodId(activity_cls, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"),
106 &.{.{ .l = service_name }},
107 );
108
109 const manager_cls = try j.objectClass(manager);
110 const network = try j.callObjectMethod(
111 manager,
112 try j.methodId(manager_cls, "getActiveNetwork", "()Landroid/net/Network;"),
113 );
114
115 // `getNetworkCapabilities(null)` is legal and answers null, which is the
116 // same "not connected" the Kotlin reads — so the null network needs no
117 // branch of its own here, only the null capabilities below.
118 const capabilities = try j.callObjectMethodA(
119 manager,
120 try j.methodId(
121 manager_cls,
122 "getNetworkCapabilities",
123 "(Landroid/net/Network;)Landroid/net/NetworkCapabilities;",
124 ),
125 &.{.{ .l = network }},
126 );
127 if (capabilities == null) return .{ .connected = false, .transport = .none };
128
129 const caps_cls = try j.findClass("android/net/NetworkCapabilities");
130 const has_transport = try j.methodId(caps_cls, "hasTransport", "(I)Z");
131
132 // Checked in the Kotlin's order, and stopping at the first match, because
133 // `when` does.
134 const probes = [_]struct { field: [*:0]const u8, transport: Transport }{
135 .{ .field = "TRANSPORT_WIFI", .transport = .wifi },
136 .{ .field = "TRANSPORT_CELLULAR", .transport = .cellular },
137 .{ .field = "TRANSPORT_ETHERNET", .transport = .ethernet },
138 };
139
140 for (probes) |probe| {
141 const value = try j.staticIntField(
142 caps_cls,
143 try j.staticFieldId(caps_cls, probe.field, "I"),
144 );
145 if (try j.callBooleanMethodA(capabilities, has_transport, &.{.{ .i = value }})) {
146 return .{ .connected = true, .transport = probe.transport };
147 }
148 }
149
150 // Connected over something else — Bluetooth tethering, a VPN-only
151 // interface, an emulator. See the module comment: this is a real state.
152 return .{ .connected = true, .transport = .none };
153}
154
155// =============================================================================
156// Tests
157// =============================================================================
158
159const testing = std.testing;
160
161fn parsed(status: NetworkStatus) !std.json.Parsed(std.json.Value) {
162 const json = try render(testing.allocator, status);
163 defer testing.allocator.free(json);
164 return std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
165}
166
167test "the reply carries every key the Kotlin puts" {
168 var p = try parsed(.{ .connected = true, .transport = .wifi });
169 defer p.deinit();
170 const obj = p.value.object;
171
172 try testing.expectEqual(@as(usize, 4), obj.count());
173 try testing.expectEqual(true, obj.get("isConnected").?.bool);
174 try testing.expectEqualStrings("wifi", obj.get("type").?.string);
175 try testing.expectEqual(true, obj.get("isWifi").?.bool);
176 try testing.expectEqual(false, obj.get("isCellular").?.bool);
177}
178
179test "isWifi and isCellular follow the transport rather than being read twice" {
180 // The Kotlin derives both from `connectionType`, so they cannot disagree
181 // with `type`. Deriving them independently is the bug this pins.
182 {
183 var p = try parsed(.{ .connected = true, .transport = .cellular });
184 defer p.deinit();
185 try testing.expectEqualStrings("cellular", p.value.object.get("type").?.string);
186 try testing.expectEqual(false, p.value.object.get("isWifi").?.bool);
187 try testing.expectEqual(true, p.value.object.get("isCellular").?.bool);
188 }
189 {
190 var p = try parsed(.{ .connected = true, .transport = .ethernet });
191 defer p.deinit();
192 try testing.expectEqualStrings("ethernet", p.value.object.get("type").?.string);
193 try testing.expectEqual(false, p.value.object.get("isWifi").?.bool);
194 try testing.expectEqual(false, p.value.object.get("isCellular").?.bool);
195 }
196}
197
198test "connected over an unrecognised transport is a real state, not a contradiction" {
199 // Bluetooth tethering, a VPN-only interface, most emulators. The Kotlin's
200 // `when` falls through to "none" while `capabilities != null` stays true,
201 // and a reading that "corrected" either half would be wrong in a way the
202 // page could not detect.
203 var p = try parsed(.{ .connected = true, .transport = .none });
204 defer p.deinit();
205 try testing.expectEqual(true, p.value.object.get("isConnected").?.bool);
206 try testing.expectEqualStrings("none", p.value.object.get("type").?.string);
207}
208
209test "no connection reports both halves as absent" {
210 var p = try parsed(.{ .connected = false, .transport = .none });
211 defer p.deinit();
212 try testing.expectEqual(false, p.value.object.get("isConnected").?.bool);
213 try testing.expectEqualStrings("none", p.value.object.get("type").?.string);
214 try testing.expectEqual(false, p.value.object.get("isWifi").?.bool);
215 try testing.expectEqual(false, p.value.object.get("isCellular").?.bool);
216}
217
218test "the transport names are the strings the page compares against" {
219 // `craft.d.ts` types this as a union of these literals, and pages switch
220 // on them. A rename here is a silent behaviour change for every consumer.
221 try testing.expectEqualStrings("wifi", Transport.wifi.name());
222 try testing.expectEqualStrings("cellular", Transport.cellular.name());
223 try testing.expectEqualStrings("ethernet", Transport.ethernet.name());
224 try testing.expectEqualStrings("none", Transport.none.name());
225}
226
227test "the action name matches the Kotlin method exactly" {
228 try testing.expectEqualStrings("getNetworkStatus", A.get_network_status);
229}
230
231// --- A fake ConnectivityManager -------------------------------------------
232//
233// `read` makes the transport claim that the module comment calls contract:
234// the checks run in the Kotlin's order and stop at the first match. Nothing
235// above tests that, because `render` never sees it — so the fake is here to
236// make the claim assertable rather than decorative.
237
238var fake_storage: [8]u8 = undefined;
239var fake_capabilities_null = false;
240/// Which `TRANSPORT_*` values `hasTransport` answers true for. The fake
241/// returns the field name's hash as the constant, so the set is by name.
242var fake_transports: []const []const u8 = &.{};
243var fake_last_field: [*:0]const u8 = "";
244
245fn fobj(tag: usize) jobject {
246 return @ptrCast(&fake_storage[tag]);
247}
248
249fn transportValue(name: []const u8) jni.jint {
250 // Stable small integers standing in for the real constants. Their values
251 // do not matter; that each is distinct does.
252 if (std.mem.eql(u8, name, "TRANSPORT_WIFI")) return 1;
253 if (std.mem.eql(u8, name, "TRANSPORT_CELLULAR")) return 0;
254 if (std.mem.eql(u8, name, "TRANSPORT_ETHERNET")) return 3;
255 return -1;
256}
257
258fn nFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
259 return fobj(0);
260}
261fn nObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass {
262 return fobj(0);
263}
264fn nStaticFieldId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID {
265 fake_last_field = name;
266 return @ptrCast(@constCast(name));
267}
268fn nStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject {
269 return fobj(1);
270}
271fn nStaticIntField(_: jni.JNIEnv, _: jni.jclass, id: jni.jfieldID) callconv(.c) jni.jint {
272 return transportValue(std.mem.span(@as([*:0]const u8, @ptrCast(id.?))));
273}
274fn nMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
275 return @ptrCast(@constCast(name));
276}
277fn nCallObjectMethod(_: jni.JNIEnv, _: jobject, _: jni.jmethodID) callconv(.c) jobject {
278 return fobj(2); // getActiveNetwork
279}
280fn nCallObjectMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
281 const name = std.mem.span(@as([*:0]const u8, @ptrCast(id.?)));
282 if (std.mem.eql(u8, name, "getNetworkCapabilities"))
283 return if (fake_capabilities_null) null else fobj(3);
284 return fobj(4); // getSystemService
285}
286fn nCallBooleanMethodA(_: jni.JNIEnv, _: jobject, _: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) jni.jboolean {
287 for (fake_transports) |t| {
288 if (transportValue(t) == args[0].i) return jni.JNI_TRUE;
289 }
290 return jni.JNI_FALSE;
291}
292fn nExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
293 return null;
294}
295fn nPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
296 return 0;
297}
298fn nPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject {
299 return keep;
300}
301
302fn readWith(transports: []const []const u8, capabilities_null: bool) !NetworkStatus {
303 fake_transports = transports;
304 fake_capabilities_null = capabilities_null;
305
306 var table = std.mem.zeroes(jni.JNINativeInterface);
307 table.FindClass = @ptrCast(&nFindClass);
308 table.GetObjectClass = @ptrCast(&nObjectClass);
309 table.GetStaticFieldID = @ptrCast(&nStaticFieldId);
310 table.GetStaticObjectField = @ptrCast(&nStaticObjectField);
311 table.GetStaticIntField = @ptrCast(&nStaticIntField);
312 table.GetMethodID = @ptrCast(&nMethodId);
313 table.CallObjectMethod = @ptrCast(&nCallObjectMethod);
314 table.CallObjectMethodA = @ptrCast(&nCallObjectMethodA);
315 table.CallBooleanMethodA = @ptrCast(&nCallBooleanMethodA);
316 table.ExceptionOccurred = @ptrCast(&nExceptionOccurred);
317 table.PushLocalFrame = @ptrCast(&nPush);
318 table.PopLocalFrame = @ptrCast(&nPop);
319
320 const ptr: *const jni.JNINativeInterface = &table;
321 return read(Jni.init(&ptr), fobj(5));
322}
323
324test "each transport is recognised as itself" {
325 try testing.expectEqual(Transport.wifi, (try readWith(&.{"TRANSPORT_WIFI"}, false)).transport);
326 try testing.expectEqual(Transport.cellular, (try readWith(&.{"TRANSPORT_CELLULAR"}, false)).transport);
327 try testing.expectEqual(Transport.ethernet, (try readWith(&.{"TRANSPORT_ETHERNET"}, false)).transport);
328}
329
330test "a connection with two transports answers the first the Kotlin would check" {
331 // The claim the module comment makes. `when` takes its first true branch,
332 // so a bridged emulator reporting both WIFI and ETHERNET must answer
333 // "wifi" — and it only does if the probes run in the Kotlin's order.
334 // Reordering them here changes the answer without changing anything a
335 // reader would look at.
336 const both = try readWith(&.{ "TRANSPORT_ETHERNET", "TRANSPORT_WIFI" }, false);
337 try testing.expectEqual(Transport.wifi, both.transport);
338
339 const cell_and_eth = try readWith(&.{ "TRANSPORT_ETHERNET", "TRANSPORT_CELLULAR" }, false);
340 try testing.expectEqual(Transport.cellular, cell_and_eth.transport);
341}
342
343test "null capabilities is not connected, and an unknown transport still is" {
344 const offline = try readWith(&.{}, true);
345 try testing.expectEqual(false, offline.connected);
346 try testing.expectEqual(Transport.none, offline.transport);
347
348 // Capabilities present, no transport matched — Bluetooth tethering or a
349 // VPN-only interface. Connected, and honestly typed "none".
350 const odd = try readWith(&.{}, false);
351 try testing.expectEqual(true, odd.connected);
352 try testing.expectEqual(Transport.none, odd.transport);
353}
packages/zig/src/jni_runtime.zigmodified+10-0
Changes to packages/zig/src/jni_runtime.zig
@@ -624,6 +624,16 @@ pub const Jni = struct {
624624 return result;
625625 }
626626
627 pub fn callBooleanMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!bool {
628 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jboolean =
629 @ptrCast(self.table().CallBooleanMethodA orelse return JniError.NotFound);
630 const result = call(self.env, obj, id, args.ptr);
631 try self.check();
632 // `jboolean` is a `u8` and the JVM only ever sets it to 0 or 1, but a
633 // `!= 0` rather than `== 1` is what the spec licenses.
634 return result != JNI_FALSE;
635 }
636
627637 pub fn callVoidMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!void {
628638 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) void =
629639 @ptrCast(self.table().CallVoidMethodA orelse return JniError.NotFound);
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -43,6 +43,7 @@ const zig_sources = [_][]const u8{
4343 @embedFile("src/bridge_android_system.zig"),
4444 @embedFile("src/bridge_android_clipboard.zig"),
4545 @embedFile("src/bridge_android_intents.zig"),
46 @embedFile("src/bridge_android_network.zig"),
4647};
4748
4849/// How many spec actions Zig does not serve yet.
@@ -53,8 +54,9 @@ const zig_sources = [_][]const u8{
5354///
5455/// History: 103 at the seam; 102 with getDeviceInfo; 100 with getMemoryUsage
5556/// and log — the first two that need no Activity at all; 98 with the
56/// clipboard pair, the first that do; 96 with openURL and share.
57const max_not_yet_migrated: usize = 96;
57/// clipboard pair, the first that do; 96 with openURL and share; 95 with
58/// getNetworkStatus, which iOS declares unavailable and Android can answer.
59const max_not_yet_migrated: usize = 95;
5860
5961/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
6062///