also looking at this
feat(android): getNetworkStatus — the action iOS cannot serve
#146
7 files
+407
-3
| @@ -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 | ||
| 33 | const std = ; | |
| 34 | const jni = ; | |
| 35 | ||
| 36 | const Jni = jni.Jni; | |
| 37 | const jobject = jni.jobject; | |
| 38 | ||
| 39 | pub 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. | |
| 48 | pub 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 | ||
| 64 | pub 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. | |
| 74 | pub 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. | |
| 92 | pub 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 | ||
| 159 | const testing = std.testing; | |
| 160 | ||
| 161 | fn 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 | ||
| 167 | test "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 | ||
| 179 | test "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 | ||
| 198 | test "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 | ||
| 209 | test "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 | ||
| 218 | test "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 | ||
| 227 | test "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 | ||
| 238 | var fake_storage: [8]u8 = undefined; | |
| 239 | var 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. | |
| 242 | var fake_transports: []const []const u8 = &.{}; | |
| 243 | var fake_last_field: [*:0]const u8 = ""; | |
| 244 | ||
| 245 | fn fobj(tag: usize) jobject { | |
| 246 | return @ptrCast(&fake_storage[tag]); | |
| 247 | } | |
| 248 | ||
| 249 | fn 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 | ||
| 258 | fn nFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass { | |
| 259 | return fobj(0); | |
| 260 | } | |
| 261 | fn nObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass { | |
| 262 | return fobj(0); | |
| 263 | } | |
| 264 | fn 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 | } | |
| 268 | fn nStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject { | |
| 269 | return fobj(1); | |
| 270 | } | |
| 271 | fn 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 | } | |
| 274 | fn nMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID { | |
| 275 | return @ptrCast(@constCast(name)); | |
| 276 | } | |
| 277 | fn nCallObjectMethod(_: jni.JNIEnv, _: jobject, _: jni.jmethodID) callconv(.c) jobject { | |
| 278 | return fobj(2); // getActiveNetwork | |
| 279 | } | |
| 280 | fn 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 | } | |
| 286 | fn 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 | } | |
| 292 | fn nExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject { | |
| 293 | return null; | |
| 294 | } | |
| 295 | fn nPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint { | |
| 296 | return 0; | |
| 297 | } | |
| 298 | fn nPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject { | |
| 299 | return keep; | |
| 300 | } | |
| 301 | ||
| 302 | fn 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 | ||
| 324 | test "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 | ||
| 330 | test "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 | ||
| 343 | test "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 | } | |