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/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}