rn-network-quality 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +899 -0
- package/android/build.gradle +60 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/rnnetworkquality/CellularInfo.kt +34 -0
- package/android/src/main/java/com/rnnetworkquality/DownloadPolicy.kt +26 -0
- package/android/src/main/java/com/rnnetworkquality/Mappers.kt +106 -0
- package/android/src/main/java/com/rnnetworkquality/NetworkMonitor.kt +263 -0
- package/android/src/main/java/com/rnnetworkquality/NetworkProbe.kt +668 -0
- package/android/src/main/java/com/rnnetworkquality/NetworkQualityModule.kt +287 -0
- package/android/src/main/java/com/rnnetworkquality/NetworkQualityPackage.kt +25 -0
- package/android/src/main/java/com/rnnetworkquality/NetworkSnapshot.kt +83 -0
- package/android/src/main/java/com/rnnetworkquality/SnapshotBuilder.kt +92 -0
- package/android/src/main/java/com/rnnetworkquality/Throttler.kt +100 -0
- package/ios/CellularInfo.swift +47 -0
- package/ios/NetworkProbe.swift +391 -0
- package/ios/NetworkQuality.h +8 -0
- package/ios/NetworkQuality.mm +88 -0
- package/ios/NetworkQualityImpl.swift +371 -0
- package/ios/PathSnapshot.swift +109 -0
- package/ios/PrivacyInfo.xcprivacy +23 -0
- package/ios/Throttler.swift +174 -0
- package/lib/module/NativeNetworkQuality.js +5 -0
- package/lib/module/NativeNetworkQuality.js.map +1 -0
- package/lib/module/classify.js +126 -0
- package/lib/module/classify.js.map +1 -0
- package/lib/module/constants.js +52 -0
- package/lib/module/constants.js.map +1 -0
- package/lib/module/errors.js +18 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/hooks.js +78 -0
- package/lib/module/hooks.js.map +1 -0
- package/lib/module/index.js +19 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/manager.js +595 -0
- package/lib/module/manager.js.map +1 -0
- package/lib/module/normalize.js +35 -0
- package/lib/module/normalize.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeNetworkQuality.d.ts +50 -0
- package/lib/typescript/src/NativeNetworkQuality.d.ts.map +1 -0
- package/lib/typescript/src/classify.d.ts +12 -0
- package/lib/typescript/src/classify.d.ts.map +1 -0
- package/lib/typescript/src/constants.d.ts +15 -0
- package/lib/typescript/src/constants.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +13 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/hooks.d.ts +18 -0
- package/lib/typescript/src/hooks.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +13 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/manager.d.ts +98 -0
- package/lib/typescript/src/manager.d.ts.map +1 -0
- package/lib/typescript/src/normalize.d.ts +5 -0
- package/lib/typescript/src/normalize.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +171 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/mock.js +318 -0
- package/package.json +161 -0
- package/rn-network-quality.podspec +32 -0
- package/src/NativeNetworkQuality.ts +58 -0
- package/src/classify.ts +208 -0
- package/src/constants.ts +48 -0
- package/src/errors.ts +23 -0
- package/src/hooks.ts +110 -0
- package/src/index.tsx +25 -0
- package/src/manager.ts +891 -0
- package/src/normalize.ts +81 -0
- package/src/types.ts +207 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
private enum PathLookupResult {
|
|
5
|
+
case path(NWPath)
|
|
6
|
+
case timedOut
|
|
7
|
+
case cancelled
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@objc(NetworkQualityImpl)
|
|
11
|
+
public final class NetworkQualityImpl: NSObject {
|
|
12
|
+
@objc public var onChange: (([String: Any]) -> Void)?
|
|
13
|
+
|
|
14
|
+
private let queue = DispatchQueue(label: "com.rnnetworkquality.monitor")
|
|
15
|
+
private let queueKey = DispatchSpecificKey<UInt8>()
|
|
16
|
+
private let cellularInfo = CellularInfo()
|
|
17
|
+
private var monitor: NWPathMonitor?
|
|
18
|
+
private var throttler: Throttler?
|
|
19
|
+
private var latestSnapshot: [String: Any]?
|
|
20
|
+
private var activeProbes: [UUID: NetworkProbe] = [:]
|
|
21
|
+
private var activeOneShots: [UUID: OneShotRequest] = [:]
|
|
22
|
+
private var invalidated = false
|
|
23
|
+
|
|
24
|
+
public override init() {
|
|
25
|
+
super.init()
|
|
26
|
+
queue.setSpecific(key: queueKey, value: 1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@objc(getCurrentStateWithResolve:reject:)
|
|
30
|
+
public func getCurrentState(
|
|
31
|
+
resolve: @escaping (Any?) -> Void,
|
|
32
|
+
reject: @escaping (String?, String?, Error?) -> Void
|
|
33
|
+
) {
|
|
34
|
+
queue.async { [weak self] in
|
|
35
|
+
guard let self else { return }
|
|
36
|
+
guard !invalidated else {
|
|
37
|
+
reject("E_PROBE_FAILED", "NetworkQuality has been invalidated.", nil)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if let monitor {
|
|
41
|
+
let snapshot = latestSnapshot
|
|
42
|
+
?? PathSnapshot.make(path: monitor.currentPath, cellularInfo: cellularInfo)
|
|
43
|
+
latestSnapshot = snapshot
|
|
44
|
+
resolve(snapshot)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
withOneShotPath(timeoutMs: 2_000) { [weak self] result in
|
|
49
|
+
guard let self else { return }
|
|
50
|
+
switch result {
|
|
51
|
+
case let .path(path):
|
|
52
|
+
resolve(PathSnapshot.make(path: path, cellularInfo: cellularInfo))
|
|
53
|
+
case .timedOut:
|
|
54
|
+
resolve(PathSnapshot.unknownDisconnected())
|
|
55
|
+
case .cancelled:
|
|
56
|
+
reject(
|
|
57
|
+
"E_PROBE_FAILED",
|
|
58
|
+
"The network state request was cancelled because monitoring stopped.",
|
|
59
|
+
nil
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@objc(startMonitoringWithThrottleMs:bandwidthChangeThresholdPct:)
|
|
67
|
+
public func startMonitoring(
|
|
68
|
+
throttleMs: NSNumber,
|
|
69
|
+
bandwidthChangeThresholdPct: NSNumber
|
|
70
|
+
) {
|
|
71
|
+
queue.async { [weak self] in
|
|
72
|
+
guard let self else { return }
|
|
73
|
+
guard !invalidated else { return }
|
|
74
|
+
let throttle = max(0, throttleMs.doubleValue)
|
|
75
|
+
let threshold = max(0, bandwidthChangeThresholdPct.doubleValue)
|
|
76
|
+
|
|
77
|
+
if let throttler {
|
|
78
|
+
throttler.update(
|
|
79
|
+
throttleMs: throttle,
|
|
80
|
+
bandwidthChangeThresholdPct: threshold
|
|
81
|
+
)
|
|
82
|
+
} else {
|
|
83
|
+
throttler = makeThrottler(
|
|
84
|
+
throttleMs: throttle,
|
|
85
|
+
bandwidthChangeThresholdPct: threshold
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
guard monitor == nil else { return }
|
|
90
|
+
let pathMonitor = NWPathMonitor()
|
|
91
|
+
monitor = pathMonitor
|
|
92
|
+
pathMonitor.pathUpdateHandler = { [weak self] path in
|
|
93
|
+
self?.handle(path: path)
|
|
94
|
+
}
|
|
95
|
+
pathMonitor.start(queue: queue)
|
|
96
|
+
handle(path: pathMonitor.currentPath)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@objc public func stopMonitoring() {
|
|
101
|
+
queue.async { [weak self] in
|
|
102
|
+
self?.stopMonitoringOnQueue()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@objc(probeWithLatencyUrl:downloadUrl:latencySamples:timeoutMs:downloadMaxDurationMs:downloadMaxBytes:resolve:reject:)
|
|
107
|
+
public func probe(
|
|
108
|
+
latencyUrl: String,
|
|
109
|
+
downloadUrl: String?,
|
|
110
|
+
latencySamples: NSNumber,
|
|
111
|
+
timeoutMs: NSNumber,
|
|
112
|
+
downloadMaxDurationMs: NSNumber,
|
|
113
|
+
downloadMaxBytes: NSNumber,
|
|
114
|
+
resolve: @escaping (Any?) -> Void,
|
|
115
|
+
reject: @escaping (String?, String?, Error?) -> Void
|
|
116
|
+
) {
|
|
117
|
+
let probeStartedAt = NetworkProbe.monotonicMilliseconds()
|
|
118
|
+
guard let latencyURL = NetworkProbe.validatedURL(latencyUrl) else {
|
|
119
|
+
reject("E_INVALID_URL", "latencyUrl must be a valid HTTP or HTTPS URL.", nil)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let downloadURL: URL?
|
|
124
|
+
if let downloadUrl {
|
|
125
|
+
guard let validated = NetworkProbe.validatedURL(downloadUrl) else {
|
|
126
|
+
reject("E_INVALID_URL", "downloadUrl must be a valid HTTP or HTTPS URL.", nil)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
downloadURL = validated
|
|
130
|
+
} else {
|
|
131
|
+
downloadURL = nil
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let latencySampleValue = latencySamples.doubleValue
|
|
135
|
+
guard
|
|
136
|
+
latencySampleValue.isFinite,
|
|
137
|
+
latencySampleValue.rounded(.towardZero) == latencySampleValue,
|
|
138
|
+
latencySampleValue >= 1,
|
|
139
|
+
latencySampleValue <= 100
|
|
140
|
+
else {
|
|
141
|
+
reject(
|
|
142
|
+
"E_PROBE_FAILED",
|
|
143
|
+
"latencySamples must be an integer between 1 and 100.",
|
|
144
|
+
nil
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
let timeoutValue = timeoutMs.doubleValue
|
|
149
|
+
guard timeoutValue.isFinite, timeoutValue > 0, timeoutValue <= 2_147_483_647
|
|
150
|
+
else {
|
|
151
|
+
reject(
|
|
152
|
+
"E_PROBE_FAILED",
|
|
153
|
+
"timeoutMs must be greater than 0 and at most 2147483647.",
|
|
154
|
+
nil
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
let downloadMaxDurationValue = downloadMaxDurationMs.doubleValue
|
|
159
|
+
guard
|
|
160
|
+
downloadMaxDurationValue.isFinite,
|
|
161
|
+
downloadMaxDurationValue > 0,
|
|
162
|
+
downloadMaxDurationValue <= 2_147_483_647
|
|
163
|
+
else {
|
|
164
|
+
reject(
|
|
165
|
+
"E_PROBE_FAILED",
|
|
166
|
+
"downloadMaxDurationMs must be greater than 0 and at most 2147483647.",
|
|
167
|
+
nil
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
let downloadMaxBytesValue = downloadMaxBytes.doubleValue
|
|
172
|
+
guard
|
|
173
|
+
downloadMaxBytesValue.isFinite,
|
|
174
|
+
downloadMaxBytesValue.rounded(.towardZero) == downloadMaxBytesValue,
|
|
175
|
+
downloadMaxBytesValue > 0,
|
|
176
|
+
downloadMaxBytesValue <= 2_147_483_647
|
|
177
|
+
else {
|
|
178
|
+
reject(
|
|
179
|
+
"E_PROBE_FAILED",
|
|
180
|
+
"downloadMaxBytes must be an integer between 1 and 2147483647.",
|
|
181
|
+
nil
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
queue.async { [weak self] in
|
|
187
|
+
guard let self else { return }
|
|
188
|
+
guard !invalidated else {
|
|
189
|
+
reject("E_PROBE_FAILED", "NetworkQuality has been invalidated.", nil)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
let pathBudgetMs = timeoutValue
|
|
193
|
+
- (NetworkProbe.monotonicMilliseconds() - probeStartedAt)
|
|
194
|
+
guard pathBudgetMs > 0 else {
|
|
195
|
+
reject("E_PROBE_TIMEOUT", "The network path check exceeded the probe timeout.", nil)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
currentPath(timeoutMs: pathBudgetMs) { [weak self] result in
|
|
199
|
+
guard let self else { return }
|
|
200
|
+
guard !invalidated else {
|
|
201
|
+
reject("E_PROBE_FAILED", "NetworkQuality has been invalidated.", nil)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let path: NWPath
|
|
206
|
+
switch result {
|
|
207
|
+
case let .path(value):
|
|
208
|
+
path = value
|
|
209
|
+
case .timedOut:
|
|
210
|
+
reject(
|
|
211
|
+
"E_PROBE_TIMEOUT",
|
|
212
|
+
"The network path check exceeded the probe timeout.",
|
|
213
|
+
nil
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
case .cancelled:
|
|
217
|
+
reject("E_PROBE_FAILED", "The network probe was cancelled.", nil)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
guard
|
|
222
|
+
NetworkProbe.monotonicMilliseconds() - probeStartedAt < timeoutValue
|
|
223
|
+
else {
|
|
224
|
+
reject("E_PROBE_TIMEOUT", "The network path check exceeded the probe timeout.", nil)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
guard path.status == .satisfied else {
|
|
228
|
+
reject("E_OFFLINE", "No connected network is available for probing.", nil)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let identifier = UUID()
|
|
233
|
+
let networkProbe = NetworkProbe(
|
|
234
|
+
latencyURL: latencyURL,
|
|
235
|
+
downloadURL: downloadURL,
|
|
236
|
+
latencySamples: Int(latencySampleValue),
|
|
237
|
+
timeoutMs: timeoutValue,
|
|
238
|
+
downloadMaxDurationMs: downloadMaxDurationValue,
|
|
239
|
+
downloadMaxBytes: Int(downloadMaxBytesValue),
|
|
240
|
+
startedAt: probeStartedAt
|
|
241
|
+
) { [weak self] result in
|
|
242
|
+
self?.queue.async {
|
|
243
|
+
self?.activeProbes.removeValue(forKey: identifier)
|
|
244
|
+
}
|
|
245
|
+
switch result {
|
|
246
|
+
case let .success(value):
|
|
247
|
+
resolve(value)
|
|
248
|
+
case let .failure(failure):
|
|
249
|
+
reject(failure.code, failure.message, failure.error)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
activeProbes[identifier] = networkProbe
|
|
253
|
+
networkProbe.start()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@objc public func invalidate() {
|
|
259
|
+
performSynchronouslyOnQueue { [weak self] in
|
|
260
|
+
guard let self else { return }
|
|
261
|
+
guard !invalidated else { return }
|
|
262
|
+
invalidated = true
|
|
263
|
+
stopMonitoringOnQueue()
|
|
264
|
+
onChange = nil
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private func makeThrottler(
|
|
269
|
+
throttleMs: Double,
|
|
270
|
+
bandwidthChangeThresholdPct: Double
|
|
271
|
+
) -> Throttler {
|
|
272
|
+
Throttler(
|
|
273
|
+
throttleMs: throttleMs,
|
|
274
|
+
bandwidthChangeThresholdPct: bandwidthChangeThresholdPct,
|
|
275
|
+
clock: {
|
|
276
|
+
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000
|
|
277
|
+
},
|
|
278
|
+
scheduler: { [weak self] delayMs, action in
|
|
279
|
+
let item = DispatchWorkItem(block: action)
|
|
280
|
+
self?.queue.asyncAfter(deadline: .now() + delayMs / 1_000, execute: item)
|
|
281
|
+
return item
|
|
282
|
+
},
|
|
283
|
+
emit: { [weak self] snapshot in
|
|
284
|
+
self?.onChange?(snapshot)
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private func handle(path: NWPath) {
|
|
290
|
+
let snapshot = PathSnapshot.make(path: path, cellularInfo: cellularInfo)
|
|
291
|
+
latestSnapshot = snapshot
|
|
292
|
+
throttler?.submit(snapshot)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private func stopMonitoringOnQueue() {
|
|
296
|
+
monitor?.pathUpdateHandler = nil
|
|
297
|
+
monitor?.cancel()
|
|
298
|
+
monitor = nil
|
|
299
|
+
latestSnapshot = nil
|
|
300
|
+
throttler?.reset()
|
|
301
|
+
throttler = nil
|
|
302
|
+
activeProbes.values.forEach { $0.cancel() }
|
|
303
|
+
activeProbes.removeAll()
|
|
304
|
+
Array(activeOneShots.keys).forEach {
|
|
305
|
+
finishOneShot($0, result: .cancelled)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private func currentPath(
|
|
310
|
+
timeoutMs: Double,
|
|
311
|
+
completion: @escaping (PathLookupResult) -> Void
|
|
312
|
+
) {
|
|
313
|
+
if let monitor {
|
|
314
|
+
completion(.path(monitor.currentPath))
|
|
315
|
+
} else {
|
|
316
|
+
withOneShotPath(timeoutMs: timeoutMs, completion: completion)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private func withOneShotPath(
|
|
321
|
+
timeoutMs: Double,
|
|
322
|
+
completion: @escaping (PathLookupResult) -> Void
|
|
323
|
+
) {
|
|
324
|
+
let oneShot = NWPathMonitor()
|
|
325
|
+
let identifier = UUID()
|
|
326
|
+
let timeoutItem = DispatchWorkItem { [weak self] in
|
|
327
|
+
self?.finishOneShot(identifier, result: .timedOut)
|
|
328
|
+
}
|
|
329
|
+
activeOneShots[identifier] = OneShotRequest(
|
|
330
|
+
monitor: oneShot,
|
|
331
|
+
timeoutItem: timeoutItem,
|
|
332
|
+
completion: completion
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
oneShot.pathUpdateHandler = { [weak self] path in
|
|
336
|
+
self?.finishOneShot(identifier, result: .path(path))
|
|
337
|
+
}
|
|
338
|
+
oneShot.start(queue: queue)
|
|
339
|
+
queue.asyncAfter(
|
|
340
|
+
deadline: .now() + max(0, timeoutMs) / 1_000,
|
|
341
|
+
execute: timeoutItem
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private func finishOneShot(
|
|
346
|
+
_ identifier: UUID,
|
|
347
|
+
result: PathLookupResult
|
|
348
|
+
) {
|
|
349
|
+
guard let request = activeOneShots.removeValue(forKey: identifier) else {
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
request.monitor.pathUpdateHandler = nil
|
|
353
|
+
request.monitor.cancel()
|
|
354
|
+
request.timeoutItem.cancel()
|
|
355
|
+
request.completion(result)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private func performSynchronouslyOnQueue(_ action: () -> Void) {
|
|
359
|
+
if DispatchQueue.getSpecific(key: queueKey) != nil {
|
|
360
|
+
action()
|
|
361
|
+
} else {
|
|
362
|
+
queue.sync(execute: action)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private struct OneShotRequest {
|
|
368
|
+
let monitor: NWPathMonitor
|
|
369
|
+
let timeoutItem: DispatchWorkItem
|
|
370
|
+
let completion: (PathLookupResult) -> Void
|
|
371
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
enum PathSnapshot {
|
|
5
|
+
static let comparableKeys = [
|
|
6
|
+
"isConnected",
|
|
7
|
+
"isValidated",
|
|
8
|
+
"isCaptivePortal",
|
|
9
|
+
"transport",
|
|
10
|
+
"isVpn",
|
|
11
|
+
"isExpensive",
|
|
12
|
+
"isConstrained",
|
|
13
|
+
"isRoaming",
|
|
14
|
+
"downlinkKbps",
|
|
15
|
+
"uplinkKbps",
|
|
16
|
+
"signalStrength",
|
|
17
|
+
"cellularGeneration",
|
|
18
|
+
"supportsIPv4",
|
|
19
|
+
"supportsIPv6",
|
|
20
|
+
"supportsDNS",
|
|
21
|
+
"unsatisfiedReason",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
static func make(path: NWPath, cellularInfo: CellularInfo) -> [String: Any] {
|
|
25
|
+
let isConnected = path.status == .satisfied
|
|
26
|
+
let transport = transport(path: path, isConnected: isConnected)
|
|
27
|
+
let isCellular = transport == "cellular"
|
|
28
|
+
|
|
29
|
+
return [
|
|
30
|
+
"isConnected": isConnected,
|
|
31
|
+
"isValidated": NSNull(),
|
|
32
|
+
"isCaptivePortal": NSNull(),
|
|
33
|
+
"transport": transport,
|
|
34
|
+
"isVpn": NSNull(),
|
|
35
|
+
"isExpensive": path.isExpensive,
|
|
36
|
+
"isConstrained": path.isConstrained,
|
|
37
|
+
"isRoaming": NSNull(),
|
|
38
|
+
"downlinkKbps": NSNull(),
|
|
39
|
+
"uplinkKbps": NSNull(),
|
|
40
|
+
"signalStrength": NSNull(),
|
|
41
|
+
"cellularGeneration": nullable(cellularInfo.generation(isCellular: isCellular)),
|
|
42
|
+
"supportsIPv4": path.supportsIPv4,
|
|
43
|
+
"supportsIPv6": path.supportsIPv6,
|
|
44
|
+
"supportsDNS": path.supportsDNS,
|
|
45
|
+
"unsatisfiedReason": nullable(unsatisfiedReason(path: path)),
|
|
46
|
+
"timestamp": Date().timeIntervalSince1970 * 1_000,
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static func unknownDisconnected() -> [String: Any] {
|
|
51
|
+
[
|
|
52
|
+
"isConnected": false,
|
|
53
|
+
"isValidated": NSNull(),
|
|
54
|
+
"isCaptivePortal": NSNull(),
|
|
55
|
+
"transport": "unknown",
|
|
56
|
+
"isVpn": NSNull(),
|
|
57
|
+
"isExpensive": false,
|
|
58
|
+
"isConstrained": false,
|
|
59
|
+
"isRoaming": NSNull(),
|
|
60
|
+
"downlinkKbps": NSNull(),
|
|
61
|
+
"uplinkKbps": NSNull(),
|
|
62
|
+
"signalStrength": NSNull(),
|
|
63
|
+
"cellularGeneration": NSNull(),
|
|
64
|
+
"supportsIPv4": NSNull(),
|
|
65
|
+
"supportsIPv6": NSNull(),
|
|
66
|
+
"supportsDNS": NSNull(),
|
|
67
|
+
"unsatisfiedReason": "unknown",
|
|
68
|
+
"timestamp": Date().timeIntervalSince1970 * 1_000,
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static func transport(path: NWPath, isConnected: Bool) -> String {
|
|
73
|
+
guard isConnected else { return "none" }
|
|
74
|
+
if path.usesInterfaceType(.wifi) { return "wifi" }
|
|
75
|
+
if path.usesInterfaceType(.cellular) { return "cellular" }
|
|
76
|
+
if path.usesInterfaceType(.wiredEthernet) { return "ethernet" }
|
|
77
|
+
if path.usesInterfaceType(.other) || path.usesInterfaceType(.loopback) {
|
|
78
|
+
return "other"
|
|
79
|
+
}
|
|
80
|
+
return "other"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static func unsatisfiedReason(path: NWPath) -> String? {
|
|
84
|
+
guard path.status != .satisfied else { return nil }
|
|
85
|
+
|
|
86
|
+
if #available(iOS 17.0, *), path.unsatisfiedReason == .vpnInactive {
|
|
87
|
+
return "vpnInactive"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
switch path.unsatisfiedReason {
|
|
91
|
+
case .notAvailable:
|
|
92
|
+
return "notAvailable"
|
|
93
|
+
case .cellularDenied:
|
|
94
|
+
return "cellularDenied"
|
|
95
|
+
case .wifiDenied:
|
|
96
|
+
return "wifiDenied"
|
|
97
|
+
case .localNetworkDenied:
|
|
98
|
+
return "localNetworkDenied"
|
|
99
|
+
case .vpnInactive:
|
|
100
|
+
return "vpnInactive"
|
|
101
|
+
@unknown default:
|
|
102
|
+
return "unknown"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private static func nullable(_ value: String?) -> Any {
|
|
107
|
+
value ?? NSNull()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>NSPrivacyTracking</key>
|
|
6
|
+
<false/>
|
|
7
|
+
<key>NSPrivacyTrackingDomains</key>
|
|
8
|
+
<array/>
|
|
9
|
+
<key>NSPrivacyCollectedDataTypes</key>
|
|
10
|
+
<array/>
|
|
11
|
+
<key>NSPrivacyAccessedAPITypes</key>
|
|
12
|
+
<array>
|
|
13
|
+
<dict>
|
|
14
|
+
<key>NSPrivacyAccessedAPIType</key>
|
|
15
|
+
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
|
16
|
+
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
17
|
+
<array>
|
|
18
|
+
<string>35F9.1</string>
|
|
19
|
+
</array>
|
|
20
|
+
</dict>
|
|
21
|
+
</array>
|
|
22
|
+
</dict>
|
|
23
|
+
</plist>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
protocol NetworkQualityScheduledTask: AnyObject {
|
|
4
|
+
func cancel()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
extension DispatchWorkItem: NetworkQualityScheduledTask {}
|
|
8
|
+
|
|
9
|
+
final class Throttler {
|
|
10
|
+
typealias Clock = () -> Double
|
|
11
|
+
typealias Scheduler = (
|
|
12
|
+
_ delayMs: Double,
|
|
13
|
+
_ action: @escaping () -> Void
|
|
14
|
+
) -> NetworkQualityScheduledTask
|
|
15
|
+
typealias Emitter = ([String: Any]) -> Void
|
|
16
|
+
|
|
17
|
+
private static let numericKeys = [
|
|
18
|
+
"downlinkKbps",
|
|
19
|
+
"uplinkKbps",
|
|
20
|
+
"signalStrength",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
private var throttleMs: Double
|
|
24
|
+
private var bandwidthChangeThresholdPct: Double
|
|
25
|
+
private let clock: Clock
|
|
26
|
+
private let scheduler: Scheduler
|
|
27
|
+
private let emit: Emitter
|
|
28
|
+
|
|
29
|
+
private var lastEmitted: [String: Any]?
|
|
30
|
+
private var lastEmitTime = -Double.infinity
|
|
31
|
+
private var pending: [String: Any]?
|
|
32
|
+
private var timer: NetworkQualityScheduledTask?
|
|
33
|
+
|
|
34
|
+
init(
|
|
35
|
+
throttleMs: Double,
|
|
36
|
+
bandwidthChangeThresholdPct: Double,
|
|
37
|
+
clock: @escaping Clock,
|
|
38
|
+
scheduler: @escaping Scheduler,
|
|
39
|
+
emit: @escaping Emitter
|
|
40
|
+
) {
|
|
41
|
+
self.throttleMs = max(0, throttleMs)
|
|
42
|
+
self.bandwidthChangeThresholdPct = max(0, bandwidthChangeThresholdPct)
|
|
43
|
+
self.clock = clock
|
|
44
|
+
self.scheduler = scheduler
|
|
45
|
+
self.emit = emit
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func update(throttleMs: Double, bandwidthChangeThresholdPct: Double) {
|
|
49
|
+
self.throttleMs = max(0, throttleMs)
|
|
50
|
+
self.bandwidthChangeThresholdPct = max(0, bandwidthChangeThresholdPct)
|
|
51
|
+
guard let pending else { return }
|
|
52
|
+
cancelPending()
|
|
53
|
+
submit(pending)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func submit(_ snapshot: [String: Any]) {
|
|
57
|
+
guard let previous = lastEmitted else {
|
|
58
|
+
emitNow(snapshot)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if Self.equalsIgnoringTimestamp(previous, snapshot) {
|
|
63
|
+
cancelPending()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if Self.isSignificantChange(previous, snapshot) {
|
|
68
|
+
cancelPending()
|
|
69
|
+
emitNow(snapshot)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let changedNumericKeys = Self.numericKeys.filter {
|
|
74
|
+
!Self.valuesEqual(previous[$0], snapshot[$0])
|
|
75
|
+
}
|
|
76
|
+
let crossesThreshold = changedNumericKeys.contains {
|
|
77
|
+
Self.exceedsThreshold(
|
|
78
|
+
oldValue: previous[$0],
|
|
79
|
+
newValue: snapshot[$0],
|
|
80
|
+
thresholdPct: bandwidthChangeThresholdPct
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
guard crossesThreshold else {
|
|
84
|
+
cancelPending()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let now = clock()
|
|
89
|
+
let elapsed = now - lastEmitTime
|
|
90
|
+
if elapsed >= throttleMs {
|
|
91
|
+
cancelPending()
|
|
92
|
+
emitNow(snapshot)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pending = snapshot
|
|
97
|
+
guard timer == nil else { return }
|
|
98
|
+
timer = scheduler(max(0, throttleMs - elapsed)) { [weak self] in
|
|
99
|
+
guard let self else { return }
|
|
100
|
+
self.timer = nil
|
|
101
|
+
guard let pending = self.pending else { return }
|
|
102
|
+
self.pending = nil
|
|
103
|
+
self.emitNow(pending)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func reset() {
|
|
108
|
+
cancelPending()
|
|
109
|
+
lastEmitted = nil
|
|
110
|
+
lastEmitTime = -Double.infinity
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static func equalsIgnoringTimestamp(
|
|
114
|
+
_ first: [String: Any],
|
|
115
|
+
_ second: [String: Any]
|
|
116
|
+
) -> Bool {
|
|
117
|
+
PathSnapshot.comparableKeys.allSatisfy {
|
|
118
|
+
valuesEqual(first[$0], second[$0])
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static func isSignificantChange(
|
|
123
|
+
_ first: [String: Any],
|
|
124
|
+
_ second: [String: Any]
|
|
125
|
+
) -> Bool {
|
|
126
|
+
PathSnapshot.comparableKeys
|
|
127
|
+
.filter { !numericKeys.contains($0) }
|
|
128
|
+
.contains { !valuesEqual(first[$0], second[$0]) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static func exceedsThreshold(
|
|
132
|
+
oldValue: Any?,
|
|
133
|
+
newValue: Any?,
|
|
134
|
+
thresholdPct: Double
|
|
135
|
+
) -> Bool {
|
|
136
|
+
let oldNumber = numericValue(oldValue)
|
|
137
|
+
let newNumber = numericValue(newValue)
|
|
138
|
+
if oldNumber == nil || newNumber == nil {
|
|
139
|
+
return oldNumber != nil || newNumber != nil
|
|
140
|
+
}
|
|
141
|
+
guard let oldNumber, let newNumber else { return false }
|
|
142
|
+
if oldNumber == newNumber { return false }
|
|
143
|
+
if oldNumber == 0 { return true }
|
|
144
|
+
return abs(newNumber - oldNumber) / abs(oldNumber) * 100 >= thresholdPct
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func emitNow(_ snapshot: [String: Any]) {
|
|
148
|
+
lastEmitted = snapshot
|
|
149
|
+
lastEmitTime = clock()
|
|
150
|
+
emit(snapshot)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func cancelPending() {
|
|
154
|
+
timer?.cancel()
|
|
155
|
+
timer = nil
|
|
156
|
+
pending = nil
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private static func numericValue(_ value: Any?) -> Double? {
|
|
160
|
+
guard !(value is NSNull) else { return nil }
|
|
161
|
+
return (value as? NSNumber)?.doubleValue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private static func valuesEqual(_ first: Any?, _ second: Any?) -> Bool {
|
|
165
|
+
switch (first, second) {
|
|
166
|
+
case (nil, nil):
|
|
167
|
+
return true
|
|
168
|
+
case let (first as NSObject, second as NSObject):
|
|
169
|
+
return first.isEqual(second)
|
|
170
|
+
default:
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["TurboModuleRegistry","get"],"sourceRoot":"..\\..\\src","sources":["NativeNetworkQuality.ts"],"mappings":";;AAAA,SACEA,mBAAmB,QAGd,cAAc;AAqDrB,eAAeA,mBAAmB,CAACC,GAAG,CAAO,gBAAgB,CAAC","ignoreList":[]}
|